mcp.test.ts 39 KB

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