mcp.ts 18 KB

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