- Remove mimeType from TextContent (not in spec, only valid on EmbeddedResource)
- Add isError: true to all error responses for proper error detection
- Replace CSV output with structuredContent for machine-readable results
- Add name and title fields to all embedded resources
- Fix URI encoding: preserve slashes, encode special chars (spaces → %20)
- Change template to {+path} for proper nested path support
- Rename tools: qmd_search → search, qmd_get → get, etc.
- Update tests for new response format and spec compliance
919 lines
32 KiB
TypeScript
919 lines
32 KiB
TypeScript
/**
|
|
* 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");
|
|
}
|
|
});
|
|
});
|
|
});
|