mcp.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918
  1. /**
  2. * MCP Server Tests
  3. *
  4. * Tests all MCP tools, resources, and prompts.
  5. * Uses mocked Ollama responses and a test database.
  6. */
  7. import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
  8. import { Database } from "bun:sqlite";
  9. import * as sqliteVec from "sqlite-vec";
  10. import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
  11. import { z } from "zod";
  12. import { setDefaultOllama, Ollama } from "./llm";
  13. // =============================================================================
  14. // Mock Ollama
  15. // =============================================================================
  16. const OLLAMA_URL = "http://localhost:11434";
  17. const originalFetch = globalThis.fetch;
  18. const mockOllamaResponses: Record<string, (body: unknown) => Response> = {
  19. "/api/embed": () => {
  20. const embedding = Array(768).fill(0).map(() => Math.random());
  21. return new Response(JSON.stringify({ embeddings: [embedding] }), {
  22. status: 200,
  23. headers: { "Content-Type": "application/json" },
  24. });
  25. },
  26. "/api/generate": (body: unknown) => {
  27. const reqBody = body as { prompt?: string };
  28. if (reqBody.prompt?.includes("Judge") || reqBody.prompt?.includes("Document")) {
  29. return new Response(JSON.stringify({
  30. response: "yes",
  31. done: true,
  32. logprobs: { tokens: ["yes"], token_logprobs: [-0.1] },
  33. }), { status: 200, headers: { "Content-Type": "application/json" } });
  34. } else {
  35. return new Response(JSON.stringify({
  36. response: "expanded query variation 1\nexpanded query variation 2",
  37. done: true,
  38. }), { status: 200, headers: { "Content-Type": "application/json" } });
  39. }
  40. },
  41. "/api/show": () => {
  42. return new Response(JSON.stringify({ size: 1000000 }), {
  43. status: 200,
  44. headers: { "Content-Type": "application/json" },
  45. });
  46. },
  47. };
  48. function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  49. const url = typeof input === "string" ? input : input.toString();
  50. if (url.startsWith(OLLAMA_URL)) {
  51. const path = url.replace(OLLAMA_URL, "");
  52. const handler = mockOllamaResponses[path];
  53. if (handler) {
  54. const body = init?.body ? JSON.parse(init.body as string) : {};
  55. return Promise.resolve(handler(body));
  56. }
  57. throw new Error(`Unmocked Ollama endpoint: ${path}`);
  58. }
  59. throw new Error(`Unexpected fetch call to: ${url}`);
  60. }
  61. // =============================================================================
  62. // Test Database Setup
  63. // =============================================================================
  64. let testDb: Database;
  65. let testDbPath: string;
  66. function initTestDatabase(db: Database): void {
  67. sqliteVec.load(db);
  68. db.exec("PRAGMA journal_mode = WAL");
  69. db.exec(`
  70. CREATE TABLE IF NOT EXISTS collections (
  71. id INTEGER PRIMARY KEY AUTOINCREMENT,
  72. pwd TEXT NOT NULL,
  73. glob_pattern TEXT NOT NULL,
  74. created_at TEXT NOT NULL,
  75. context TEXT,
  76. UNIQUE(pwd, glob_pattern)
  77. )
  78. `);
  79. db.exec(`
  80. CREATE TABLE IF NOT EXISTS path_contexts (
  81. id INTEGER PRIMARY KEY AUTOINCREMENT,
  82. path_prefix TEXT NOT NULL UNIQUE,
  83. context TEXT NOT NULL,
  84. created_at TEXT NOT NULL
  85. )
  86. `);
  87. db.exec(`
  88. CREATE TABLE IF NOT EXISTS ollama_cache (
  89. hash TEXT PRIMARY KEY,
  90. result TEXT NOT NULL,
  91. created_at TEXT NOT NULL
  92. )
  93. `);
  94. db.exec(`
  95. CREATE TABLE IF NOT EXISTS documents (
  96. id INTEGER PRIMARY KEY AUTOINCREMENT,
  97. collection_id INTEGER NOT NULL,
  98. name TEXT NOT NULL,
  99. title TEXT NOT NULL,
  100. hash TEXT NOT NULL,
  101. filepath TEXT NOT NULL,
  102. display_path TEXT NOT NULL DEFAULT '',
  103. body TEXT NOT NULL,
  104. created_at TEXT NOT NULL,
  105. modified_at TEXT NOT NULL,
  106. active INTEGER NOT NULL DEFAULT 1,
  107. FOREIGN KEY (collection_id) REFERENCES collections(id)
  108. )
  109. `);
  110. db.exec(`
  111. CREATE TABLE IF NOT EXISTS content_vectors (
  112. hash TEXT NOT NULL,
  113. seq INTEGER NOT NULL DEFAULT 0,
  114. pos INTEGER NOT NULL DEFAULT 0,
  115. model TEXT NOT NULL,
  116. embedded_at TEXT NOT NULL,
  117. PRIMARY KEY (hash, seq)
  118. )
  119. `);
  120. db.exec(`
  121. CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
  122. name, body,
  123. content='documents',
  124. content_rowid='id',
  125. tokenize='porter unicode61'
  126. )
  127. `);
  128. db.exec(`
  129. CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
  130. INSERT INTO documents_fts(rowid, name, body) VALUES (new.id, new.name, new.body);
  131. END
  132. `);
  133. // Create vector table
  134. db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[768])`);
  135. }
  136. function seedTestData(db: Database): void {
  137. const now = new Date().toISOString();
  138. // Create a collection
  139. db.prepare(`INSERT INTO collections (pwd, glob_pattern, created_at, context) VALUES (?, ?, ?, ?)`).run(
  140. "/test/docs",
  141. "**/*.md",
  142. now,
  143. "Test documentation collection"
  144. );
  145. // Add path context
  146. db.prepare(`INSERT INTO path_contexts (path_prefix, context, created_at) VALUES (?, ?, ?)`).run(
  147. "/test/docs/meetings",
  148. "Meeting notes and transcripts",
  149. now
  150. );
  151. // Add test documents
  152. const docs = [
  153. {
  154. name: "readme.md",
  155. title: "Project README",
  156. hash: "hash1",
  157. filepath: "/test/docs/readme.md",
  158. display_path: "readme.md",
  159. body: "# Project README\n\nThis is the main readme file for the project.\n\nIt contains important information about setup and usage.",
  160. },
  161. {
  162. name: "api.md",
  163. title: "API Documentation",
  164. hash: "hash2",
  165. filepath: "/test/docs/api.md",
  166. display_path: "api.md",
  167. body: "# API Documentation\n\nThis document describes the REST API endpoints.\n\n## Authentication\n\nUse Bearer tokens for auth.",
  168. },
  169. {
  170. name: "meeting-2024-01.md",
  171. title: "January Meeting Notes",
  172. hash: "hash3",
  173. filepath: "/test/docs/meetings/meeting-2024-01.md",
  174. display_path: "meetings/meeting-2024-01.md",
  175. body: "# January Meeting Notes\n\nDiscussed Q1 goals and roadmap.\n\n## Action Items\n\n- Review budget\n- Hire new team members",
  176. },
  177. {
  178. name: "meeting-2024-02.md",
  179. title: "February Meeting Notes",
  180. hash: "hash4",
  181. filepath: "/test/docs/meetings/meeting-2024-02.md",
  182. display_path: "meetings/meeting-2024-02.md",
  183. body: "# February Meeting Notes\n\nFollowed up on Q1 progress.\n\n## Updates\n\n- Budget approved\n- Two candidates interviewed",
  184. },
  185. {
  186. name: "large-file.md",
  187. title: "Large Document",
  188. hash: "hash5",
  189. filepath: "/test/docs/large-file.md",
  190. display_path: "large-file.md",
  191. body: "# Large Document\n\n" + "Lorem ipsum ".repeat(2000), // ~24KB
  192. },
  193. ];
  194. for (const doc of docs) {
  195. db.prepare(`
  196. INSERT INTO documents (collection_id, name, title, hash, filepath, display_path, body, created_at, modified_at, active)
  197. VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, 1)
  198. `).run(doc.name, doc.title, doc.hash, doc.filepath, doc.display_path, doc.body, now, now);
  199. }
  200. // Add embeddings for vector search
  201. const embedding = new Float32Array(768);
  202. for (let i = 0; i < 768; i++) embedding[i] = Math.random();
  203. for (const doc of docs.slice(0, 4)) { // Skip large file for embeddings
  204. db.prepare(`INSERT INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, 0, 0, 'embeddinggemma', ?)`).run(doc.hash, now);
  205. db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(`${doc.hash}_0`, embedding);
  206. }
  207. }
  208. // =============================================================================
  209. // MCP Server Test Helpers
  210. // =============================================================================
  211. // We need to create a testable version of the MCP handlers
  212. // Since McpServer uses internal routing, we'll test the handler functions directly
  213. import {
  214. searchFTS,
  215. searchVec,
  216. expandQuery,
  217. rerank,
  218. reciprocalRankFusion,
  219. extractSnippet,
  220. getContextForFile,
  221. getCollectionIdByName,
  222. getDocument,
  223. getMultipleDocuments,
  224. getStatus,
  225. DEFAULT_EMBED_MODEL,
  226. DEFAULT_QUERY_MODEL,
  227. DEFAULT_RERANK_MODEL,
  228. DEFAULT_MULTI_GET_MAX_BYTES,
  229. createStore,
  230. } from "./store";
  231. import type { RankedResult } from "./store";
  232. // Note: searchResultsToMcpCsv no longer used in MCP - using structuredContent instead
  233. // =============================================================================
  234. // Tests
  235. // =============================================================================
  236. describe("MCP Server", () => {
  237. beforeAll(() => {
  238. globalThis.fetch = mockFetch as typeof fetch;
  239. setDefaultOllama(new Ollama({ baseUrl: OLLAMA_URL }));
  240. testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`;
  241. testDb = new Database(testDbPath);
  242. initTestDatabase(testDb);
  243. seedTestData(testDb);
  244. });
  245. afterAll(() => {
  246. globalThis.fetch = originalFetch;
  247. setDefaultOllama(null);
  248. testDb.close();
  249. try {
  250. require("fs").unlinkSync(testDbPath);
  251. } catch {}
  252. });
  253. // ===========================================================================
  254. // Tool: qmd_search (BM25)
  255. // ===========================================================================
  256. describe("qmd_search tool", () => {
  257. test("returns results for matching query", () => {
  258. const results = searchFTS(testDb, "readme", 10);
  259. expect(results.length).toBeGreaterThan(0);
  260. expect(results[0].displayPath).toBe("readme.md");
  261. });
  262. test("returns empty for non-matching query", () => {
  263. const results = searchFTS(testDb, "xyznonexistent", 10);
  264. expect(results.length).toBe(0);
  265. });
  266. test("respects limit parameter", () => {
  267. const results = searchFTS(testDb, "meeting", 1);
  268. expect(results.length).toBe(1);
  269. });
  270. test("filters by collection", () => {
  271. const collectionId = getCollectionIdByName(testDb, "docs");
  272. expect(collectionId).toBe(1);
  273. const results = searchFTS(testDb, "meeting", 10, collectionId!);
  274. expect(results.length).toBeGreaterThan(0);
  275. });
  276. test("returns null for non-existent collection", () => {
  277. const collectionId = getCollectionIdByName(testDb, "nonexistent");
  278. expect(collectionId).toBeNull();
  279. });
  280. test("formats results as structured content", () => {
  281. const results = searchFTS(testDb, "api", 10);
  282. const filtered = results.map(r => ({
  283. file: r.displayPath,
  284. title: r.title,
  285. score: Math.round(r.score * 100) / 100,
  286. context: getContextForFile(testDb, r.file),
  287. snippet: extractSnippet(r.body, "api", 300, r.chunkPos).snippet,
  288. }));
  289. // MCP now returns structuredContent with results array
  290. expect(filtered.length).toBeGreaterThan(0);
  291. expect(filtered[0]).toHaveProperty("file");
  292. expect(filtered[0]).toHaveProperty("title");
  293. expect(filtered[0]).toHaveProperty("score");
  294. expect(filtered[0]).toHaveProperty("snippet");
  295. });
  296. });
  297. // ===========================================================================
  298. // Tool: qmd_vsearch (Vector)
  299. // ===========================================================================
  300. describe("qmd_vsearch tool", () => {
  301. test("returns results for semantic query", async () => {
  302. const results = await searchVec(testDb, "project documentation", DEFAULT_EMBED_MODEL, 10);
  303. expect(results.length).toBeGreaterThan(0);
  304. });
  305. test("respects limit parameter", async () => {
  306. const results = await searchVec(testDb, "documentation", DEFAULT_EMBED_MODEL, 2);
  307. expect(results.length).toBeLessThanOrEqual(2);
  308. });
  309. test("returns empty when no vector table exists", async () => {
  310. const emptyDb = new Database(":memory:");
  311. initTestDatabase(emptyDb);
  312. emptyDb.exec("DROP TABLE IF EXISTS vectors_vec");
  313. const results = await searchVec(emptyDb, "test", DEFAULT_EMBED_MODEL, 10);
  314. expect(results.length).toBe(0);
  315. emptyDb.close();
  316. });
  317. });
  318. // ===========================================================================
  319. // Tool: qmd_query (Hybrid)
  320. // ===========================================================================
  321. describe("qmd_query tool", () => {
  322. test("expands query with variations", async () => {
  323. const queries = await expandQuery("api documentation", DEFAULT_QUERY_MODEL, testDb);
  324. expect(queries.length).toBeGreaterThan(1);
  325. expect(queries[0]).toBe("api documentation");
  326. });
  327. test("performs RRF fusion on multiple result lists", () => {
  328. const list1: RankedResult[] = [
  329. { file: "/a", displayPath: "a.md", title: "A", body: "body", score: 1 },
  330. { file: "/b", displayPath: "b.md", title: "B", body: "body", score: 0.8 },
  331. ];
  332. const list2: RankedResult[] = [
  333. { file: "/b", displayPath: "b.md", title: "B", body: "body", score: 1 },
  334. { file: "/c", displayPath: "c.md", title: "C", body: "body", score: 0.9 },
  335. ];
  336. const fused = reciprocalRankFusion([list1, list2]);
  337. expect(fused.length).toBe(3);
  338. // B appears in both lists, should have higher score
  339. const bResult = fused.find(r => r.file === "/b");
  340. expect(bResult).toBeDefined();
  341. });
  342. test("reranks documents with LLM", async () => {
  343. const docs = [
  344. { file: "/test/docs/readme.md", text: "Project readme" },
  345. { file: "/test/docs/api.md", text: "API documentation" },
  346. ];
  347. const reranked = await rerank("readme", docs, DEFAULT_RERANK_MODEL, testDb);
  348. expect(reranked.length).toBe(2);
  349. expect(reranked[0].score).toBeGreaterThan(0);
  350. });
  351. test("full hybrid search pipeline", async () => {
  352. // Simulate full qmd_query flow
  353. const query = "meeting notes";
  354. const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, testDb);
  355. const rankedLists: RankedResult[][] = [];
  356. for (const q of queries) {
  357. const ftsResults = searchFTS(testDb, q, 20);
  358. if (ftsResults.length > 0) {
  359. rankedLists.push(ftsResults.map(r => ({
  360. file: r.file,
  361. displayPath: r.displayPath,
  362. title: r.title,
  363. body: r.body,
  364. score: r.score,
  365. })));
  366. }
  367. }
  368. expect(rankedLists.length).toBeGreaterThan(0);
  369. const fused = reciprocalRankFusion(rankedLists);
  370. expect(fused.length).toBeGreaterThan(0);
  371. const candidates = fused.slice(0, 10);
  372. const reranked = await rerank(
  373. query,
  374. candidates.map(c => ({ file: c.file, text: c.body })),
  375. DEFAULT_RERANK_MODEL,
  376. testDb
  377. );
  378. expect(reranked.length).toBeGreaterThan(0);
  379. });
  380. });
  381. // ===========================================================================
  382. // Tool: qmd_get (Get Document)
  383. // ===========================================================================
  384. describe("qmd_get tool", () => {
  385. test("retrieves document by display_path", () => {
  386. const result = getDocument(testDb, "readme.md");
  387. expect("error" in result).toBe(false);
  388. if (!("error" in result)) {
  389. expect(result.displayPath).toBe("readme.md");
  390. expect(result.body).toContain("Project README");
  391. }
  392. });
  393. test("retrieves document by filepath", () => {
  394. const result = getDocument(testDb, "/test/docs/api.md");
  395. expect("error" in result).toBe(false);
  396. if (!("error" in result)) {
  397. expect(result.title).toBe("API Documentation");
  398. }
  399. });
  400. test("retrieves document by partial path", () => {
  401. const result = getDocument(testDb, "api.md");
  402. expect("error" in result).toBe(false);
  403. });
  404. test("returns not found for missing document", () => {
  405. const result = getDocument(testDb, "nonexistent.md");
  406. expect("error" in result).toBe(true);
  407. if ("error" in result) {
  408. expect(result.error).toBe("not_found");
  409. }
  410. });
  411. test("suggests similar files when not found", () => {
  412. const result = getDocument(testDb, "readm.md"); // typo
  413. expect("error" in result).toBe(true);
  414. if ("error" in result) {
  415. expect(result.similarFiles.length).toBeGreaterThanOrEqual(0);
  416. }
  417. });
  418. test("supports line range with :line suffix", () => {
  419. const result = getDocument(testDb, "readme.md:2", undefined, 2);
  420. expect("error" in result).toBe(false);
  421. if (!("error" in result)) {
  422. const lines = result.body.split("\n");
  423. expect(lines.length).toBeLessThanOrEqual(2);
  424. }
  425. });
  426. test("supports fromLine parameter", () => {
  427. const result = getDocument(testDb, "readme.md", 3);
  428. expect("error" in result).toBe(false);
  429. if (!("error" in result)) {
  430. expect(result.body).not.toContain("# Project README");
  431. }
  432. });
  433. test("supports maxLines parameter", () => {
  434. const result = getDocument(testDb, "api.md", 1, 3);
  435. expect("error" in result).toBe(false);
  436. if (!("error" in result)) {
  437. const lines = result.body.split("\n");
  438. expect(lines.length).toBeLessThanOrEqual(3);
  439. }
  440. });
  441. test("includes context for documents in context path", () => {
  442. const result = getDocument(testDb, "meetings/meeting-2024-01.md");
  443. expect("error" in result).toBe(false);
  444. if (!("error" in result)) {
  445. expect(result.context).toBe("Meeting notes and transcripts");
  446. }
  447. });
  448. });
  449. // ===========================================================================
  450. // Tool: qmd_multi_get (Multi Get)
  451. // ===========================================================================
  452. describe("qmd_multi_get tool", () => {
  453. test("retrieves multiple documents by glob pattern", () => {
  454. const { files, errors } = getMultipleDocuments(testDb, "meetings/*.md");
  455. expect(errors.length).toBe(0);
  456. expect(files.length).toBe(2);
  457. expect(files.some(f => f.displayPath === "meetings/meeting-2024-01.md")).toBe(true);
  458. expect(files.some(f => f.displayPath === "meetings/meeting-2024-02.md")).toBe(true);
  459. });
  460. test("retrieves documents by comma-separated list", () => {
  461. const { files, errors } = getMultipleDocuments(testDb, "readme.md, api.md");
  462. expect(errors.length).toBe(0);
  463. expect(files.length).toBe(2);
  464. });
  465. test("returns errors for missing files in comma list", () => {
  466. const { files, errors } = getMultipleDocuments(testDb, "readme.md, nonexistent.md");
  467. expect(files.length).toBe(1);
  468. expect(errors.length).toBe(1);
  469. expect(errors[0]).toContain("not found");
  470. });
  471. test("skips files larger than maxBytes", () => {
  472. const { files } = getMultipleDocuments(testDb, "*.md", undefined, 1000); // 1KB limit
  473. const largeFile = files.find(f => f.displayPath === "large-file.md");
  474. expect(largeFile).toBeDefined();
  475. expect(largeFile?.skipped).toBe(true);
  476. if (largeFile?.skipped) {
  477. expect(largeFile.skipReason).toContain("too large");
  478. }
  479. });
  480. test("respects maxLines parameter", () => {
  481. const { files } = getMultipleDocuments(testDb, "readme.md", 2);
  482. expect(files.length).toBe(1);
  483. if (!files[0].skipped) {
  484. const lines = files[0].body.split("\n");
  485. // maxLines + truncation message
  486. expect(lines.length).toBeLessThanOrEqual(4);
  487. }
  488. });
  489. test("returns error for non-matching glob", () => {
  490. const { files, errors } = getMultipleDocuments(testDb, "nonexistent/*.md");
  491. expect(files.length).toBe(0);
  492. expect(errors.length).toBe(1);
  493. expect(errors[0]).toContain("No files matched");
  494. });
  495. test("includes context in results", () => {
  496. const { files } = getMultipleDocuments(testDb, "meetings/meeting-2024-01.md");
  497. expect(files.length).toBe(1);
  498. if (!files[0].skipped) {
  499. expect(files[0].context).toBe("Meeting notes and transcripts");
  500. }
  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 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 filepath, display_path, body
  539. FROM documents
  540. WHERE display_path = ? AND active = 1
  541. `).get(path) as { filepath: string; display_path: string; body: string } | null;
  542. expect(doc).not.toBeNull();
  543. expect(doc?.body).toContain("Project README");
  544. });
  545. test("reads document by URL-encoded path", () => {
  546. // Simulate URL encoding that MCP clients may send
  547. const encodedPath = "meetings%2Fmeeting-2024-01.md";
  548. const decodedPath = decodeURIComponent(encodedPath);
  549. const doc = testDb.prepare(`
  550. SELECT filepath, display_path, body
  551. FROM documents
  552. WHERE display_path = ? AND active = 1
  553. `).get(decodedPath) as { filepath: string; display_path: string; body: string } | null;
  554. expect(doc).not.toBeNull();
  555. expect(doc?.display_path).toBe("meetings/meeting-2024-01.md");
  556. });
  557. test("reads document by suffix match", () => {
  558. const path = "meeting-2024-01.md"; // without meetings/ prefix
  559. let doc = testDb.prepare(`
  560. SELECT filepath, display_path, body
  561. FROM documents
  562. WHERE display_path = ? AND active = 1
  563. `).get(path) as { filepath: string; display_path: string; body: string } | null;
  564. if (!doc) {
  565. doc = testDb.prepare(`
  566. SELECT filepath, display_path, body
  567. FROM documents
  568. WHERE display_path LIKE ? AND active = 1
  569. LIMIT 1
  570. `).get(`%${path}`) as { filepath: string; display_path: string; body: string } | null;
  571. }
  572. expect(doc).not.toBeNull();
  573. expect(doc?.display_path).toBe("meetings/meeting-2024-01.md");
  574. });
  575. test("returns not found for missing document", () => {
  576. const path = "nonexistent.md";
  577. const doc = testDb.prepare(`
  578. SELECT filepath, display_path, body
  579. FROM documents
  580. WHERE display_path = ? AND active = 1
  581. `).get(path) as { filepath: string; display_path: string; body: string } | null;
  582. expect(doc).toBeNull();
  583. });
  584. test("includes context in document body", () => {
  585. const path = "meetings/meeting-2024-01.md";
  586. const doc = testDb.prepare(`
  587. SELECT filepath, display_path, body
  588. FROM documents
  589. WHERE display_path = ? AND active = 1
  590. `).get(path) as { filepath: string; display_path: string; body: string } | null;
  591. expect(doc).not.toBeNull();
  592. const context = getContextForFile(testDb, doc!.filepath);
  593. expect(context).toBe("Meeting notes and transcripts");
  594. // Verify context would be prepended
  595. let text = doc!.body;
  596. if (context) {
  597. text = `<!-- Context: ${context} -->\n\n` + text;
  598. }
  599. expect(text).toContain("<!-- Context: Meeting notes and transcripts -->");
  600. });
  601. test("handles URL-encoded special characters", () => {
  602. // Test various URL encodings
  603. const testCases = [
  604. { encoded: "readme.md", decoded: "readme.md" },
  605. { encoded: "meetings%2Fmeeting-2024-01.md", decoded: "meetings/meeting-2024-01.md" },
  606. { encoded: "api.md%3A10", decoded: "api.md:10" }, // with line number
  607. ];
  608. for (const { encoded, decoded } of testCases) {
  609. expect(decodeURIComponent(encoded)).toBe(decoded);
  610. }
  611. });
  612. test("handles double-encoded URLs", () => {
  613. // Some clients may double-encode
  614. const doubleEncoded = "meetings%252Fmeeting-2024-01.md";
  615. const singleDecoded = decodeURIComponent(doubleEncoded);
  616. expect(singleDecoded).toBe("meetings%2Fmeeting-2024-01.md");
  617. const fullyDecoded = decodeURIComponent(singleDecoded);
  618. expect(fullyDecoded).toBe("meetings/meeting-2024-01.md");
  619. });
  620. test("handles URL-encoded paths with spaces", () => {
  621. // Add a document with spaces in the path
  622. const now = new Date().toISOString();
  623. testDb.prepare(`
  624. INSERT INTO documents (collection_id, name, title, hash, filepath, display_path, body, created_at, modified_at, active)
  625. VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, 1)
  626. `).run(
  627. "podcast with spaces.md",
  628. "Podcast Episode",
  629. "hash_spaces",
  630. "/test/docs/External Podcast/2023 April - Interview.md",
  631. "External Podcast/2023 April - Interview.md",
  632. "# Podcast Episode\n\nInterview content here.",
  633. now,
  634. now
  635. );
  636. // Simulate URL-encoded path from MCP client
  637. const encodedPath = "External%20Podcast%2F2023%20April%20-%20Interview.md";
  638. const decodedPath = decodeURIComponent(encodedPath);
  639. expect(decodedPath).toBe("External Podcast/2023 April - Interview.md");
  640. const doc = testDb.prepare(`
  641. SELECT filepath, display_path, body
  642. FROM documents
  643. WHERE display_path = ? AND active = 1
  644. `).get(decodedPath) as { filepath: string; display_path: string; body: string } | null;
  645. expect(doc).not.toBeNull();
  646. expect(doc?.display_path).toBe("External Podcast/2023 April - Interview.md");
  647. expect(doc?.body).toContain("Podcast Episode");
  648. });
  649. });
  650. // ===========================================================================
  651. // Prompt: query
  652. // ===========================================================================
  653. describe("query prompt", () => {
  654. test("returns usage guide", () => {
  655. // The prompt content is static, just verify the structure
  656. const promptContent = `# QMD - Quick Markdown Search
  657. QMD is your on-device search engine for markdown knowledge bases.`;
  658. expect(promptContent).toContain("QMD");
  659. expect(promptContent).toContain("search");
  660. });
  661. test("describes all available tools", () => {
  662. const toolNames = [
  663. "qmd_search",
  664. "qmd_vsearch",
  665. "qmd_query",
  666. "qmd_get",
  667. "qmd_multi_get",
  668. "qmd_status",
  669. ];
  670. // Verify these are documented in the prompt
  671. const promptGuide = `
  672. ### 1. qmd_search (Fast keyword search)
  673. ### 2. qmd_vsearch (Semantic search)
  674. ### 3. qmd_query (Hybrid search - highest quality)
  675. ### 4. qmd_get (Retrieve document)
  676. ### 5. qmd_multi_get (Retrieve multiple documents)
  677. ### 6. qmd_status (Index info)
  678. `;
  679. for (const tool of toolNames) {
  680. expect(promptGuide).toContain(tool);
  681. }
  682. });
  683. });
  684. // ===========================================================================
  685. // Edge Cases
  686. // ===========================================================================
  687. describe("edge cases", () => {
  688. test("handles empty query", () => {
  689. const results = searchFTS(testDb, "", 10);
  690. expect(results.length).toBe(0);
  691. });
  692. test("handles special characters in query", () => {
  693. const results = searchFTS(testDb, "project's", 10);
  694. // Should not throw
  695. expect(Array.isArray(results)).toBe(true);
  696. });
  697. test("handles unicode in query", () => {
  698. const results = searchFTS(testDb, "文档", 10);
  699. expect(Array.isArray(results)).toBe(true);
  700. });
  701. test("handles very long query", () => {
  702. const longQuery = "documentation ".repeat(100);
  703. const results = searchFTS(testDb, longQuery, 10);
  704. expect(Array.isArray(results)).toBe(true);
  705. });
  706. test("handles query with only stopwords", () => {
  707. const results = searchFTS(testDb, "the and or", 10);
  708. expect(Array.isArray(results)).toBe(true);
  709. });
  710. test("extracts snippet around matching text", () => {
  711. const body = "Line 1\nLine 2\nThis is the important line with the keyword\nLine 4\nLine 5";
  712. const { line, snippet } = extractSnippet(body, "keyword", 200);
  713. expect(snippet).toContain("keyword");
  714. expect(line).toBe(3);
  715. });
  716. test("handles snippet extraction with chunkPos", () => {
  717. const body = "A".repeat(1000) + "KEYWORD" + "B".repeat(1000);
  718. const chunkPos = 1000; // Position of KEYWORD
  719. const { snippet } = extractSnippet(body, "keyword", 200, chunkPos);
  720. expect(snippet).toContain("KEYWORD");
  721. });
  722. });
  723. // ===========================================================================
  724. // MCP Spec Compliance
  725. // ===========================================================================
  726. describe("MCP spec compliance", () => {
  727. test("encodeQmdPath preserves slashes but encodes special chars", () => {
  728. // Helper function behavior (tested indirectly through resource URIs)
  729. const path = "External Podcast/2023 April - Interview.md";
  730. const segments = path.split('/').map(s => encodeURIComponent(s)).join('/');
  731. expect(segments).toBe("External%20Podcast/2023%20April%20-%20Interview.md");
  732. expect(segments).toContain("/"); // Slashes preserved
  733. expect(segments).toContain("%20"); // Spaces encoded
  734. });
  735. test("search results have correct structure for structuredContent", () => {
  736. const results = searchFTS(testDb, "readme", 5);
  737. const structured = results.map(r => ({
  738. file: r.displayPath,
  739. title: r.title,
  740. score: Math.round(r.score * 100) / 100,
  741. context: getContextForFile(testDb, r.file),
  742. snippet: extractSnippet(r.body, "readme", 300, r.chunkPos).snippet,
  743. }));
  744. expect(structured.length).toBeGreaterThan(0);
  745. const item = structured[0];
  746. expect(typeof item.file).toBe("string");
  747. expect(typeof item.title).toBe("string");
  748. expect(typeof item.score).toBe("number");
  749. expect(item.score).toBeGreaterThanOrEqual(0);
  750. expect(item.score).toBeLessThanOrEqual(1);
  751. expect(typeof item.snippet).toBe("string");
  752. });
  753. test("error responses should include isError flag", () => {
  754. // Simulate what MCP server returns for errors
  755. const errorResponse = {
  756. content: [{ type: "text", text: "Collection not found: nonexistent" }],
  757. isError: true,
  758. };
  759. expect(errorResponse.isError).toBe(true);
  760. expect(errorResponse.content[0].type).toBe("text");
  761. });
  762. test("embedded resources include name and title", () => {
  763. // Simulate what qmd_get returns
  764. const result = getDocument(testDb, "readme.md");
  765. expect("error" in result).toBe(false);
  766. if (!("error" in result)) {
  767. const resource = {
  768. uri: `qmd://${result.displayPath}`,
  769. name: result.displayPath,
  770. title: result.title,
  771. mimeType: "text/markdown",
  772. text: result.body,
  773. };
  774. expect(resource.name).toBe("readme.md");
  775. expect(resource.title).toBe("Project README");
  776. expect(resource.mimeType).toBe("text/markdown");
  777. }
  778. });
  779. test("status response includes structuredContent", () => {
  780. const status = getStatus(testDb);
  781. // Verify structure matches StatusResult type
  782. expect(typeof status.totalDocuments).toBe("number");
  783. expect(typeof status.needsEmbedding).toBe("number");
  784. expect(typeof status.hasVectorIndex).toBe("boolean");
  785. expect(Array.isArray(status.collections)).toBe(true);
  786. if (status.collections.length > 0) {
  787. const col = status.collections[0];
  788. expect(typeof col.id).toBe("number");
  789. expect(typeof col.path).toBe("string");
  790. expect(typeof col.pattern).toBe("string");
  791. expect(typeof col.documents).toBe("number");
  792. }
  793. });
  794. });
  795. });