mcp.test.ts 39 KB

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