| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918 |
- /**
- * MCP Server Tests
- *
- * Tests all MCP tools, resources, and prompts.
- * Uses mocked Ollama responses and a test database.
- */
- import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
- import { Database } from "bun:sqlite";
- import * as sqliteVec from "sqlite-vec";
- import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
- import { z } from "zod";
- import { setDefaultOllama, Ollama } from "./llm";
- // =============================================================================
- // Mock Ollama
- // =============================================================================
- const OLLAMA_URL = "http://localhost:11434";
- const originalFetch = globalThis.fetch;
- const mockOllamaResponses: Record<string, (body: unknown) => Response> = {
- "/api/embed": () => {
- const embedding = Array(768).fill(0).map(() => Math.random());
- return new Response(JSON.stringify({ embeddings: [embedding] }), {
- status: 200,
- headers: { "Content-Type": "application/json" },
- });
- },
- "/api/generate": (body: unknown) => {
- const reqBody = body as { prompt?: string };
- if (reqBody.prompt?.includes("Judge") || reqBody.prompt?.includes("Document")) {
- return new Response(JSON.stringify({
- response: "yes",
- done: true,
- logprobs: { tokens: ["yes"], token_logprobs: [-0.1] },
- }), { status: 200, headers: { "Content-Type": "application/json" } });
- } else {
- return new Response(JSON.stringify({
- response: "expanded query variation 1\nexpanded query variation 2",
- done: true,
- }), { status: 200, headers: { "Content-Type": "application/json" } });
- }
- },
- "/api/show": () => {
- return new Response(JSON.stringify({ size: 1000000 }), {
- status: 200,
- headers: { "Content-Type": "application/json" },
- });
- },
- };
- function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
- const url = typeof input === "string" ? input : input.toString();
- if (url.startsWith(OLLAMA_URL)) {
- const path = url.replace(OLLAMA_URL, "");
- const handler = mockOllamaResponses[path];
- if (handler) {
- const body = init?.body ? JSON.parse(init.body as string) : {};
- return Promise.resolve(handler(body));
- }
- throw new Error(`Unmocked Ollama endpoint: ${path}`);
- }
- throw new Error(`Unexpected fetch call to: ${url}`);
- }
- // =============================================================================
- // Test Database Setup
- // =============================================================================
- let testDb: Database;
- let testDbPath: string;
- function initTestDatabase(db: Database): void {
- sqliteVec.load(db);
- db.exec("PRAGMA journal_mode = WAL");
- db.exec(`
- CREATE TABLE IF NOT EXISTS collections (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- pwd TEXT NOT NULL,
- glob_pattern TEXT NOT NULL,
- created_at TEXT NOT NULL,
- context TEXT,
- UNIQUE(pwd, glob_pattern)
- )
- `);
- db.exec(`
- CREATE TABLE IF NOT EXISTS path_contexts (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- path_prefix TEXT NOT NULL UNIQUE,
- context TEXT NOT NULL,
- created_at TEXT NOT NULL
- )
- `);
- db.exec(`
- CREATE TABLE IF NOT EXISTS ollama_cache (
- hash TEXT PRIMARY KEY,
- result TEXT NOT NULL,
- created_at TEXT NOT NULL
- )
- `);
- db.exec(`
- CREATE TABLE IF NOT EXISTS documents (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- collection_id INTEGER NOT NULL,
- name TEXT NOT NULL,
- title TEXT NOT NULL,
- hash TEXT NOT NULL,
- filepath TEXT NOT NULL,
- display_path TEXT NOT NULL DEFAULT '',
- body TEXT NOT NULL,
- created_at TEXT NOT NULL,
- modified_at TEXT NOT NULL,
- active INTEGER NOT NULL DEFAULT 1,
- FOREIGN KEY (collection_id) REFERENCES collections(id)
- )
- `);
- db.exec(`
- CREATE TABLE IF NOT EXISTS content_vectors (
- hash TEXT NOT NULL,
- seq INTEGER NOT NULL DEFAULT 0,
- pos INTEGER NOT NULL DEFAULT 0,
- model TEXT NOT NULL,
- embedded_at TEXT NOT NULL,
- PRIMARY KEY (hash, seq)
- )
- `);
- db.exec(`
- CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
- name, body,
- content='documents',
- content_rowid='id',
- tokenize='porter unicode61'
- )
- `);
- db.exec(`
- CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
- INSERT INTO documents_fts(rowid, name, body) VALUES (new.id, new.name, new.body);
- END
- `);
- // Create vector table
- db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[768])`);
- }
- function seedTestData(db: Database): void {
- const now = new Date().toISOString();
- // Create a collection
- db.prepare(`INSERT INTO collections (pwd, glob_pattern, created_at, context) VALUES (?, ?, ?, ?)`).run(
- "/test/docs",
- "**/*.md",
- now,
- "Test documentation collection"
- );
- // Add path context
- db.prepare(`INSERT INTO path_contexts (path_prefix, context, created_at) VALUES (?, ?, ?)`).run(
- "/test/docs/meetings",
- "Meeting notes and transcripts",
- now
- );
- // Add test documents
- const docs = [
- {
- name: "readme.md",
- title: "Project README",
- hash: "hash1",
- filepath: "/test/docs/readme.md",
- display_path: "readme.md",
- body: "# Project README\n\nThis is the main readme file for the project.\n\nIt contains important information about setup and usage.",
- },
- {
- name: "api.md",
- title: "API Documentation",
- hash: "hash2",
- filepath: "/test/docs/api.md",
- display_path: "api.md",
- body: "# API Documentation\n\nThis document describes the REST API endpoints.\n\n## Authentication\n\nUse Bearer tokens for auth.",
- },
- {
- name: "meeting-2024-01.md",
- title: "January Meeting Notes",
- hash: "hash3",
- filepath: "/test/docs/meetings/meeting-2024-01.md",
- display_path: "meetings/meeting-2024-01.md",
- body: "# January Meeting Notes\n\nDiscussed Q1 goals and roadmap.\n\n## Action Items\n\n- Review budget\n- Hire new team members",
- },
- {
- name: "meeting-2024-02.md",
- title: "February Meeting Notes",
- hash: "hash4",
- filepath: "/test/docs/meetings/meeting-2024-02.md",
- display_path: "meetings/meeting-2024-02.md",
- body: "# February Meeting Notes\n\nFollowed up on Q1 progress.\n\n## Updates\n\n- Budget approved\n- Two candidates interviewed",
- },
- {
- name: "large-file.md",
- title: "Large Document",
- hash: "hash5",
- filepath: "/test/docs/large-file.md",
- display_path: "large-file.md",
- body: "# Large Document\n\n" + "Lorem ipsum ".repeat(2000), // ~24KB
- },
- ];
- for (const doc of docs) {
- db.prepare(`
- INSERT INTO documents (collection_id, name, title, hash, filepath, display_path, body, created_at, modified_at, active)
- VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, 1)
- `).run(doc.name, doc.title, doc.hash, doc.filepath, doc.display_path, doc.body, now, now);
- }
- // Add embeddings for vector search
- const embedding = new Float32Array(768);
- for (let i = 0; i < 768; i++) embedding[i] = Math.random();
- for (const doc of docs.slice(0, 4)) { // Skip large file for embeddings
- db.prepare(`INSERT INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, 0, 0, 'embeddinggemma', ?)`).run(doc.hash, now);
- db.prepare(`INSERT INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`).run(`${doc.hash}_0`, embedding);
- }
- }
- // =============================================================================
- // MCP Server Test Helpers
- // =============================================================================
- // We need to create a testable version of the MCP handlers
- // Since McpServer uses internal routing, we'll test the handler functions directly
- import {
- searchFTS,
- searchVec,
- expandQuery,
- rerank,
- reciprocalRankFusion,
- extractSnippet,
- getContextForFile,
- getCollectionIdByName,
- getDocument,
- getMultipleDocuments,
- getStatus,
- DEFAULT_EMBED_MODEL,
- DEFAULT_QUERY_MODEL,
- DEFAULT_RERANK_MODEL,
- DEFAULT_MULTI_GET_MAX_BYTES,
- createStore,
- } from "./store";
- import type { RankedResult } from "./store";
- // Note: searchResultsToMcpCsv no longer used in MCP - using structuredContent instead
- // =============================================================================
- // Tests
- // =============================================================================
- describe("MCP Server", () => {
- beforeAll(() => {
- globalThis.fetch = mockFetch as typeof fetch;
- setDefaultOllama(new Ollama({ baseUrl: OLLAMA_URL }));
- testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`;
- testDb = new Database(testDbPath);
- initTestDatabase(testDb);
- seedTestData(testDb);
- });
- afterAll(() => {
- globalThis.fetch = originalFetch;
- setDefaultOllama(null);
- testDb.close();
- try {
- require("fs").unlinkSync(testDbPath);
- } catch {}
- });
- // ===========================================================================
- // Tool: qmd_search (BM25)
- // ===========================================================================
- describe("qmd_search tool", () => {
- test("returns results for matching query", () => {
- const results = searchFTS(testDb, "readme", 10);
- expect(results.length).toBeGreaterThan(0);
- expect(results[0].displayPath).toBe("readme.md");
- });
- test("returns empty for non-matching query", () => {
- const results = searchFTS(testDb, "xyznonexistent", 10);
- expect(results.length).toBe(0);
- });
- test("respects limit parameter", () => {
- const results = searchFTS(testDb, "meeting", 1);
- expect(results.length).toBe(1);
- });
- test("filters by collection", () => {
- const collectionId = getCollectionIdByName(testDb, "docs");
- expect(collectionId).toBe(1);
- const results = searchFTS(testDb, "meeting", 10, collectionId!);
- expect(results.length).toBeGreaterThan(0);
- });
- test("returns null for non-existent collection", () => {
- const collectionId = getCollectionIdByName(testDb, "nonexistent");
- expect(collectionId).toBeNull();
- });
- test("formats results as structured content", () => {
- const results = searchFTS(testDb, "api", 10);
- const filtered = results.map(r => ({
- file: r.displayPath,
- title: r.title,
- score: Math.round(r.score * 100) / 100,
- context: getContextForFile(testDb, r.file),
- snippet: extractSnippet(r.body, "api", 300, r.chunkPos).snippet,
- }));
- // MCP now returns structuredContent with results array
- expect(filtered.length).toBeGreaterThan(0);
- expect(filtered[0]).toHaveProperty("file");
- expect(filtered[0]).toHaveProperty("title");
- expect(filtered[0]).toHaveProperty("score");
- expect(filtered[0]).toHaveProperty("snippet");
- });
- });
- // ===========================================================================
- // Tool: qmd_vsearch (Vector)
- // ===========================================================================
- describe("qmd_vsearch tool", () => {
- test("returns results for semantic query", async () => {
- const results = await searchVec(testDb, "project documentation", DEFAULT_EMBED_MODEL, 10);
- expect(results.length).toBeGreaterThan(0);
- });
- test("respects limit parameter", async () => {
- const results = await searchVec(testDb, "documentation", DEFAULT_EMBED_MODEL, 2);
- expect(results.length).toBeLessThanOrEqual(2);
- });
- test("returns empty when no vector table exists", async () => {
- const emptyDb = new Database(":memory:");
- initTestDatabase(emptyDb);
- emptyDb.exec("DROP TABLE IF EXISTS vectors_vec");
- const results = await searchVec(emptyDb, "test", DEFAULT_EMBED_MODEL, 10);
- expect(results.length).toBe(0);
- emptyDb.close();
- });
- });
- // ===========================================================================
- // Tool: qmd_query (Hybrid)
- // ===========================================================================
- describe("qmd_query tool", () => {
- test("expands query with variations", async () => {
- const queries = await expandQuery("api documentation", DEFAULT_QUERY_MODEL, testDb);
- expect(queries.length).toBeGreaterThan(1);
- expect(queries[0]).toBe("api documentation");
- });
- test("performs RRF fusion on multiple result lists", () => {
- const list1: RankedResult[] = [
- { file: "/a", displayPath: "a.md", title: "A", body: "body", score: 1 },
- { file: "/b", displayPath: "b.md", title: "B", body: "body", score: 0.8 },
- ];
- const list2: RankedResult[] = [
- { file: "/b", displayPath: "b.md", title: "B", body: "body", score: 1 },
- { file: "/c", displayPath: "c.md", title: "C", body: "body", score: 0.9 },
- ];
- const fused = reciprocalRankFusion([list1, list2]);
- expect(fused.length).toBe(3);
- // B appears in both lists, should have higher score
- const bResult = fused.find(r => r.file === "/b");
- expect(bResult).toBeDefined();
- });
- test("reranks documents with LLM", async () => {
- const docs = [
- { file: "/test/docs/readme.md", text: "Project readme" },
- { file: "/test/docs/api.md", text: "API documentation" },
- ];
- const reranked = await rerank("readme", docs, DEFAULT_RERANK_MODEL, testDb);
- expect(reranked.length).toBe(2);
- expect(reranked[0].score).toBeGreaterThan(0);
- });
- test("full hybrid search pipeline", async () => {
- // Simulate full qmd_query flow
- const query = "meeting notes";
- const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, testDb);
- const rankedLists: RankedResult[][] = [];
- for (const q of queries) {
- const ftsResults = searchFTS(testDb, q, 20);
- if (ftsResults.length > 0) {
- rankedLists.push(ftsResults.map(r => ({
- file: r.file,
- displayPath: r.displayPath,
- title: r.title,
- body: r.body,
- score: r.score,
- })));
- }
- }
- expect(rankedLists.length).toBeGreaterThan(0);
- const fused = reciprocalRankFusion(rankedLists);
- expect(fused.length).toBeGreaterThan(0);
- const candidates = fused.slice(0, 10);
- const reranked = await rerank(
- query,
- candidates.map(c => ({ file: c.file, text: c.body })),
- DEFAULT_RERANK_MODEL,
- testDb
- );
- expect(reranked.length).toBeGreaterThan(0);
- });
- });
- // ===========================================================================
- // Tool: qmd_get (Get Document)
- // ===========================================================================
- describe("qmd_get tool", () => {
- test("retrieves document by display_path", () => {
- const result = getDocument(testDb, "readme.md");
- expect("error" in result).toBe(false);
- if (!("error" in result)) {
- expect(result.displayPath).toBe("readme.md");
- expect(result.body).toContain("Project README");
- }
- });
- test("retrieves document by filepath", () => {
- const result = getDocument(testDb, "/test/docs/api.md");
- expect("error" in result).toBe(false);
- if (!("error" in result)) {
- expect(result.title).toBe("API Documentation");
- }
- });
- test("retrieves document by partial path", () => {
- const result = getDocument(testDb, "api.md");
- expect("error" in result).toBe(false);
- });
- test("returns not found for missing document", () => {
- const result = getDocument(testDb, "nonexistent.md");
- expect("error" in result).toBe(true);
- if ("error" in result) {
- expect(result.error).toBe("not_found");
- }
- });
- test("suggests similar files when not found", () => {
- const result = getDocument(testDb, "readm.md"); // typo
- expect("error" in result).toBe(true);
- if ("error" in result) {
- expect(result.similarFiles.length).toBeGreaterThanOrEqual(0);
- }
- });
- test("supports line range with :line suffix", () => {
- const result = getDocument(testDb, "readme.md:2", undefined, 2);
- expect("error" in result).toBe(false);
- if (!("error" in result)) {
- const lines = result.body.split("\n");
- expect(lines.length).toBeLessThanOrEqual(2);
- }
- });
- test("supports fromLine parameter", () => {
- const result = getDocument(testDb, "readme.md", 3);
- expect("error" in result).toBe(false);
- if (!("error" in result)) {
- expect(result.body).not.toContain("# Project README");
- }
- });
- test("supports maxLines parameter", () => {
- const result = getDocument(testDb, "api.md", 1, 3);
- expect("error" in result).toBe(false);
- if (!("error" in result)) {
- const lines = result.body.split("\n");
- expect(lines.length).toBeLessThanOrEqual(3);
- }
- });
- test("includes context for documents in context path", () => {
- const result = getDocument(testDb, "meetings/meeting-2024-01.md");
- expect("error" in result).toBe(false);
- if (!("error" in result)) {
- expect(result.context).toBe("Meeting notes and transcripts");
- }
- });
- });
- // ===========================================================================
- // Tool: qmd_multi_get (Multi Get)
- // ===========================================================================
- describe("qmd_multi_get tool", () => {
- test("retrieves multiple documents by glob pattern", () => {
- const { files, errors } = getMultipleDocuments(testDb, "meetings/*.md");
- expect(errors.length).toBe(0);
- expect(files.length).toBe(2);
- expect(files.some(f => f.displayPath === "meetings/meeting-2024-01.md")).toBe(true);
- expect(files.some(f => f.displayPath === "meetings/meeting-2024-02.md")).toBe(true);
- });
- test("retrieves documents by comma-separated list", () => {
- const { files, errors } = getMultipleDocuments(testDb, "readme.md, api.md");
- expect(errors.length).toBe(0);
- expect(files.length).toBe(2);
- });
- test("returns errors for missing files in comma list", () => {
- const { files, errors } = getMultipleDocuments(testDb, "readme.md, nonexistent.md");
- expect(files.length).toBe(1);
- expect(errors.length).toBe(1);
- expect(errors[0]).toContain("not found");
- });
- test("skips files larger than maxBytes", () => {
- const { files } = getMultipleDocuments(testDb, "*.md", undefined, 1000); // 1KB limit
- const largeFile = files.find(f => f.displayPath === "large-file.md");
- expect(largeFile).toBeDefined();
- expect(largeFile?.skipped).toBe(true);
- if (largeFile?.skipped) {
- expect(largeFile.skipReason).toContain("too large");
- }
- });
- test("respects maxLines parameter", () => {
- const { files } = getMultipleDocuments(testDb, "readme.md", 2);
- expect(files.length).toBe(1);
- if (!files[0].skipped) {
- const lines = files[0].body.split("\n");
- // maxLines + truncation message
- expect(lines.length).toBeLessThanOrEqual(4);
- }
- });
- test("returns error for non-matching glob", () => {
- const { files, errors } = getMultipleDocuments(testDb, "nonexistent/*.md");
- expect(files.length).toBe(0);
- expect(errors.length).toBe(1);
- expect(errors[0]).toContain("No files matched");
- });
- test("includes context in results", () => {
- const { files } = getMultipleDocuments(testDb, "meetings/meeting-2024-01.md");
- expect(files.length).toBe(1);
- if (!files[0].skipped) {
- expect(files[0].context).toBe("Meeting notes and transcripts");
- }
- });
- });
- // ===========================================================================
- // Tool: qmd_status
- // ===========================================================================
- describe("qmd_status tool", () => {
- test("returns index status", () => {
- const status = getStatus(testDb);
- expect(status.totalDocuments).toBe(5);
- expect(status.hasVectorIndex).toBe(true);
- expect(status.collections.length).toBe(1);
- expect(status.collections[0].path).toBe("/test/docs");
- });
- test("shows documents needing embedding", () => {
- const status = getStatus(testDb);
- // large-file.md doesn't have embeddings
- expect(status.needsEmbedding).toBe(1);
- });
- });
- // ===========================================================================
- // Resource: qmd://{path}
- // ===========================================================================
- describe("qmd:// resource", () => {
- test("lists all documents", () => {
- const docs = testDb.prepare(`
- SELECT display_path, title
- FROM documents
- WHERE active = 1
- ORDER BY modified_at DESC
- LIMIT 1000
- `).all() as { display_path: string; title: string }[];
- expect(docs.length).toBe(5);
- expect(docs.map(d => d.display_path)).toContain("readme.md");
- });
- test("reads document by display_path", () => {
- const path = "readme.md";
- const doc = testDb.prepare(`
- SELECT filepath, display_path, body
- FROM documents
- WHERE display_path = ? AND active = 1
- `).get(path) as { filepath: string; display_path: string; body: string } | null;
- expect(doc).not.toBeNull();
- expect(doc?.body).toContain("Project README");
- });
- test("reads document by URL-encoded path", () => {
- // Simulate URL encoding that MCP clients may send
- const encodedPath = "meetings%2Fmeeting-2024-01.md";
- const decodedPath = decodeURIComponent(encodedPath);
- const doc = testDb.prepare(`
- SELECT filepath, display_path, body
- FROM documents
- WHERE display_path = ? AND active = 1
- `).get(decodedPath) as { filepath: string; display_path: string; body: string } | null;
- expect(doc).not.toBeNull();
- expect(doc?.display_path).toBe("meetings/meeting-2024-01.md");
- });
- test("reads document by suffix match", () => {
- const path = "meeting-2024-01.md"; // without meetings/ prefix
- let doc = testDb.prepare(`
- SELECT filepath, display_path, body
- FROM documents
- WHERE display_path = ? AND active = 1
- `).get(path) as { filepath: string; display_path: string; body: string } | null;
- if (!doc) {
- doc = testDb.prepare(`
- SELECT filepath, display_path, body
- FROM documents
- WHERE display_path LIKE ? AND active = 1
- LIMIT 1
- `).get(`%${path}`) as { filepath: string; display_path: string; body: string } | null;
- }
- expect(doc).not.toBeNull();
- expect(doc?.display_path).toBe("meetings/meeting-2024-01.md");
- });
- test("returns not found for missing document", () => {
- const path = "nonexistent.md";
- const doc = testDb.prepare(`
- SELECT filepath, display_path, body
- FROM documents
- WHERE display_path = ? AND active = 1
- `).get(path) as { filepath: string; display_path: string; body: string } | null;
- expect(doc).toBeNull();
- });
- test("includes context in document body", () => {
- const path = "meetings/meeting-2024-01.md";
- const doc = testDb.prepare(`
- SELECT filepath, display_path, body
- FROM documents
- WHERE display_path = ? AND active = 1
- `).get(path) as { filepath: string; display_path: string; body: string } | null;
- expect(doc).not.toBeNull();
- const context = getContextForFile(testDb, doc!.filepath);
- expect(context).toBe("Meeting notes and transcripts");
- // Verify context would be prepended
- let text = doc!.body;
- if (context) {
- text = `<!-- Context: ${context} -->\n\n` + text;
- }
- expect(text).toContain("<!-- Context: Meeting notes and transcripts -->");
- });
- test("handles URL-encoded special characters", () => {
- // Test various URL encodings
- const testCases = [
- { encoded: "readme.md", decoded: "readme.md" },
- { encoded: "meetings%2Fmeeting-2024-01.md", decoded: "meetings/meeting-2024-01.md" },
- { encoded: "api.md%3A10", decoded: "api.md:10" }, // with line number
- ];
- for (const { encoded, decoded } of testCases) {
- expect(decodeURIComponent(encoded)).toBe(decoded);
- }
- });
- test("handles double-encoded URLs", () => {
- // Some clients may double-encode
- const doubleEncoded = "meetings%252Fmeeting-2024-01.md";
- const singleDecoded = decodeURIComponent(doubleEncoded);
- expect(singleDecoded).toBe("meetings%2Fmeeting-2024-01.md");
- const fullyDecoded = decodeURIComponent(singleDecoded);
- expect(fullyDecoded).toBe("meetings/meeting-2024-01.md");
- });
- test("handles URL-encoded paths with spaces", () => {
- // Add a document with spaces in the path
- const now = new Date().toISOString();
- testDb.prepare(`
- INSERT INTO documents (collection_id, name, title, hash, filepath, display_path, body, created_at, modified_at, active)
- VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, 1)
- `).run(
- "podcast with spaces.md",
- "Podcast Episode",
- "hash_spaces",
- "/test/docs/External Podcast/2023 April - Interview.md",
- "External Podcast/2023 April - Interview.md",
- "# Podcast Episode\n\nInterview content here.",
- now,
- now
- );
- // Simulate URL-encoded path from MCP client
- const encodedPath = "External%20Podcast%2F2023%20April%20-%20Interview.md";
- const decodedPath = decodeURIComponent(encodedPath);
- expect(decodedPath).toBe("External Podcast/2023 April - Interview.md");
- const doc = testDb.prepare(`
- SELECT filepath, display_path, body
- FROM documents
- WHERE display_path = ? AND active = 1
- `).get(decodedPath) as { filepath: string; display_path: string; body: string } | null;
- expect(doc).not.toBeNull();
- expect(doc?.display_path).toBe("External Podcast/2023 April - Interview.md");
- expect(doc?.body).toContain("Podcast Episode");
- });
- });
- // ===========================================================================
- // Prompt: query
- // ===========================================================================
- describe("query prompt", () => {
- test("returns usage guide", () => {
- // The prompt content is static, just verify the structure
- const promptContent = `# QMD - Quick Markdown Search
- QMD is your on-device search engine for markdown knowledge bases.`;
- expect(promptContent).toContain("QMD");
- expect(promptContent).toContain("search");
- });
- test("describes all available tools", () => {
- const toolNames = [
- "qmd_search",
- "qmd_vsearch",
- "qmd_query",
- "qmd_get",
- "qmd_multi_get",
- "qmd_status",
- ];
- // Verify these are documented in the prompt
- const promptGuide = `
- ### 1. qmd_search (Fast keyword search)
- ### 2. qmd_vsearch (Semantic search)
- ### 3. qmd_query (Hybrid search - highest quality)
- ### 4. qmd_get (Retrieve document)
- ### 5. qmd_multi_get (Retrieve multiple documents)
- ### 6. qmd_status (Index info)
- `;
- for (const tool of toolNames) {
- expect(promptGuide).toContain(tool);
- }
- });
- });
- // ===========================================================================
- // Edge Cases
- // ===========================================================================
- describe("edge cases", () => {
- test("handles empty query", () => {
- const results = searchFTS(testDb, "", 10);
- expect(results.length).toBe(0);
- });
- test("handles special characters in query", () => {
- const results = searchFTS(testDb, "project's", 10);
- // Should not throw
- expect(Array.isArray(results)).toBe(true);
- });
- test("handles unicode in query", () => {
- const results = searchFTS(testDb, "文档", 10);
- expect(Array.isArray(results)).toBe(true);
- });
- test("handles very long query", () => {
- const longQuery = "documentation ".repeat(100);
- const results = searchFTS(testDb, longQuery, 10);
- expect(Array.isArray(results)).toBe(true);
- });
- test("handles query with only stopwords", () => {
- const results = searchFTS(testDb, "the and or", 10);
- expect(Array.isArray(results)).toBe(true);
- });
- test("extracts snippet around matching text", () => {
- const body = "Line 1\nLine 2\nThis is the important line with the keyword\nLine 4\nLine 5";
- const { line, snippet } = extractSnippet(body, "keyword", 200);
- expect(snippet).toContain("keyword");
- expect(line).toBe(3);
- });
- test("handles snippet extraction with chunkPos", () => {
- const body = "A".repeat(1000) + "KEYWORD" + "B".repeat(1000);
- const chunkPos = 1000; // Position of KEYWORD
- const { snippet } = extractSnippet(body, "keyword", 200, chunkPos);
- expect(snippet).toContain("KEYWORD");
- });
- });
- // ===========================================================================
- // MCP Spec Compliance
- // ===========================================================================
- describe("MCP spec compliance", () => {
- test("encodeQmdPath preserves slashes but encodes special chars", () => {
- // Helper function behavior (tested indirectly through resource URIs)
- const path = "External Podcast/2023 April - Interview.md";
- const segments = path.split('/').map(s => encodeURIComponent(s)).join('/');
- expect(segments).toBe("External%20Podcast/2023%20April%20-%20Interview.md");
- expect(segments).toContain("/"); // Slashes preserved
- expect(segments).toContain("%20"); // Spaces encoded
- });
- test("search results have correct structure for structuredContent", () => {
- const results = searchFTS(testDb, "readme", 5);
- const structured = results.map(r => ({
- file: r.displayPath,
- title: r.title,
- score: Math.round(r.score * 100) / 100,
- context: getContextForFile(testDb, r.file),
- snippet: extractSnippet(r.body, "readme", 300, r.chunkPos).snippet,
- }));
- expect(structured.length).toBeGreaterThan(0);
- const item = structured[0];
- expect(typeof item.file).toBe("string");
- expect(typeof item.title).toBe("string");
- expect(typeof item.score).toBe("number");
- expect(item.score).toBeGreaterThanOrEqual(0);
- expect(item.score).toBeLessThanOrEqual(1);
- expect(typeof item.snippet).toBe("string");
- });
- test("error responses should include isError flag", () => {
- // Simulate what MCP server returns for errors
- const errorResponse = {
- content: [{ type: "text", text: "Collection not found: nonexistent" }],
- isError: true,
- };
- expect(errorResponse.isError).toBe(true);
- expect(errorResponse.content[0].type).toBe("text");
- });
- test("embedded resources include name and title", () => {
- // Simulate what qmd_get returns
- const result = getDocument(testDb, "readme.md");
- expect("error" in result).toBe(false);
- if (!("error" in result)) {
- const resource = {
- uri: `qmd://${result.displayPath}`,
- name: result.displayPath,
- title: result.title,
- mimeType: "text/markdown",
- text: result.body,
- };
- expect(resource.name).toBe("readme.md");
- expect(resource.title).toBe("Project README");
- expect(resource.mimeType).toBe("text/markdown");
- }
- });
- test("status response includes structuredContent", () => {
- const status = getStatus(testDb);
- // Verify structure matches StatusResult type
- expect(typeof status.totalDocuments).toBe("number");
- expect(typeof status.needsEmbedding).toBe("number");
- expect(typeof status.hasVectorIndex).toBe("boolean");
- expect(Array.isArray(status.collections)).toBe(true);
- if (status.collections.length > 0) {
- const col = status.collections[0];
- expect(typeof col.id).toBe("number");
- expect(typeof col.path).toBe("string");
- expect(typeof col.pattern).toBe("string");
- expect(typeof col.documents).toBe("number");
- }
- });
- });
- });
|