Bladeren bron

Make MCP server spec-compliant (2025-06-18)

- 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
Tobi Lutke 5 maanden geleden
bovenliggende
commit
3b22f88c9f
2 gewijzigde bestanden met toevoegingen van 255 en 103 verwijderingen
  1. 82 34
      mcp.test.ts
  2. 173 69
      mcp.ts

+ 82 - 34
mcp.test.ts

@@ -254,9 +254,10 @@ import {
   DEFAULT_QUERY_MODEL,
   DEFAULT_RERANK_MODEL,
   DEFAULT_MULTI_GET_MAX_BYTES,
+  createStore,
 } from "./store";
 import type { RankedResult } from "./store";
-import { searchResultsToMcpCsv } from "./formatter";
+// Note: searchResultsToMcpCsv no longer used in MCP - using structuredContent instead
 
 // =============================================================================
 // Tests
@@ -315,7 +316,7 @@ describe("MCP Server", () => {
       expect(collectionId).toBeNull();
     });
 
-    test("formats results as CSV", () => {
+    test("formats results as structured content", () => {
       const results = searchFTS(testDb, "api", 10);
       const filtered = results.map(r => ({
         file: r.displayPath,
@@ -324,9 +325,12 @@ describe("MCP Server", () => {
         context: getContextForFile(testDb, r.file),
         snippet: extractSnippet(r.body, "api", 300, r.chunkPos).snippet,
       }));
-      const csv = searchResultsToMcpCsv(filtered);
-      expect(csv).toContain("file,title,score,context,snippet");
-      expect(csv).toContain("api.md");
+      // 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");
     });
   });
 
@@ -834,37 +838,81 @@ QMD is your on-device search engine for markdown knowledge bases.`;
   });
 
   // ===========================================================================
-  // CSV Formatting
+  // MCP Spec Compliance
   // ===========================================================================
 
-  describe("CSV formatting", () => {
-    test("escapes quotes in CSV", () => {
-      const results = [{
-        file: 'test.md',
-        title: 'Test "quoted" title',
-        score: 0.9,
-        context: null,
-        snippet: 'Some "quoted" text',
-      }];
-      const csv = searchResultsToMcpCsv(results);
-      expect(csv).toContain('""quoted""');
-    });
-
-    test("escapes newlines in CSV", () => {
-      const results = [{
-        file: 'test.md',
-        title: 'Test title',
-        score: 0.9,
-        context: null,
-        snippet: 'Line 1\nLine 2',
-      }];
-      const csv = searchResultsToMcpCsv(results);
-      expect(csv).not.toContain('\n\n'); // Should be escaped within quotes
-    });
-
-    test("handles empty results", () => {
-      const csv = searchResultsToMcpCsv([]);
-      expect(csv).toBe("file,title,score,context,snippet");
+  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");
+      }
     });
   });
 });

+ 173 - 69
mcp.ts

@@ -4,6 +4,8 @@
  *
  * Exposes QMD search and document retrieval as MCP tools and resources.
  * Documents are accessible via qmd:// URIs.
+ *
+ * Follows MCP spec 2025-06-18 for proper response types.
  */
 
 import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -19,7 +21,62 @@ import {
   DEFAULT_MULTI_GET_MAX_BYTES,
 } from "./store.js";
 import type { RankedResult } from "./store.js";
-import { searchResultsToMcpCsv } from "./formatter.js";
+
+// =============================================================================
+// Types for structured content
+// =============================================================================
+
+type SearchResultItem = {
+  file: string;
+  title: string;
+  score: number;
+  context: string | null;
+  snippet: string;
+};
+
+type StatusResult = {
+  totalDocuments: number;
+  needsEmbedding: number;
+  hasVectorIndex: boolean;
+  collections: {
+    id: number;
+    path: string;
+    pattern: string;
+    documents: number;
+    lastUpdated: string;
+  }[];
+};
+
+// =============================================================================
+// Helper functions
+// =============================================================================
+
+/**
+ * Encode a path for use in qmd:// URIs.
+ * Encodes special characters but preserves forward slashes for readability.
+ */
+function encodeQmdPath(path: string): string {
+  // Encode each path segment separately to preserve slashes
+  return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
+}
+
+/**
+ * Format search results as human-readable text summary
+ */
+function formatSearchSummary(results: SearchResultItem[], query: string): string {
+  if (results.length === 0) {
+    return `No results found for "${query}"`;
+  }
+  const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`];
+  for (const r of results) {
+    lines.push(`${Math.round(r.score * 100)}% ${r.file} - ${r.title}`);
+  }
+  return lines.join('\n');
+}
+
+// =============================================================================
+// MCP Server
+// =============================================================================
 
 export async function startMcpServer(): Promise<void> {
   // Open database once at startup - keep it open for the lifetime of the server
@@ -30,11 +87,13 @@ export async function startMcpServer(): Promise<void> {
     version: "1.0.0",
   });
 
