mcp.test.ts 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034
  1. /**
  2. * MCP Server Tests
  3. *
  4. * Tests all MCP tools, resources, and prompts.
  5. * Uses mocked Ollama responses and a test database.
  6. */
  7. import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
  8. import { Database } from "bun:sqlite";
  9. import * as sqliteVec from "sqlite-vec";
  10. import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
  11. import { z } from "zod";
  12. import { getDefaultLlamaCpp, disposeDefaultLlamaCpp } from "./llm";
  13. import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises";
  14. import { join } from "node:path";
  15. import { tmpdir } from "node:os";
  16. import YAML from "yaml";
  17. import type { CollectionConfig } from "./collections";
  18. // =============================================================================
  19. // Test Database Setup
  20. // =============================================================================
  21. let testDb: Database;
  22. let testDbPath: string;
  23. let testConfigDir: string;
  24. afterAll(async () => {
  25. // Ensure native resources are released to avoid ggml-metal asserts on process exit.
  26. await disposeDefaultLlamaCpp();
  27. });
  28. function initTestDatabase(db: Database): void {
  29. sqliteVec.load(db);
  30. db.exec("PRAGMA journal_mode = WAL");
  31. // Content-addressable storage - the source of truth for document content
  32. db.exec(`
  33. CREATE TABLE IF NOT EXISTS content (
  34. hash TEXT PRIMARY KEY,
  35. doc TEXT NOT NULL,
  36. created_at TEXT NOT NULL
  37. )
  38. `);
  39. // Documents table - file system layer mapping virtual paths to content hashes
  40. // Collections are now managed in YAML config
  41. db.exec(`
  42. CREATE TABLE IF NOT EXISTS documents (
  43. id INTEGER PRIMARY KEY AUTOINCREMENT,
  44. collection TEXT NOT NULL,
  45. path TEXT NOT NULL,
  46. title TEXT NOT NULL,
  47. hash TEXT NOT NULL,
  48. created_at TEXT NOT NULL,
  49. modified_at TEXT NOT NULL,
  50. active INTEGER NOT NULL DEFAULT 1,
  51. FOREIGN KEY (hash) REFERENCES content(hash) ON DELETE CASCADE,
  52. UNIQUE(collection, path)
  53. )
  54. `);
  55. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_collection ON documents(collection, active)`);
  56. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`);
  57. db.exec(`
  58. CREATE TABLE IF NOT EXISTS llm_cache (
  59. hash TEXT PRIMARY KEY,
  60. result TEXT NOT NULL,
  61. created_at TEXT NOT NULL
  62. )
  63. `);
  64. db.exec(`
  65. CREATE TABLE IF NOT EXISTS content_vectors (
  66. hash TEXT NOT NULL,
  67. seq INTEGER NOT NULL DEFAULT 0,
  68. pos INTEGER NOT NULL DEFAULT 0,
  69. model TEXT NOT NULL,
  70. embedded_at TEXT NOT NULL,
  71. PRIMARY KEY (hash, seq)
  72. )
  73. `);
  74. db.exec(`
  75. CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
  76. name, body,
  77. content='documents',
  78. content_rowid='id',
  79. tokenize='porter unicode61'
  80. )
  81. `);
  82. db.exec(`
  83. CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
  84. INSERT INTO documents_fts(rowid, name, body)
  85. SELECT new.id, new.path, content.doc
  86. FROM content
  87. WHERE content.hash = new.hash;
  88. END
  89. `);
  90. // Create vector table
  91. db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[768] distance_metric=cosine)`);
  92. }
  93. function seedTestData(db: Database): void {
  94. const now = new Date().toISOString();
  95. // Note: Collections are now managed in YAML config, not in database
  96. // For tests, we'll use a collection name "docs"
  97. // Add test documents
  98. const docs = [
  99. {
  100. path: "readme.md",
  101. title: "Project README",
  102. hash: "hash1",
  103. body: "# Project README\n\nThis is the main readme file for the project.\n\nIt contains important information about setup and usage.",
  104. },
  105. {
  106. path: "api.md",
  107. title: "API Documentation",
  108. hash: "hash2",
  109. body: "# API Documentation\n\nThis document describes the REST API endpoints.\n\n## Authentication\n\nUse Bearer tokens for auth.",
  110. },
  111. {
  112. path: "meetings/meeting-2024-01.md",
  113. title: "January Meeting Notes",
  114. hash: "hash3",
  115. body: "# January Meeting Notes\n\nDiscussed Q1 goals and roadmap.\n\n## Action Items\n\n- Review budget\n- Hire new team members",
  116. },
  117. {
  118. path: "meetings/meeting-2024-02.md",
  119. title: "February Meeting Notes",
  120. hash: "hash4",
  121. body: "# February Meeting Notes\n\nFollowed up on Q1 progress.\n\n## Updates\n\n- Budget approved\n- Two candidates interviewed",
  122. },
  123. {
  124. path: "large-file.md",
  125. title: "Large Document",
  126. hash: "hash5",
  127. body: "# Large Document\n\n" + "Lorem ipsum ".repeat(2000), // ~24KB
  128. },
  129. ];
  130. for (const doc of docs) {
  131. // Insert content first
  132. db.prepare(`
  133. INSERT OR IGNORE INTO content (hash, doc, created_at)
  134. VALUES (?, ?, ?)
  135. `).run(doc.hash, doc.body, now);
  136. // Then insert document metadata
  137. db.prepare(`
  138. INSERT INTO documents (collection, path, title, hash, created_at, modified_at, active)
  139. VALUES ('docs', ?, ?, ?, ?, ?, 1)
  140. `).run(doc.path, doc.title, doc.hash, now, now);
  141. }
  142. // Add embeddings for vector search
  143. const embedding = new Float32Array(768);
  144. for (let i = 0; i < 768; i++) embedding[i] = Math.random();
  145. for (const doc of docs.slice(0, 4)) { // Skip large file for embeddings
  146. db.prepare(`INSERT INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, 0, 0, 'embeddinggemma', ?)`).run(doc.hash, now);
  147. db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(`${doc.hash}_0`, embedding);
  148. }
  149. }
  150. // =============================================================================
  151. // MCP Server Test Helpers
  152. // =============================================================================
  153. // We need to create a testable version of the MCP handlers
  154. // Since McpServer uses internal routing, we'll test the handler functions directly
  155. import {
  156. searchFTS,
  157. searchVec,
  158. expandQuery,
  159. rerank,
  160. reciprocalRankFusion,
  161. extractSnippet,
  162. getContextForFile,
  163. findDocument,
  164. getDocumentBody,
  165. findDocuments,
  166. getStatus,
  167. DEFAULT_EMBED_MODEL,
  168. DEFAULT_QUERY_MODEL,
  169. DEFAULT_RERANK_MODEL,
  170. DEFAULT_MULTI_GET_MAX_BYTES,
  171. createStore,
  172. } from "./store";
  173. import type { RankedResult } from "./store";
  174. // Note: searchResultsToMcpCsv no longer used in MCP - using structuredContent instead
  175. // =============================================================================
  176. // Tests
  177. // =============================================================================
  178. describe("MCP Server", () => {
  179. beforeAll(async () => {
  180. // LlamaCpp uses node-llama-cpp for local model inference (no HTTP mocking needed)
  181. // Use shared singleton to avoid creating multiple instances with separate GPU resources
  182. getDefaultLlamaCpp();
  183. // Set up test config directory
  184. const configPrefix = join(tmpdir(), `qmd-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
  185. testConfigDir = await mkdtemp(configPrefix);
  186. process.env.QMD_CONFIG_DIR = testConfigDir;
  187. // Create YAML config with test collection
  188. const testConfig: CollectionConfig = {
  189. collections: {
  190. docs: {
  191. path: "/test/docs",
  192. pattern: "**/*.md",
  193. context: {
  194. "/meetings": "Meeting notes and transcripts"
  195. }
  196. }
  197. }
  198. };
  199. await writeFile(join(testConfigDir, "index.yml"), YAML.stringify(testConfig));
  200. testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`;
  201. testDb = new Database(testDbPath);
  202. initTestDatabase(testDb);
  203. seedTestData(testDb);
  204. });
  205. afterAll(async () => {
  206. testDb.close();
  207. try {
  208. require("fs").unlinkSync(testDbPath);
  209. } catch {}
  210. // Clean up test config directory
  211. try {
  212. const files = await readdir(testConfigDir);
  213. for (const file of files) {
  214. await unlink(join(testConfigDir, file));
  215. }
  216. await rmdir(testConfigDir);
  217. } catch {}
  218. delete process.env.QMD_CONFIG_DIR;
  219. });
  220. // ===========================================================================
  221. // Tool: qmd_search (BM25)
  222. // ===========================================================================
  223. describe("qmd_search tool", () => {
  224. test("returns results for matching query", () => {
  225. const results = searchFTS(testDb, "readme", 10);
  226. expect(results.length).toBeGreaterThan(0);
  227. expect(results[0]!.displayPath).toBe("docs/readme.md");
  228. });
  229. test("returns empty for non-matching query", () => {
  230. const results = searchFTS(testDb, "xyznonexistent", 10);
  231. expect(results.length).toBe(0);
  232. });
  233. test("respects limit parameter", () => {
  234. const results = searchFTS(testDb, "meeting", 1);
  235. expect(results.length).toBe(1);
  236. });
  237. // Note: Collection filtering tests removed - collections are now managed in YAML, not DB
  238. test("formats results as structured content", () => {
  239. const results = searchFTS(testDb, "api", 10);
  240. const filtered = results.map(r => ({
  241. file: r.displayPath,
  242. title: r.title,
  243. score: Math.round(r.score * 100) / 100,
  244. context: getContextForFile(testDb, r.filepath),
  245. snippet: extractSnippet(r.body || "", "api", 300, r.chunkPos).snippet,
  246. }));
  247. // MCP now returns structuredContent with results array
  248. expect(filtered.length).toBeGreaterThan(0);
  249. expect(filtered[0]).toHaveProperty("file");
  250. expect(filtered[0]).toHaveProperty("title");
  251. expect(filtered[0]).toHaveProperty("score");
  252. expect(filtered[0]).toHaveProperty("snippet");
  253. });
  254. });
  255. // ===========================================================================
  256. // Tool: qmd_vector_search (Vector)
  257. // ===========================================================================
  258. describe("qmd_vector_search tool", () => {
  259. test("returns results for semantic query", async () => {
  260. const results = await searchVec(testDb, "project documentation", DEFAULT_EMBED_MODEL, 10);
  261. expect(results.length).toBeGreaterThan(0);
  262. });
  263. test("respects limit parameter", async () => {
  264. const results = await searchVec(testDb, "documentation", DEFAULT_EMBED_MODEL, 2);
  265. expect(results.length).toBeLessThanOrEqual(2);
  266. });
  267. test("returns empty when no vector table exists", async () => {
  268. const emptyDb = new Database(":memory:");
  269. initTestDatabase(emptyDb);
  270. emptyDb.exec("DROP TABLE IF EXISTS vectors_vec");
  271. const results = await searchVec(emptyDb, "test", DEFAULT_EMBED_MODEL, 10);
  272. expect(results.length).toBe(0);
  273. emptyDb.close();
  274. });
  275. });
  276. // ===========================================================================
  277. // Tool: qmd_deep_search (Deep search)
  278. // ===========================================================================
  279. describe("qmd_deep_search tool", () => {
  280. test("expands query with typed variations", async () => {
  281. const expanded = await expandQuery("api documentation", DEFAULT_QUERY_MODEL, testDb);
  282. // Returns ExpandedQuery[] — typed expansions, original excluded
  283. expect(expanded.length).toBeGreaterThanOrEqual(1);
  284. for (const q of expanded) {
  285. expect(['lex', 'vec', 'hyde']).toContain(q.type);
  286. expect(q.text.length).toBeGreaterThan(0);
  287. }
  288. }, 30000); // 30s timeout for model loading
  289. test("performs RRF fusion on multiple result lists", () => {
  290. const list1: RankedResult[] = [
  291. { file: "/a", displayPath: "a.md", title: "A", body: "body", score: 1 },
  292. { file: "/b", displayPath: "b.md", title: "B", body: "body", score: 0.8 },
  293. ];
  294. const list2: RankedResult[] = [
  295. { file: "/b", displayPath: "b.md", title: "B", body: "body", score: 1 },
  296. { file: "/c", displayPath: "c.md", title: "C", body: "body", score: 0.9 },
  297. ];
  298. const fused = reciprocalRankFusion([list1, list2]);
  299. expect(fused.length).toBe(3);
  300. // B appears in both lists, should have higher score
  301. const bResult = fused.find(r => r.file === "/b");
  302. expect(bResult).toBeDefined();
  303. });
  304. test("reranks documents with LLM", async () => {
  305. const docs = [
  306. { file: "/test/docs/readme.md", text: "Project readme" },
  307. { file: "/test/docs/api.md", text: "API documentation" },
  308. ];
  309. const reranked = await rerank("readme", docs, DEFAULT_RERANK_MODEL, testDb);
  310. expect(reranked.length).toBe(2);
  311. expect(reranked[0]!.score).toBeGreaterThan(0);
  312. });
  313. test("full hybrid search pipeline", async () => {
  314. // Simulate full qmd_deep_search flow with type-routed queries
  315. const query = "meeting notes";
  316. const expanded = await expandQuery(query, DEFAULT_QUERY_MODEL, testDb);
  317. const rankedLists: RankedResult[][] = [];
  318. // Original query → FTS (probe)
  319. const probeFts = searchFTS(testDb, query, 20);
  320. if (probeFts.length > 0) {
  321. rankedLists.push(probeFts.map(r => ({
  322. file: r.filepath, displayPath: r.displayPath,
  323. title: r.title, body: r.body || "", score: r.score,
  324. })));
  325. }
  326. // Expanded queries → route by type: lex→FTS, vec/hyde skipped (no vectors in test)
  327. for (const q of expanded) {
  328. if (q.type === 'lex') {
  329. const ftsResults = searchFTS(testDb, q.text, 20);
  330. if (ftsResults.length > 0) {
  331. rankedLists.push(ftsResults.map(r => ({
  332. file: r.filepath, displayPath: r.displayPath,
  333. title: r.title, body: r.body || "", score: r.score,
  334. })));
  335. }
  336. }
  337. // vec/hyde would go to searchVec — not available in this unit test
  338. }
  339. expect(rankedLists.length).toBeGreaterThan(0);
  340. const fused = reciprocalRankFusion(rankedLists);
  341. expect(fused.length).toBeGreaterThan(0);
  342. const candidates = fused.slice(0, 10);
  343. const reranked = await rerank(
  344. query,
  345. candidates.map(c => ({ file: c.file, text: c.body })),
  346. DEFAULT_RERANK_MODEL,
  347. testDb
  348. );
  349. expect(reranked.length).toBeGreaterThan(0);
  350. });
  351. });
  352. // ===========================================================================
  353. // Tool: qmd_get (Get Document)
  354. // ===========================================================================
  355. describe("qmd_get tool", () => {
  356. test("retrieves document by display_path", () => {
  357. const meta = findDocument(testDb, "readme.md", { includeBody: false });
  358. expect("error" in meta).toBe(false);
  359. if ("error" in meta) return;
  360. const body = getDocumentBody(testDb, meta) ?? "";
  361. expect(meta.displayPath).toBe("docs/readme.md");
  362. expect(body).toContain("Project README");
  363. });
  364. test("retrieves document by filepath", () => {
  365. const meta = findDocument(testDb, "/test/docs/api.md", { includeBody: false });
  366. expect("error" in meta).toBe(false);
  367. if ("error" in meta) return;
  368. expect(meta.title).toBe("API Documentation");
  369. });
  370. test("retrieves document by partial path", () => {
  371. const result = findDocument(testDb, "api.md", { includeBody: false });
  372. expect("error" in result).toBe(false);
  373. });
  374. test("returns not found for missing document", () => {
  375. const result = findDocument(testDb, "nonexistent.md", { includeBody: false });
  376. expect("error" in result).toBe(true);
  377. if ("error" in result) {
  378. expect(result.error).toBe("not_found");
  379. }
  380. });
  381. test("suggests similar files when not found", () => {
  382. const result = findDocument(testDb, "readm.md", { includeBody: false }); // typo
  383. expect("error" in result).toBe(true);
  384. if ("error" in result) {
  385. expect(result.similarFiles.length).toBeGreaterThanOrEqual(0);
  386. }
  387. });
  388. test("supports line range with :line suffix", () => {
  389. const meta = findDocument(testDb, "readme.md:2", { includeBody: false });
  390. expect("error" in meta).toBe(false);
  391. if ("error" in meta) return;
  392. const body = getDocumentBody(testDb, meta, 2, 2) ?? "";
  393. const lines = body.split("\n");
  394. expect(lines.length).toBeLessThanOrEqual(2);
  395. });
  396. test("supports fromLine parameter", () => {
  397. const meta = findDocument(testDb, "readme.md", { includeBody: false });
  398. expect("error" in meta).toBe(false);
  399. if ("error" in meta) return;
  400. const body = getDocumentBody(testDb, meta, 3) ?? "";
  401. expect(body).not.toContain("# Project README");
  402. });
  403. test("supports maxLines parameter", () => {
  404. const meta = findDocument(testDb, "api.md", { includeBody: false });
  405. expect("error" in meta).toBe(false);
  406. if ("error" in meta) return;
  407. const body = getDocumentBody(testDb, meta, 1, 3) ?? "";
  408. const lines = body.split("\n");
  409. expect(lines.length).toBeLessThanOrEqual(3);
  410. });
  411. test("includes context for documents in context path", () => {
  412. const result = findDocument(testDb, "meetings/meeting-2024-01.md", { includeBody: false });
  413. expect("error" in result).toBe(false);
  414. if ("error" in result) return;
  415. expect(result.context).toBe("Meeting notes and transcripts");
  416. });
  417. });
  418. // ===========================================================================
  419. // Tool: qmd_multi_get (Multi Get)
  420. // ===========================================================================
  421. describe("qmd_multi_get tool", () => {
  422. test("retrieves multiple documents by glob pattern", () => {
  423. const { docs, errors } = findDocuments(testDb, "meetings/*.md", { includeBody: true });
  424. expect(errors.length).toBe(0);
  425. expect(docs.length).toBe(2);
  426. const paths = docs.map(d => d.doc.displayPath);
  427. expect(paths).toContain("docs/meetings/meeting-2024-01.md");
  428. expect(paths).toContain("docs/meetings/meeting-2024-02.md");
  429. });
  430. test("retrieves documents by comma-separated list", () => {
  431. const { docs, errors } = findDocuments(testDb, "readme.md, api.md", { includeBody: true });
  432. expect(errors.length).toBe(0);
  433. expect(docs.length).toBe(2);
  434. });
  435. test("returns errors for missing files in comma list", () => {
  436. const { docs, errors } = findDocuments(testDb, "readme.md, nonexistent.md", { includeBody: true });
  437. expect(docs.length).toBe(1);
  438. expect(errors.length).toBe(1);
  439. expect(errors[0]).toContain("not found");
  440. });
  441. test("skips files larger than maxBytes", () => {
  442. const { docs } = findDocuments(testDb, "*.md", { includeBody: true, maxBytes: 1000 }); // 1KB limit
  443. const large = docs.find(d => d.doc.displayPath === "docs/large-file.md");
  444. expect(large).toBeDefined();
  445. expect(large?.skipped).toBe(true);
  446. if (large?.skipped) expect(large.skipReason).toContain("too large");
  447. });
  448. test("respects maxLines parameter", () => {
  449. const { docs } = findDocuments(testDb, "readme.md", { includeBody: true, maxBytes: DEFAULT_MULTI_GET_MAX_BYTES });
  450. expect(docs.length).toBe(1);
  451. const d = docs[0]!;
  452. expect(d.skipped).toBe(false);
  453. if (d.skipped) return;
  454. if (!("body" in d.doc)) {
  455. throw new Error("Expected body to be included in findDocuments result");
  456. }
  457. const lines = (d.doc.body || "").split("\n").slice(0, 2);
  458. expect(lines.length).toBeLessThanOrEqual(2);
  459. });
  460. test("returns error for non-matching glob", () => {
  461. const { docs, errors } = findDocuments(testDb, "nonexistent/*.md", { includeBody: true });
  462. expect(docs.length).toBe(0);
  463. expect(errors.length).toBe(1);
  464. expect(errors[0]).toContain("No files matched");
  465. });
  466. test("includes context in results", () => {
  467. const { docs } = findDocuments(testDb, "meetings/meeting-2024-01.md", { includeBody: true });
  468. expect(docs.length).toBe(1);
  469. const d = docs[0]!;
  470. expect(d.skipped).toBe(false);
  471. if (d.skipped) return;
  472. if (!("context" in d.doc)) {
  473. throw new Error("Expected context to be present on document result");
  474. }
  475. expect(d.doc.context).toBe("Meeting notes and transcripts");
  476. });
  477. });
  478. // ===========================================================================
  479. // Tool: qmd_status
  480. // ===========================================================================
  481. describe("qmd_status tool", () => {
  482. test("returns index status", () => {
  483. const status = getStatus(testDb);
  484. expect(status.totalDocuments).toBe(5);
  485. expect(status.hasVectorIndex).toBe(true);
  486. expect(status.collections.length).toBe(1);
  487. expect(status.collections[0]!.path).toBe("/test/docs");
  488. });
  489. test("shows documents needing embedding", () => {
  490. const status = getStatus(testDb);
  491. // large-file.md doesn't have embeddings
  492. expect(status.needsEmbedding).toBe(1);
  493. });
  494. });
  495. // ===========================================================================
  496. // Resource: qmd://{path}
  497. // ===========================================================================
  498. describe("qmd:// resource", () => {
  499. test("lists all documents", () => {
  500. const docs = testDb.prepare(`
  501. SELECT path as display_path, title
  502. FROM documents
  503. WHERE active = 1
  504. ORDER BY modified_at DESC
  505. LIMIT 1000
  506. `).all() as { display_path: string; title: string }[];
  507. expect(docs.length).toBe(5);
  508. expect(docs.map(d => d.display_path)).toContain("readme.md");
  509. });
  510. test("reads document by display_path", () => {
  511. const path = "readme.md";
  512. const doc = testDb.prepare(`
  513. SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
  514. FROM documents d
  515. JOIN content ON content.hash = d.hash
  516. WHERE d.path = ? AND d.active = 1
  517. `).get(path) as { filepath: string; display_path: string; body: string } | null;
  518. expect(doc).not.toBeNull();
  519. expect(doc?.body).toContain("Project README");
  520. });
  521. test("reads document by URL-encoded path", () => {
  522. // Simulate URL encoding that MCP clients may send
  523. const encodedPath = "meetings%2Fmeeting-2024-01.md";
  524. const decodedPath = decodeURIComponent(encodedPath);
  525. const doc = testDb.prepare(`
  526. SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
  527. FROM documents d
  528. JOIN content ON content.hash = d.hash
  529. WHERE d.path = ? AND d.active = 1
  530. `).get(decodedPath) as { filepath: string; display_path: string; body: string } | null;
  531. expect(doc).not.toBeNull();
  532. expect(doc?.display_path).toBe("meetings/meeting-2024-01.md");
  533. });
  534. test("reads document by suffix match", () => {
  535. const path = "meeting-2024-01.md"; // without meetings/ prefix
  536. let doc = testDb.prepare(`
  537. SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
  538. FROM documents d
  539. JOIN content ON content.hash = d.hash
  540. WHERE d.path = ? AND d.active = 1
  541. `).get(path) as { filepath: string; display_path: string; body: string } | null;
  542. if (!doc) {
  543. doc = testDb.prepare(`
  544. SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
  545. FROM documents d
  546. JOIN content ON content.hash = d.hash
  547. WHERE d.path LIKE ? AND d.active = 1
  548. LIMIT 1
  549. `).get(`%${path}`) as { filepath: string; display_path: string; body: string } | null;
  550. }
  551. expect(doc).not.toBeNull();
  552. expect(doc?.display_path).toBe("meetings/meeting-2024-01.md");
  553. });
  554. test("returns not found for missing document", () => {
  555. const path = "nonexistent.md";
  556. const doc = testDb.prepare(`
  557. SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
  558. FROM documents d
  559. JOIN content ON content.hash = d.hash
  560. WHERE d.path = ? AND d.active = 1
  561. `).get(path) as { filepath: string; display_path: string; body: string } | null;
  562. expect(doc).toBeNull();
  563. });
  564. test("includes context in document body", () => {
  565. const path = "meetings/meeting-2024-01.md";
  566. const doc = testDb.prepare(`
  567. SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
  568. FROM documents d
  569. JOIN content ON content.hash = d.hash
  570. WHERE d.path = ? AND d.active = 1
  571. `).get(path) as { filepath: string; display_path: string; body: string } | null;
  572. expect(doc).not.toBeNull();
  573. const context = getContextForFile(testDb, doc!.filepath);
  574. expect(context).toBe("Meeting notes and transcripts");
  575. // Verify context would be prepended
  576. let text = doc!.body;
  577. if (context) {
  578. text = `<!-- Context: ${context} -->\n\n` + text;
  579. }
  580. expect(text).toContain("<!-- Context: Meeting notes and transcripts -->");
  581. });
  582. test("handles URL-encoded special characters", () => {
  583. // Test various URL encodings
  584. const testCases = [
  585. { encoded: "readme.md", decoded: "readme.md" },
  586. { encoded: "meetings%2Fmeeting-2024-01.md", decoded: "meetings/meeting-2024-01.md" },
  587. { encoded: "api.md%3A10", decoded: "api.md:10" }, // with line number
  588. ];
  589. for (const { encoded, decoded } of testCases) {
  590. expect(decodeURIComponent(encoded)).toBe(decoded);
  591. }
  592. });
  593. test("handles double-encoded URLs", () => {
  594. // Some clients may double-encode
  595. const doubleEncoded = "meetings%252Fmeeting-2024-01.md";
  596. const singleDecoded = decodeURIComponent(doubleEncoded);
  597. expect(singleDecoded).toBe("meetings%2Fmeeting-2024-01.md");
  598. const fullyDecoded = decodeURIComponent(singleDecoded);
  599. expect(fullyDecoded).toBe("meetings/meeting-2024-01.md");
  600. });
  601. test("handles URL-encoded paths with spaces", () => {
  602. // Add a document with spaces in the path
  603. const now = new Date().toISOString();
  604. const body = "# Podcast Episode\n\nInterview content here.";
  605. const hash = "hash_spaces";
  606. const path = "External Podcast/2023 April - Interview.md";
  607. // Insert content first
  608. testDb.prepare(`
  609. INSERT OR IGNORE INTO content (hash, doc, created_at)
  610. VALUES (?, ?, ?)
  611. `).run(hash, body, now);
  612. // Then insert document metadata
  613. testDb.prepare(`
  614. INSERT INTO documents (collection, path, title, hash, created_at, modified_at, active)
  615. VALUES ('docs', ?, ?, ?, ?, ?, 1)
  616. `).run(path, "Podcast Episode", hash, now, now);
  617. // Simulate URL-encoded path from MCP client
  618. const encodedPath = "External%20Podcast%2F2023%20April%20-%20Interview.md";
  619. const decodedPath = decodeURIComponent(encodedPath);
  620. expect(decodedPath).toBe("External Podcast/2023 April - Interview.md");
  621. const doc = testDb.prepare(`
  622. SELECT 'qmd://' || d.collection || '/' || d.path as filepath, d.path as display_path, content.doc as body
  623. FROM documents d
  624. JOIN content ON content.hash = d.hash
  625. WHERE d.path = ? AND d.active = 1
  626. `).get(decodedPath) as { filepath: string; display_path: string; body: string } | null;
  627. expect(doc).not.toBeNull();
  628. expect(doc?.display_path).toBe("External Podcast/2023 April - Interview.md");
  629. expect(doc?.body).toContain("Podcast Episode");
  630. });
  631. });
  632. // ===========================================================================
  633. // Edge Cases
  634. // ===========================================================================
  635. describe("edge cases", () => {
  636. test("handles empty query", () => {
  637. const results = searchFTS(testDb, "", 10);
  638. expect(results.length).toBe(0);
  639. });
  640. test("handles special characters in query", () => {
  641. const results = searchFTS(testDb, "project's", 10);
  642. // Should not throw
  643. expect(Array.isArray(results)).toBe(true);
  644. });
  645. test("handles unicode in query", () => {
  646. const results = searchFTS(testDb, "文档", 10);
  647. expect(Array.isArray(results)).toBe(true);
  648. });
  649. test("handles very long query", () => {
  650. const longQuery = "documentation ".repeat(100);
  651. const results = searchFTS(testDb, longQuery, 10);
  652. expect(Array.isArray(results)).toBe(true);
  653. });
  654. test("handles query with only stopwords", () => {
  655. const results = searchFTS(testDb, "the and or", 10);
  656. expect(Array.isArray(results)).toBe(true);
  657. });
  658. test("extracts snippet around matching text", () => {
  659. const body = "Line 1\nLine 2\nThis is the important line with the keyword\nLine 4\nLine 5";
  660. const { line, snippet } = extractSnippet(body, "keyword", 200);
  661. expect(snippet).toContain("keyword");
  662. expect(line).toBe(3);
  663. });
  664. test("handles snippet extraction with chunkPos", () => {
  665. const body = "A".repeat(1000) + "KEYWORD" + "B".repeat(1000);
  666. const chunkPos = 1000; // Position of KEYWORD
  667. const { snippet } = extractSnippet(body, "keyword", 200, chunkPos);
  668. expect(snippet).toContain("KEYWORD");
  669. });
  670. });
  671. // ===========================================================================
  672. // MCP Spec Compliance
  673. // ===========================================================================
  674. describe("MCP spec compliance", () => {
  675. test("encodeQmdPath preserves slashes but encodes special chars", () => {
  676. // Helper function behavior (tested indirectly through resource URIs)
  677. const path = "External Podcast/2023 April - Interview.md";
  678. const segments = path.split('/').map(s => encodeURIComponent(s)).join('/');
  679. expect(segments).toBe("External%20Podcast/2023%20April%20-%20Interview.md");
  680. expect(segments).toContain("/"); // Slashes preserved
  681. expect(segments).toContain("%20"); // Spaces encoded
  682. });
  683. test("search results have correct structure for structuredContent", () => {
  684. const results = searchFTS(testDb, "readme", 5);
  685. const structured = results.map(r => ({
  686. file: r.displayPath,
  687. title: r.title,
  688. score: Math.round(r.score * 100) / 100,
  689. context: getContextForFile(testDb, r.filepath),
  690. snippet: extractSnippet(r.body || "", "readme", 300, r.chunkPos).snippet,
  691. }));
  692. expect(structured.length).toBeGreaterThan(0);
  693. const item = structured[0]!;
  694. expect(typeof item.file).toBe("string");
  695. expect(typeof item.title).toBe("string");
  696. expect(typeof item.score).toBe("number");
  697. expect(item.score).toBeGreaterThanOrEqual(0);
  698. expect(item.score).toBeLessThanOrEqual(1);
  699. expect(typeof item.snippet).toBe("string");
  700. });
  701. test("error responses should include isError flag", () => {
  702. // Simulate what MCP server returns for errors
  703. const errorResponse = {
  704. content: [{ type: "text", text: "Collection not found: nonexistent" }],
  705. isError: true,
  706. };
  707. expect(errorResponse.isError).toBe(true);
  708. expect(errorResponse.content[0]!.type).toBe("text");
  709. });
  710. test("embedded resources include name and title", () => {
  711. // Simulate what qmd_get returns
  712. const meta = findDocument(testDb, "readme.md", { includeBody: false });
  713. expect("error" in meta).toBe(false);
  714. if ("error" in meta) return;
  715. const body = getDocumentBody(testDb, meta) ?? "";
  716. const resource = {
  717. uri: `qmd://${meta.displayPath}`,
  718. name: meta.displayPath,
  719. title: meta.title,
  720. mimeType: "text/markdown",
  721. text: body,
  722. };
  723. expect(resource.name).toBe("docs/readme.md");
  724. expect(resource.title).toBe("Project README");
  725. expect(resource.mimeType).toBe("text/markdown");
  726. });
  727. test("status response includes structuredContent", () => {
  728. const status = getStatus(testDb);
  729. // Verify structure matches StatusResult type
  730. expect(typeof status.totalDocuments).toBe("number");
  731. expect(typeof status.needsEmbedding).toBe("number");
  732. expect(typeof status.hasVectorIndex).toBe("boolean");
  733. expect(Array.isArray(status.collections)).toBe(true);
  734. if (status.collections.length > 0) {
  735. const col = status.collections[0]!;
  736. expect(typeof col.name).toBe("string"); // Collections now use names, not IDs
  737. expect(typeof col.path).toBe("string");
  738. expect(typeof col.pattern).toBe("string");
  739. expect(typeof col.documents).toBe("number");
  740. }
  741. });
  742. });
  743. });
  744. // =============================================================================
  745. // HTTP Transport Tests
  746. // =============================================================================
  747. import { startMcpHttpServer, type HttpServerHandle } from "./mcp";
  748. import { enableProductionMode } from "./store";
  749. describe("MCP HTTP Transport", () => {
  750. let handle: HttpServerHandle;
  751. let baseUrl: string;
  752. let httpTestDbPath: string;
  753. let httpTestConfigDir: string;
  754. // Stash original env to restore after tests
  755. const origIndexPath = process.env.INDEX_PATH;
  756. const origConfigDir = process.env.QMD_CONFIG_DIR;
  757. beforeAll(async () => {
  758. // Create isolated test database with seeded data
  759. httpTestDbPath = `/tmp/qmd-mcp-http-test-${Date.now()}.sqlite`;
  760. const db = new Database(httpTestDbPath);
  761. initTestDatabase(db);
  762. seedTestData(db);
  763. db.close();
  764. // Create isolated YAML config
  765. const configPrefix = join(tmpdir(), `qmd-mcp-http-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
  766. httpTestConfigDir = await mkdtemp(configPrefix);
  767. const testConfig: CollectionConfig = {
  768. collections: {
  769. docs: {
  770. path: "/test/docs",
  771. pattern: "**/*.md",
  772. }
  773. }
  774. };
  775. await writeFile(join(httpTestConfigDir, "index.yml"), YAML.stringify(testConfig));
  776. // Point createStore() at our test DB
  777. process.env.INDEX_PATH = httpTestDbPath;
  778. process.env.QMD_CONFIG_DIR = httpTestConfigDir;
  779. handle = await startMcpHttpServer(0, { quiet: true }); // OS-assigned ephemeral port
  780. baseUrl = `http://localhost:${handle.port}`;
  781. });
  782. afterAll(async () => {
  783. await handle.stop();
  784. // Restore env
  785. if (origIndexPath !== undefined) process.env.INDEX_PATH = origIndexPath;
  786. else delete process.env.INDEX_PATH;
  787. if (origConfigDir !== undefined) process.env.QMD_CONFIG_DIR = origConfigDir;
  788. else delete process.env.QMD_CONFIG_DIR;
  789. // Clean up test files
  790. try { require("fs").unlinkSync(httpTestDbPath); } catch {}
  791. try {
  792. const files = await readdir(httpTestConfigDir);
  793. for (const f of files) await unlink(join(httpTestConfigDir, f));
  794. await rmdir(httpTestConfigDir);
  795. } catch {}
  796. });
  797. // ---------------------------------------------------------------------------
  798. // Health & routing
  799. // ---------------------------------------------------------------------------
  800. test("GET /health returns 200 with status and uptime", async () => {
  801. const res = await fetch(`${baseUrl}/health`);
  802. expect(res.status).toBe(200);
  803. expect(res.headers.get("content-type")).toContain("application/json");
  804. const body = await res.json();
  805. expect(body.status).toBe("ok");
  806. expect(typeof body.uptime).toBe("number");
  807. });
  808. test("GET /other returns 404", async () => {
  809. const res = await fetch(`${baseUrl}/other`);
  810. expect(res.status).toBe(404);
  811. });
  812. // ---------------------------------------------------------------------------
  813. // MCP protocol over HTTP
  814. // ---------------------------------------------------------------------------
  815. /** Send a JSON-RPC message to /mcp and return the parsed response.
  816. * MCP Streamable HTTP requires Accept header with both JSON and SSE. */
  817. async function mcpRequest(body: object): Promise<{ status: number; json: any; contentType: string | null }> {
  818. const res = await fetch(`${baseUrl}/mcp`, {
  819. method: "POST",
  820. headers: {
  821. "Content-Type": "application/json",
  822. "Accept": "application/json, text/event-stream",
  823. },
  824. body: JSON.stringify(body),
  825. });
  826. const json = await res.json();
  827. return { status: res.status, json, contentType: res.headers.get("content-type") };
  828. }
  829. test("POST /mcp initialize returns 200 JSON (not SSE)", async () => {
  830. const { status, json, contentType } = await mcpRequest({
  831. jsonrpc: "2.0",
  832. id: 1,
  833. method: "initialize",
  834. params: {
  835. protocolVersion: "2025-03-26",
  836. capabilities: {},
  837. clientInfo: { name: "test-client", version: "1.0.0" },
  838. },
  839. });
  840. expect(status).toBe(200);
  841. expect(contentType).toContain("application/json");
  842. expect(json.jsonrpc).toBe("2.0");
  843. expect(json.id).toBe(1);
  844. expect(json.result.serverInfo.name).toBe("qmd");
  845. });
  846. test("POST /mcp tools/list returns registered tools", async () => {
  847. // Initialize first (required by MCP protocol)
  848. await mcpRequest({
  849. jsonrpc: "2.0", id: 1, method: "initialize",
  850. params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1.0" } },
  851. });
  852. const { status, json, contentType } = await mcpRequest({
  853. jsonrpc: "2.0", id: 2, method: "tools/list", params: {},
  854. });
  855. expect(status).toBe(200);
  856. expect(contentType).toContain("application/json");
  857. const toolNames = json.result.tools.map((t: any) => t.name);
  858. expect(toolNames).toContain("search");
  859. expect(toolNames).toContain("get");
  860. expect(toolNames).toContain("status");
  861. });
  862. test("POST /mcp tools/call search returns results", async () => {
  863. // Initialize
  864. await mcpRequest({
  865. jsonrpc: "2.0", id: 1, method: "initialize",
  866. params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1.0" } },
  867. });
  868. const { status, json } = await mcpRequest({
  869. jsonrpc: "2.0", id: 3, method: "tools/call",
  870. params: { name: "search", arguments: { query: "readme" } },
  871. });
  872. expect(status).toBe(200);
  873. expect(json.result).toBeDefined();
  874. // Should have content array with text results
  875. expect(json.result.content.length).toBeGreaterThan(0);
  876. expect(json.result.content[0].type).toBe("text");
  877. });
  878. test("POST /mcp tools/call get returns document", async () => {
  879. // Initialize
  880. await mcpRequest({
  881. jsonrpc: "2.0", id: 1, method: "initialize",
  882. params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1.0" } },
  883. });
  884. const { status, json } = await mcpRequest({
  885. jsonrpc: "2.0", id: 4, method: "tools/call",
  886. params: { name: "get", arguments: { path: "readme.md" } },
  887. });
  888. expect(status).toBe(200);
  889. expect(json.result).toBeDefined();
  890. expect(json.result.content.length).toBeGreaterThan(0);
  891. });
  892. });