mcp.test.ts 39 KB

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