mcp.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. #!/usr/bin/env bun
  2. /**
  3. * QMD MCP Server - Model Context Protocol server for QMD
  4. *
  5. * Exposes QMD search and document retrieval as MCP tools and resources.
  6. * Documents are accessible via qmd:// URIs.
  7. *
  8. * Follows MCP spec 2025-06-18 for proper response types.
  9. */
  10. import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
  11. import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  12. import { z } from "zod";
  13. import {
  14. createStore,
  15. reciprocalRankFusion,
  16. extractSnippet,
  17. DEFAULT_EMBED_MODEL,
  18. DEFAULT_QUERY_MODEL,
  19. DEFAULT_RERANK_MODEL,
  20. DEFAULT_MULTI_GET_MAX_BYTES,
  21. } from "./store.js";
  22. import type { RankedResult } from "./store.js";
  23. // =============================================================================
  24. // Types for structured content
  25. // =============================================================================
  26. type SearchResultItem = {
  27. file: string;
  28. title: string;
  29. score: number;
  30. context: string | null;
  31. snippet: string;
  32. };
  33. type StatusResult = {
  34. totalDocuments: number;
  35. needsEmbedding: number;
  36. hasVectorIndex: boolean;
  37. collections: {
  38. id: number;
  39. path: string;
  40. pattern: string;
  41. documents: number;
  42. lastUpdated: string;
  43. }[];
  44. };
  45. // =============================================================================
  46. // Helper functions
  47. // =============================================================================
  48. /**
  49. * Encode a path for use in qmd:// URIs.
  50. * Encodes special characters but preserves forward slashes for readability.
  51. */
  52. function encodeQmdPath(path: string): string {
  53. // Encode each path segment separately to preserve slashes
  54. return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
  55. }
  56. /**
  57. * Format search results as human-readable text summary
  58. */
  59. function formatSearchSummary(results: SearchResultItem[], query: string): string {
  60. if (results.length === 0) {
  61. return `No results found for "${query}"`;
  62. }
  63. const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`];
  64. for (const r of results) {
  65. lines.push(`${Math.round(r.score * 100)}% ${r.file} - ${r.title}`);
  66. }
  67. return lines.join('\n');
  68. }
  69. // =============================================================================
  70. // MCP Server
  71. // =============================================================================
  72. export async function startMcpServer(): Promise<void> {
  73. // Open database once at startup - keep it open for the lifetime of the server
  74. const store = createStore();
  75. const server = new McpServer({
  76. name: "qmd",
  77. version: "1.0.0",
  78. });
  79. // ---------------------------------------------------------------------------
  80. // Resource: qmd://{path}
  81. // ---------------------------------------------------------------------------
  82. server.registerResource(
  83. "document",
  84. new ResourceTemplate("qmd://{+path}", {
  85. list: async () => {
  86. // List all indexed documents
  87. const docs = store.db.prepare(`
  88. SELECT display_path, title
  89. FROM documents
  90. WHERE active = 1
  91. ORDER BY modified_at DESC
  92. LIMIT 1000
  93. `).all() as { display_path: string; title: string }[];
  94. return {
  95. resources: docs.map(doc => ({
  96. uri: `qmd://${encodeQmdPath(doc.display_path)}`,
  97. name: doc.display_path,
  98. title: doc.title || doc.display_path,
  99. mimeType: "text/markdown",
  100. })),
  101. };
  102. },
  103. }),
  104. {
  105. title: "QMD Document",
  106. description: "A markdown document from your QMD knowledge base",
  107. mimeType: "text/markdown",
  108. },
  109. async (uri, { path }) => {
  110. // Decode URL-encoded path (MCP clients send encoded URIs)
  111. const decodedPath = decodeURIComponent(path);
  112. // Find document by display_path
  113. 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;
  114. // Try suffix match if exact match fails
  115. if (!doc) {
  116. 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;
  117. }
  118. if (!doc) {
  119. return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
  120. }
  121. const context = store.getContextForFile(doc.filepath);
  122. let text = doc.body;
  123. if (context) {
  124. text = `<!-- Context: ${context} -->\n\n` + text;
  125. }
  126. return {
  127. contents: [{
  128. uri: uri.href,
  129. name: doc.display_path,
  130. title: doc.title || doc.display_path,
  131. mimeType: "text/markdown",
  132. text,
  133. }],
  134. };
  135. }
  136. );
  137. // ---------------------------------------------------------------------------
  138. // Prompt: query guide
  139. // ---------------------------------------------------------------------------
  140. server.registerPrompt(
  141. "query",
  142. {
  143. title: "QMD Query Guide",
  144. description: "How to effectively search your knowledge base with QMD",
  145. },
  146. () => ({
  147. messages: [
  148. {
  149. role: "user",
  150. content: {
  151. type: "text",
  152. text: `# QMD - Quick Markdown Search
  153. QMD is your on-device search engine for markdown knowledge bases. Use it to find information across your notes, documents, and meeting transcripts.
  154. ## Available Tools
  155. ### 1. search (Fast keyword search)
  156. Best for: Finding documents with specific keywords or phrases.
  157. - Uses BM25 full-text search
  158. - Fast, no LLM required
  159. - Good for exact matches
  160. - Use \`collection\` parameter to filter to a specific collection
  161. ### 2. vsearch (Semantic search)
  162. Best for: Finding conceptually related content even without exact keyword matches.
  163. - Uses vector embeddings
  164. - Understands meaning and context
  165. - Good for "how do I..." or conceptual queries
  166. - Use \`collection\` parameter to filter to a specific collection
  167. ### 3. query (Hybrid search - highest quality)
  168. Best for: Important searches where you want the best results.
  169. - Combines keyword + semantic search
  170. - Expands your query with variations
  171. - Re-ranks results with LLM
  172. - Slower but most accurate
  173. - Use \`collection\` parameter to filter to a specific collection
  174. ### 4. get (Retrieve document)
  175. Best for: Getting the full content of a single document you found.
  176. - Use the file path from search results
  177. - Supports line ranges: \`file.md:100\` or fromLine/maxLines parameters
  178. - Suggests similar files if not found
  179. ### 5. multi_get (Retrieve multiple documents)
  180. Best for: Getting content from multiple files at once.
  181. - Use glob patterns: \`journals/2025-05*.md\`
  182. - Or comma-separated: \`file1.md, file2.md\`
  183. - Skips files over maxBytes (default 10KB) - use get for large files
  184. ### 6. status (Index info)
  185. Shows collection info, document counts, and embedding status.
  186. ## Resources
  187. You can also access documents directly via the \`qmd://\` URI scheme:
  188. - List all documents: \`resources/list\`
  189. - Read a document: \`resources/read\` with uri \`qmd://path/to/file.md\`
  190. ## Search Strategy
  191. 1. **Start with search** for quick keyword lookups
  192. 2. **Use vsearch** when keywords aren't working or for conceptual queries
  193. 3. **Use query** for important searches or when you need high confidence
  194. 4. **Use get** to retrieve a single full document
  195. 5. **Use multi_get** to batch retrieve multiple related files
  196. ## Tips
  197. - Use \`minScore: 0.5\` to filter low-relevance results
  198. - Use \`collection: "notes"\` to search only in a specific collection
  199. - Check the "Context" field - it describes what kind of content the file contains
  200. - File paths are relative to their collection (e.g., \`pages/meeting.md\`)
  201. - For glob patterns, match on display_path (e.g., \`journals/2025-*.md\`)`,
  202. },
  203. },
  204. ],
  205. })
  206. );
  207. // ---------------------------------------------------------------------------
  208. // Tool: qmd_search (BM25 full-text)
  209. // ---------------------------------------------------------------------------
  210. server.registerTool(
  211. "search",
  212. {
  213. title: "Search (BM25)",
  214. description: "Fast keyword-based full-text search using BM25. Best for finding documents with specific words or phrases.",
  215. inputSchema: {
  216. query: z.string().describe("Search query - keywords or phrases to find"),
  217. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  218. minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
  219. collection: z.string().optional().describe("Filter to a specific collection by name"),
  220. },
  221. },
  222. async ({ query, limit, minScore, collection }) => {
  223. // Resolve collection filter
  224. let collectionId: number | undefined;
  225. if (collection) {
  226. collectionId = store.getCollectionIdByName(collection) ?? undefined;
  227. if (collectionId === undefined) {
  228. return {
  229. content: [{ type: "text", text: `Collection not found: ${collection}` }],
  230. isError: true,
  231. };
  232. }
  233. }
  234. const results = store.searchFTS(query, limit || 10, collectionId);
  235. const filtered: SearchResultItem[] = results
  236. .filter(r => r.score >= (minScore || 0))
  237. .map(r => ({
  238. file: r.displayPath,
  239. title: r.title,
  240. score: Math.round(r.score * 100) / 100,
  241. context: store.getContextForFile(r.file),
  242. snippet: extractSnippet(r.body, query, 300, r.chunkPos).snippet,
  243. }));
  244. return {
  245. content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
  246. structuredContent: { results: filtered },
  247. };
  248. }
  249. );
  250. // ---------------------------------------------------------------------------
  251. // Tool: qmd_vsearch (Vector semantic search)
  252. // ---------------------------------------------------------------------------
  253. server.registerTool(
  254. "vsearch",
  255. {
  256. title: "Vector Search (Semantic)",
  257. description: "Semantic similarity search using vector embeddings. Finds conceptually related content even without exact keyword matches. Requires embeddings (run 'qmd embed' first).",
  258. inputSchema: {
  259. query: z.string().describe("Natural language query - describe what you're looking for"),
  260. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  261. minScore: z.number().optional().default(0.3).describe("Minimum relevance score 0-1 (default: 0.3)"),
  262. collection: z.string().optional().describe("Filter to a specific collection by name"),
  263. },
  264. },
  265. async ({ query, limit, minScore, collection }) => {
  266. // Resolve collection filter
  267. let collectionId: number | undefined;
  268. if (collection) {
  269. collectionId = store.getCollectionIdByName(collection) ?? undefined;
  270. if (collectionId === undefined) {
  271. return {
  272. content: [{ type: "text", text: `Collection not found: ${collection}` }],
  273. isError: true,
  274. };
  275. }
  276. }
  277. const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  278. if (!tableExists) {
  279. return {
  280. content: [{ type: "text", text: "Vector index not found. Run 'qmd embed' first to create embeddings." }],
  281. isError: true,
  282. };
  283. }
  284. // Expand query
  285. const queries = await store.expandQuery(query, DEFAULT_QUERY_MODEL);
  286. // Collect results
  287. const allResults = new Map<string, { file: string; displayPath: string; title: string; body: string; score: number }>();
  288. for (const q of queries) {
  289. const vecResults = await store.searchVec(q, DEFAULT_EMBED_MODEL, limit || 10, collectionId);
  290. for (const r of vecResults) {
  291. const existing = allResults.get(r.file);
  292. if (!existing || r.score > existing.score) {
  293. allResults.set(r.file, { file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score });
  294. }
  295. }
  296. }
  297. const filtered: SearchResultItem[] = Array.from(allResults.values())
  298. .sort((a, b) => b.score - a.score)
  299. .slice(0, limit || 10)
  300. .filter(r => r.score >= (minScore || 0.3))
  301. .map(r => ({
  302. file: r.displayPath,
  303. title: r.title,
  304. score: Math.round(r.score * 100) / 100,
  305. context: store.getContextForFile(r.file),
  306. snippet: extractSnippet(r.body, query, 300).snippet,
  307. }));
  308. return {
  309. content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
  310. structuredContent: { results: filtered },
  311. };
  312. }
  313. );
  314. // ---------------------------------------------------------------------------
  315. // Tool: qmd_query (Hybrid with reranking)
  316. // ---------------------------------------------------------------------------
  317. server.registerTool(
  318. "query",
  319. {
  320. title: "Hybrid Query (Best Quality)",
  321. description: "Highest quality search combining BM25 + vector + query expansion + LLM reranking. Slower but most accurate. Use for important searches.",
  322. inputSchema: {
  323. query: z.string().describe("Natural language query - describe what you're looking for"),
  324. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  325. minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
  326. collection: z.string().optional().describe("Filter to a specific collection by name"),
  327. },
  328. },
  329. async ({ query, limit, minScore, collection }) => {
  330. // Resolve collection filter
  331. let collectionId: number | undefined;
  332. if (collection) {
  333. collectionId = store.getCollectionIdByName(collection) ?? undefined;
  334. if (collectionId === undefined) {
  335. return {
  336. content: [{ type: "text", text: `Collection not found: ${collection}` }],
  337. isError: true,
  338. };
  339. }
  340. }
  341. // Expand query
  342. const queries = await store.expandQuery(query, DEFAULT_QUERY_MODEL);
  343. // Collect ranked lists
  344. const rankedLists: RankedResult[][] = [];
  345. const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  346. for (const q of queries) {
  347. const ftsResults = store.searchFTS(q, 20, collectionId);
  348. if (ftsResults.length > 0) {
  349. rankedLists.push(ftsResults.map(r => ({ file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score })));
  350. }
  351. if (hasVectors) {
  352. const vecResults = await store.searchVec(q, DEFAULT_EMBED_MODEL, 20, collectionId);
  353. if (vecResults.length > 0) {
  354. rankedLists.push(vecResults.map(r => ({ file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score })));
  355. }
  356. }
  357. }
  358. // RRF fusion
  359. const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
  360. const fused = reciprocalRankFusion(rankedLists, weights);
  361. const candidates = fused.slice(0, 30);
  362. // Rerank
  363. const reranked = await store.rerank(
  364. query,
  365. candidates.map(c => ({ file: c.file, text: c.body })),
  366. DEFAULT_RERANK_MODEL
  367. );
  368. // Blend scores
  369. const candidateMap = new Map(candidates.map(c => [c.file, { displayPath: c.displayPath, title: c.title, body: c.body }]));
  370. const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
  371. const filtered: SearchResultItem[] = reranked.map(r => {
  372. const rrfRank = rrfRankMap.get(r.file) || candidates.length;
  373. let rrfWeight: number;
  374. if (rrfRank <= 3) rrfWeight = 0.75;
  375. else if (rrfRank <= 10) rrfWeight = 0.60;
  376. else rrfWeight = 0.40;
  377. const rrfScore = 1 / rrfRank;
  378. const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
  379. const candidate = candidateMap.get(r.file);
  380. return {
  381. file: candidate?.displayPath || "",
  382. title: candidate?.title || "",
  383. score: Math.round(blendedScore * 100) / 100,
  384. context: store.getContextForFile(r.file),
  385. snippet: extractSnippet(candidate?.body || "", query, 300).snippet,
  386. };
  387. }).filter(r => r.score >= (minScore || 0)).slice(0, limit || 10);
  388. return {
  389. content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
  390. structuredContent: { results: filtered },
  391. };
  392. }
  393. );
  394. // ---------------------------------------------------------------------------
  395. // Tool: qmd_get (Retrieve document)
  396. // ---------------------------------------------------------------------------
  397. server.registerTool(
  398. "get",
  399. {
  400. title: "Get Document",
  401. description: "Retrieve the full content of a document by its file path. Use paths from search results. Suggests similar files if not found.",
  402. inputSchema: {
  403. file: z.string().describe("File path from search results (e.g., 'pages/meeting.md' or 'pages/meeting.md:100' to start at line 100)"),
  404. fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
  405. maxLines: z.number().optional().describe("Maximum number of lines to return"),
  406. },
  407. },
  408. async ({ file, fromLine, maxLines }) => {
  409. const result = store.getDocument(file, fromLine, maxLines);
  410. if ("error" in result) {
  411. let msg = `Document not found: ${file}`;
  412. if (result.similarFiles.length > 0) {
  413. msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`;
  414. }
  415. return {
  416. content: [{ type: "text", text: msg }],
  417. isError: true,
  418. };
  419. }
  420. let text = result.body;
  421. if (result.context) {
  422. text = `<!-- Context: ${result.context} -->\n\n` + text;
  423. }
  424. return {
  425. content: [{
  426. type: "resource",
  427. resource: {
  428. uri: `qmd://${encodeQmdPath(result.displayPath)}`,
  429. name: result.displayPath,
  430. title: result.title,
  431. mimeType: "text/markdown",
  432. text,
  433. },
  434. }],
  435. };
  436. }
  437. );
  438. // ---------------------------------------------------------------------------
  439. // Tool: qmd_multi_get (Retrieve multiple documents)
  440. // ---------------------------------------------------------------------------
  441. server.registerTool(
  442. "multi_get",
  443. {
  444. title: "Multi-Get Documents",
  445. description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
  446. inputSchema: {
  447. pattern: z.string().describe("Glob pattern or comma-separated list of file paths"),
  448. maxLines: z.number().optional().describe("Maximum lines per file"),
  449. maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"),
  450. },
  451. },
  452. async ({ pattern, maxLines, maxBytes }) => {
  453. const { files, errors } = store.getMultipleDocuments(pattern, maxLines, maxBytes || DEFAULT_MULTI_GET_MAX_BYTES);
  454. if (files.length === 0 && errors.length === 0) {
  455. return {
  456. content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
  457. isError: true,
  458. };
  459. }
  460. const content: ({ type: "text"; text: string } | { type: "resource"; resource: { uri: string; name: string; title?: string; mimeType: string; text: string } })[] = [];
  461. if (errors.length > 0) {
  462. content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
  463. }
  464. for (const file of files) {
  465. if (file.skipped) {
  466. content.push({
  467. type: "text",
  468. text: `[SKIPPED: ${file.displayPath} - ${file.skipReason}. Use 'qmd_get' with file="${file.displayPath}" to retrieve.]`,
  469. });
  470. continue;
  471. }
  472. let text = file.body;
  473. if (file.context) {
  474. text = `<!-- Context: ${file.context} -->\n\n` + text;
  475. }
  476. content.push({
  477. type: "resource",
  478. resource: {
  479. uri: `qmd://${encodeQmdPath(file.displayPath)}`,
  480. name: file.displayPath,
  481. title: file.title,
  482. mimeType: "text/markdown",
  483. text,
  484. },
  485. });
  486. }
  487. return { content };
  488. }
  489. );
  490. // ---------------------------------------------------------------------------
  491. // Tool: qmd_status (Index status)
  492. // ---------------------------------------------------------------------------
  493. server.registerTool(
  494. "status",
  495. {
  496. title: "Index Status",
  497. description: "Show the status of the QMD index: collections, document counts, and health information.",
  498. inputSchema: {},
  499. },
  500. async () => {
  501. const status: StatusResult = store.getStatus();
  502. const summary = [
  503. `QMD Index Status:`,
  504. ` Total documents: ${status.totalDocuments}`,
  505. ` Needs embedding: ${status.needsEmbedding}`,
  506. ` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
  507. ` Collections: ${status.collections.length}`,
  508. ];
  509. for (const col of status.collections) {
  510. summary.push(` - ${col.path} (${col.documents} docs)`);
  511. }
  512. return {
  513. content: [{ type: "text", text: summary.join('\n') }],
  514. structuredContent: status,
  515. };
  516. }
  517. );
  518. // ---------------------------------------------------------------------------
  519. // Connect via stdio
  520. // ---------------------------------------------------------------------------
  521. const transport = new StdioServerTransport();
  522. await server.connect(transport);
  523. // Note: Database stays open - it will be closed when the process exits
  524. }
  525. // Run if this is the main module
  526. if (import.meta.main) {
  527. startMcpServer().catch(console.error);
  528. }