-  // Register resource template for qmd:// URIs
-  // This allows clients to list and read documents via the MCP resources API
+  // ---------------------------------------------------------------------------
+  // Resource: qmd://{path}
+  // ---------------------------------------------------------------------------
+
   server.registerResource(
     "document",
-    new ResourceTemplate("qmd://{path}", {
+    new ResourceTemplate("qmd://{+path}", {
       list: async () => {
         // List all indexed documents
         const docs = store.db.prepare(`
@@ -47,8 +106,9 @@ export async function startMcpServer(): Promise<void> {
 
         return {
           resources: docs.map(doc => ({
-            uri: `qmd://${encodeURIComponent(doc.display_path)}`,
-            name: doc.title || doc.display_path,
+            uri: `qmd://${encodeQmdPath(doc.display_path)}`,
+            name: doc.display_path,
+            title: doc.title || doc.display_path,
             mimeType: "text/markdown",
           })),
         };
@@ -64,11 +124,11 @@ export async function startMcpServer(): Promise<void> {
       const decodedPath = decodeURIComponent(path);
 
       // Find document by display_path
-      let doc = store.db.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;
+      let doc = store.db.prepare(`SELECT filepath, display_path, title, body FROM documents WHERE display_path = ? AND active = 1`).get(decodedPath) as { filepath: string; display_path: string; title: string; body: string } | null;
 
       // Try suffix match if exact match fails
       if (!doc) {
-        doc = store.db.prepare(`SELECT filepath, display_path, body FROM documents WHERE display_path LIKE ? AND active = 1 LIMIT 1`).get(`%${decodedPath}`) as { filepath: string; display_path: string; body: string } | null;
+        doc = store.db.prepare(`SELECT filepath, display_path, title, body FROM documents WHERE display_path LIKE ? AND active = 1 LIMIT 1`).get(`%${decodedPath}`) as { filepath: string; display_path: string; title: string; body: string } | null;
       }
 
       if (!doc) {
@@ -85,6 +145,8 @@ export async function startMcpServer(): Promise<void> {
       return {
         contents: [{
           uri: uri.href,
+          name: doc.display_path,
+          title: doc.title || doc.display_path,
           mimeType: "text/markdown",
           text,
         }],
@@ -92,7 +154,10 @@ export async function startMcpServer(): Promise<void> {
     }
   );
 
-  // Register the query prompt - describes ideal usage
+  // ---------------------------------------------------------------------------
+  // Prompt: query guide
+  // ---------------------------------------------------------------------------
+
   server.registerPrompt(
     "query",
     {
@@ -111,21 +176,21 @@ QMD is your on-device search engine for markdown knowledge bases. Use it to find
 
 ## Available Tools
 
-### 1. qmd_search (Fast keyword search)
+### 1. search (Fast keyword search)
 Best for: Finding documents with specific keywords or phrases.
 - Uses BM25 full-text search
 - Fast, no LLM required
 - Good for exact matches
 - Use \`collection\` parameter to filter to a specific collection
 
-### 2. qmd_vsearch (Semantic search)
+### 2. vsearch (Semantic search)
 Best for: Finding conceptually related content even without exact keyword matches.
 - Uses vector embeddings
 - Understands meaning and context
 - Good for "how do I..." or conceptual queries
 - Use \`collection\` parameter to filter to a specific collection
 
-### 3. qmd_query (Hybrid search - highest quality)
+### 3. query (Hybrid search - highest quality)
 Best for: Important searches where you want the best results.
 - Combines keyword + semantic search
 - Expands your query with variations
@@ -133,19 +198,19 @@ Best for: Important searches where you want the best results.
 - Slower but most accurate
 - Use \`collection\` parameter to filter to a specific collection
 
-### 4. qmd_get (Retrieve document)
+### 4. get (Retrieve document)
 Best for: Getting the full content of a single document you found.
 - Use the file path from search results
 - Supports line ranges: \`file.md:100\` or fromLine/maxLines parameters
 - Suggests similar files if not found
 
-### 5. qmd_multi_get (Retrieve multiple documents)
+### 5. multi_get (Retrieve multiple documents)
 Best for: Getting content from multiple files at once.
 - Use glob patterns: \`journals/2025-05*.md\`
 - Or comma-separated: \`file1.md, file2.md\`
-- Skips files over maxBytes (default 10KB) - use qmd_get for large files
+- Skips files over maxBytes (default 10KB) - use get for large files
 
-### 6. qmd_status (Index info)
+### 6. status (Index info)
 Shows collection info, document counts, and embedding status.
 
 ## Resources
@@ -156,11 +221,11 @@ You can also access documents directly via the \`qmd://\` URI scheme:
 
 ## Search Strategy
 
-1. **Start with qmd_search** for quick keyword lookups
-2. **Use qmd_vsearch** when keywords aren't working or for conceptual queries
-3. **Use qmd_query** for important searches or when you need high confidence
-4. **Use qmd_get** to retrieve a single full document
-5. **Use qmd_multi_get** to batch retrieve multiple related files
+1. **Start with search** for quick keyword lookups
+2. **Use vsearch** when keywords aren't working or for conceptual queries
+3. **Use query** for important searches or when you need high confidence
+4. **Use get** to retrieve a single full document
+5. **Use multi_get** to batch retrieve multiple related files
 
 ## Tips
 
@@ -175,9 +240,12 @@ You can also access documents directly via the \`qmd://\` URI scheme:
     })
   );
 
-  // Tool: search (BM25 full-text)
+  // ---------------------------------------------------------------------------
+  // Tool: qmd_search (BM25 full-text)
+  // ---------------------------------------------------------------------------
+
   server.registerTool(
-    "qmd_search",
+    "search",
     {
       title: "Search (BM25)",
       description: "Fast keyword-based full-text search using BM25. Best for finding documents with specific words or phrases.",
@@ -194,12 +262,15 @@ You can also access documents directly via the \`qmd://\` URI scheme:
       if (collection) {
         collectionId = store.getCollectionIdByName(collection) ?? undefined;
         if (collectionId === undefined) {
-          return { content: [{ type: "text", text: `Error: Collection not found: ${collection}` }] };
+          return {
+            content: [{ type: "text", text: `Collection not found: ${collection}` }],
+            isError: true,
+          };
         }
       }
 
       const results = store.searchFTS(query, limit || 10, collectionId);
-      const filtered = results
+      const filtered: SearchResultItem[] = results
         .filter(r => r.score >= (minScore || 0))
         .map(r => ({
           file: r.displayPath,
@@ -210,20 +281,18 @@ You can also access documents directly via the \`qmd://\` URI scheme:
         }));
 
       return {
-        content: [
-          {
-            type: "text",
-            mimeType: "text/csv",
-            text: searchResultsToMcpCsv(filtered),
-          },
-        ],
+        content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
+        structuredContent: { results: filtered },
       };
     }
   );
 
-  // Tool: vsearch (Vector semantic search)
+  // ---------------------------------------------------------------------------
+  // Tool: qmd_vsearch (Vector semantic search)
+  // ---------------------------------------------------------------------------
+
   server.registerTool(
-    "qmd_vsearch",
+    "vsearch",
     {
       title: "Vector Search (Semantic)",
       description: "Semantic similarity search using vector embeddings. Finds conceptually related content even without exact keyword matches. Requires embeddings (run 'qmd embed' first).",
@@ -240,14 +309,18 @@ You can also access documents directly via the \`qmd://\` URI scheme:
       if (collection) {
         collectionId = store.getCollectionIdByName(collection) ?? undefined;
         if (collectionId === undefined) {
-          return { content: [{ type: "text", text: `Error: Collection not found: ${collection}` }] };
+          return {
+            content: [{ type: "text", text: `Collection not found: ${collection}` }],
+            isError: true,
+          };
         }
       }
 
       const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
       if (!tableExists) {
         return {
-          content: [{ type: "text", text: "Error: Vector index not found. Run 'qmd embed' first to create embeddings." }],
+          content: [{ type: "text", text: "Vector index not found. Run 'qmd embed' first to create embeddings." }],
+          isError: true,
         };
       }
 
@@ -266,7 +339,7 @@ You can also access documents directly via the \`qmd://\` URI scheme:
         }
       }
 
-      const filtered = Array.from(allResults.values())
+      const filtered: SearchResultItem[] = Array.from(allResults.values())
         .sort((a, b) => b.score - a.score)
         .slice(0, limit || 10)
         .filter(r => r.score >= (minScore || 0.3))
@@ -279,20 +352,18 @@ You can also access documents directly via the \`qmd://\` URI scheme:
         }));
 
       return {
-        content: [
-          {
-            type: "text",
-            mimeType: "text/csv",
-            text: searchResultsToMcpCsv(filtered),
-          },
-        ],
+        content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
+        structuredContent: { results: filtered },
       };
     }
   );
 
-  // Tool: query (Hybrid with reranking)
+  // ---------------------------------------------------------------------------
+  // Tool: qmd_query (Hybrid with reranking)
+  // ---------------------------------------------------------------------------
+
   server.registerTool(
-    "qmd_query",
+    "query",
     {
       title: "Hybrid Query (Best Quality)",
       description: "Highest quality search combining BM25 + vector + query expansion + LLM reranking. Slower but most accurate. Use for important searches.",
@@ -309,7 +380,10 @@ You can also access documents directly via the \`qmd://\` URI scheme:
       if (collection) {
         collectionId = store.getCollectionIdByName(collection) ?? undefined;
         if (collectionId === undefined) {
-          return { content: [{ type: "text", text: `Error: Collection not found: ${collection}` }] };
+          return {
+            content: [{ type: "text", text: `Collection not found: ${collection}` }],
+            isError: true,
+          };
         }
       }
 
@@ -349,7 +423,7 @@ You can also access documents directly via the \`qmd://\` URI scheme:
       const candidateMap = new Map(candidates.map(c => [c.file, { displayPath: c.displayPath, title: c.title, body: c.body }]));
       const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
 
-      const finalResults = reranked.map(r => {
+      const filtered: SearchResultItem[] = reranked.map(r => {
         const rrfRank = rrfRankMap.get(r.file) || candidates.length;
         let rrfWeight: number;
         if (rrfRank <= 3) rrfWeight = 0.75;
@@ -368,20 +442,18 @@ You can also access documents directly via the \`qmd://\` URI scheme:
       }).filter(r => r.score >= (minScore || 0)).slice(0, limit || 10);
 
       return {
-        content: [
-          {
-            type: "text",
-            mimeType: "text/csv",
-            text: searchResultsToMcpCsv(finalResults),
-          },
-        ],
+        content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
+        structuredContent: { results: filtered },
       };
     }
   );
 
-  // Tool: get (Retrieve document)
+  // ---------------------------------------------------------------------------
+  // Tool: qmd_get (Retrieve document)
+  // ---------------------------------------------------------------------------
+
   server.registerTool(
-    "qmd_get",
+    "get",
     {
       title: "Get Document",
       description: "Retrieve the full content of a document by its file path. Use paths from search results. Suggests similar files if not found.",
@@ -395,11 +467,14 @@ You can also access documents directly via the \`qmd://\` URI scheme:
       const result = store.getDocument(file, fromLine, maxLines);
 
       if ("error" in result) {
-        let msg = `Error: Document not found: ${file}`;
+        let msg = `Document not found: ${file}`;
         if (result.similarFiles.length > 0) {
           msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => `  - ${s}`).join('\n')}`;
         }
-        return { content: [{ type: "text", text: msg }] };
+        return {
+          content: [{ type: "text", text: msg }],
+          isError: true,
+        };
       }
 
       let text = result.body;
@@ -411,7 +486,9 @@ You can also access documents directly via the \`qmd://\` URI scheme:
         content: [{
           type: "resource",
           resource: {
-            uri: `qmd://${result.displayPath}`,
+            uri: `qmd://${encodeQmdPath(result.displayPath)}`,
+            name: result.displayPath,
+            title: result.title,
             mimeType: "text/markdown",
             text,
           },
@@ -420,9 +497,12 @@ You can also access documents directly via the \`qmd://\` URI scheme:
     }
   );
 
-  // Tool: multi-get (Retrieve multiple documents)
+  // ---------------------------------------------------------------------------
+  // Tool: qmd_multi_get (Retrieve multiple documents)
+  // ---------------------------------------------------------------------------
+
   server.registerTool(
-    "qmd_multi_get",
+    "multi_get",
     {
       title: "Multi-Get Documents",
       description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
@@ -436,10 +516,13 @@ You can also access documents directly via the \`qmd://\` URI scheme:
       const { files, errors } = store.getMultipleDocuments(pattern, maxLines, maxBytes || DEFAULT_MULTI_GET_MAX_BYTES);
 
       if (files.length === 0 && errors.length === 0) {
-        return { content: [{ type: "text", text: `No files matched pattern: ${pattern}` }] };
+        return {
+          content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
+          isError: true,
+        };
       }
 
-      const content: ({ type: "text"; text: string } | { type: "resource"; resource: { uri: string; mimeType: string; text: string } })[] = [];
+      const content: ({ type: "text"; text: string } | { type: "resource"; resource: { uri: string; name: string; title?: string; mimeType: string; text: string } })[] = [];
 
       if (errors.length > 0) {
         content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
@@ -462,7 +545,9 @@ You can also access documents directly via the \`qmd://\` URI scheme:
         content.push({
           type: "resource",
           resource: {
-            uri: `qmd://${file.displayPath}`,
+            uri: `qmd://${encodeQmdPath(file.displayPath)}`,
+            name: file.displayPath,
+            title: file.title,
             mimeType: "text/markdown",
             text,
           },
@@ -473,24 +558,43 @@ You can also access documents directly via the \`qmd://\` URI scheme:
     }
   );
 
-  // Tool: status (Index status)
+  // ---------------------------------------------------------------------------
+  // Tool: qmd_status (Index status)
+  // ---------------------------------------------------------------------------
+
   server.registerTool(
-    "qmd_status",
+    "status",
     {
       title: "Index Status",
       description: "Show the status of the QMD index: collections, document counts, and health information.",
       inputSchema: {},
     },
     async () => {
-      const status = store.getStatus();
+      const status: StatusResult = store.getStatus();
+
+      const summary = [
+        `QMD Index Status:`,
+        `  Total documents: ${status.totalDocuments}`,
+        `  Needs embedding: ${status.needsEmbedding}`,
+        `  Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
+        `  Collections: ${status.collections.length}`,
+      ];
+
+      for (const col of status.collections) {
+        summary.push(`    - ${col.path} (${col.documents} docs)`);
+      }
 
       return {
-        content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
+        content: [{ type: "text", text: summary.join('\n') }],
+        structuredContent: status,
       };
     }
   );
 
+  // ---------------------------------------------------------------------------
   // Connect via stdio
+  // ---------------------------------------------------------------------------
+
   const transport = new StdioServerTransport();
   await server.connect(transport);