mcp.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  1. /**
  2. * QMD MCP Server - Model Context Protocol server for QMD
  3. *
  4. * Exposes QMD search and document retrieval as MCP tools and resources.
  5. * Documents are accessible via qmd:// URIs.
  6. *
  7. * Follows MCP spec 2025-06-18 for proper response types.
  8. */
  9. import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
  10. import { randomUUID } from "node:crypto";
  11. import { fileURLToPath } from "url";
  12. import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
  13. import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  14. import { WebStandardStreamableHTTPServerTransport }
  15. from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
  16. import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
  17. import { z } from "zod";
  18. import {
  19. createStore,
  20. extractSnippet,
  21. addLineNumbers,
  22. structuredSearch,
  23. DEFAULT_MULTI_GET_MAX_BYTES,
  24. } from "./store.js";
  25. import type { Store, StructuredSubSearch } from "./store.js";
  26. import { getCollection, getGlobalContext, getDefaultCollectionNames } from "./collections.js";
  27. import { disposeDefaultLlamaCpp } from "./llm.js";
  28. // =============================================================================
  29. // Types for structured content
  30. // =============================================================================
  31. type SearchResultItem = {
  32. docid: string; // Short docid (#abc123) for quick reference
  33. file: string;
  34. title: string;
  35. score: number;
  36. context: string | null;
  37. snippet: string;
  38. };
  39. type StatusResult = {
  40. totalDocuments: number;
  41. needsEmbedding: number;
  42. hasVectorIndex: boolean;
  43. collections: {
  44. name: string;
  45. path: string;
  46. pattern: string;
  47. documents: number;
  48. lastUpdated: string;
  49. }[];
  50. };
  51. // =============================================================================
  52. // Helper functions
  53. // =============================================================================
  54. /**
  55. * Encode a path for use in qmd:// URIs.
  56. * Encodes special characters but preserves forward slashes for readability.
  57. */
  58. function encodeQmdPath(path: string): string {
  59. // Encode each path segment separately to preserve slashes
  60. return path.split('/').map(segment => encodeURIComponent(segment)).join('/');
  61. }
  62. /**
  63. * Format search results as human-readable text summary
  64. */
  65. function formatSearchSummary(results: SearchResultItem[], query: string): string {
  66. if (results.length === 0) {
  67. return `No results found for "${query}"`;
  68. }
  69. const lines = [`Found ${results.length} result${results.length === 1 ? '' : 's'} for "${query}":\n`];
  70. for (const r of results) {
  71. lines.push(`${r.docid} ${Math.round(r.score * 100)}% ${r.file} - ${r.title}`);
  72. }
  73. return lines.join('\n');
  74. }
  75. // =============================================================================
  76. // MCP Server
  77. // =============================================================================
  78. /**
  79. * Build dynamic server instructions from actual index state.
  80. * Injected into the LLM's system prompt via MCP initialize response —
  81. * gives the LLM immediate context about what's searchable without a tool call.
  82. */
  83. function buildInstructions(store: Store): string {
  84. const status = store.getStatus();
  85. const lines: string[] = [];
  86. // --- What is this? ---
  87. const globalCtx = getGlobalContext();
  88. lines.push(`QMD is your local search engine over ${status.totalDocuments} markdown documents.`);
  89. if (globalCtx) lines.push(`Context: ${globalCtx}`);
  90. // --- What's searchable? ---
  91. if (status.collections.length > 0) {
  92. lines.push("");
  93. lines.push("Collections (scope with `collection` parameter):");
  94. for (const col of status.collections) {
  95. const collConfig = getCollection(col.name);
  96. const rootCtx = collConfig?.context?.[""] || collConfig?.context?.["/"];
  97. const desc = rootCtx ? ` — ${rootCtx}` : "";
  98. lines.push(` - "${col.name}" (${col.documents} docs)${desc}`);
  99. }
  100. }
  101. // --- Capability gaps ---
  102. if (!status.hasVectorIndex) {
  103. lines.push("");
  104. lines.push("Note: No vector embeddings yet. Run `qmd embed` to enable semantic search (vec/hyde).");
  105. } else if (status.needsEmbedding > 0) {
  106. lines.push("");
  107. lines.push(`Note: ${status.needsEmbedding} documents need embedding. Run \`qmd embed\` to update.`);
  108. }
  109. // --- Search tool ---
  110. lines.push("");
  111. lines.push("Search: Use `query` with sub-queries (lex/vec/hyde):");
  112. lines.push(" - type:'lex' — BM25 keyword search (exact terms, fast)");
  113. lines.push(" - type:'vec' — semantic vector search (meaning-based)");
  114. lines.push(" - type:'hyde' — hypothetical document (write what the answer looks like)");
  115. lines.push("");
  116. lines.push("Examples:");
  117. lines.push(" Quick keyword lookup: [{type:'lex', query:'error handling'}]");
  118. lines.push(" Semantic search: [{type:'vec', query:'how to handle errors gracefully'}]");
  119. lines.push(" Best results: [{type:'lex', query:'error'}, {type:'vec', query:'error handling best practices'}]");
  120. // --- Retrieval workflow ---
  121. lines.push("");
  122. lines.push("Retrieval:");
  123. lines.push(" - `get` — single document by path or docid (#abc123). Supports line offset (`file.md:100`).");
  124. lines.push(" - `multi_get` — batch retrieve by glob (`journals/2025-05*.md`) or comma-separated list.");
  125. // --- Non-obvious things that prevent mistakes ---
  126. lines.push("");
  127. lines.push("Tips:");
  128. lines.push(" - File paths in results are relative to their collection.");
  129. lines.push(" - Use `minScore: 0.5` to filter low-confidence results.");
  130. lines.push(" - Results include a `context` field describing the content type.");
  131. return lines.join("\n");
  132. }
  133. /**
  134. * Create an MCP server with all QMD tools, resources, and prompts registered.
  135. * Shared by both stdio and HTTP transports.
  136. */
  137. function createMcpServer(store: Store): McpServer {
  138. const server = new McpServer(
  139. { name: "qmd", version: "0.9.9" },
  140. { instructions: buildInstructions(store) },
  141. );
  142. // ---------------------------------------------------------------------------
  143. // Resource: qmd://{path} - read-only access to documents by path
  144. // Note: No list() - documents are discovered via search tools
  145. // ---------------------------------------------------------------------------
  146. server.registerResource(
  147. "document",
  148. new ResourceTemplate("qmd://{+path}", { list: undefined }),
  149. {
  150. title: "QMD Document",
  151. description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.",
  152. mimeType: "text/markdown",
  153. },
  154. async (uri, { path }) => {
  155. // Decode URL-encoded path (MCP clients send encoded URIs)
  156. const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
  157. const decodedPath = decodeURIComponent(pathStr);
  158. // Parse virtual path: collection/relative/path
  159. const parts = decodedPath.split('/');
  160. const collection = parts[0] || '';
  161. const relativePath = parts.slice(1).join('/');
  162. // Find document by collection and path, join with content table
  163. let doc = store.db.prepare(`
  164. SELECT d.collection, d.path, d.title, c.doc as body
  165. FROM documents d
  166. JOIN content c ON c.hash = d.hash
  167. WHERE d.collection = ? AND d.path = ? AND d.active = 1
  168. `).get(collection, relativePath) as { collection: string; path: string; title: string; body: string } | null;
  169. // Try suffix match if exact match fails
  170. if (!doc) {
  171. doc = store.db.prepare(`
  172. SELECT d.collection, d.path, d.title, c.doc as body
  173. FROM documents d
  174. JOIN content c ON c.hash = d.hash
  175. WHERE d.path LIKE ? AND d.active = 1
  176. LIMIT 1
  177. `).get(`%${relativePath}`) as { collection: string; path: string; title: string; body: string } | null;
  178. }
  179. if (!doc) {
  180. return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
  181. }
  182. // Construct virtual path for context lookup
  183. const virtualPath = `qmd://${doc.collection}/${doc.path}`;
  184. const context = store.getContextForFile(virtualPath);
  185. let text = addLineNumbers(doc.body); // Default to line numbers
  186. if (context) {
  187. text = `<!-- Context: ${context} -->\n\n` + text;
  188. }
  189. const displayName = `${doc.collection}/${doc.path}`;
  190. return {
  191. contents: [{
  192. uri: uri.href,
  193. name: displayName,
  194. title: doc.title || doc.path,
  195. mimeType: "text/markdown",
  196. text,
  197. }],
  198. };
  199. }
  200. );
  201. // ---------------------------------------------------------------------------
  202. // Tool: query (Primary search tool)
  203. // ---------------------------------------------------------------------------
  204. const subSearchSchema = z.object({
  205. type: z.enum(['lex', 'vec', 'hyde']).describe(
  206. "lex = BM25 keywords (supports \"phrase\" and -negation); " +
  207. "vec = semantic question; hyde = hypothetical answer passage"
  208. ),
  209. query: z.string().describe(
  210. "The query text. For lex: use keywords, \"quoted phrases\", and -negation. " +
  211. "For vec: natural language question. For hyde: 50-100 word answer passage."
  212. ),
  213. });
  214. server.registerTool(
  215. "query",
  216. {
  217. title: "Query",
  218. description: `Search the knowledge base using a query document — one or more typed sub-queries combined for best recall.
  219. ## Query Types
  220. **lex** — BM25 keyword search. Fast, exact, no LLM needed.
  221. Full lex syntax:
  222. - \`term\` — prefix match ("perf" matches "performance")
  223. - \`"exact phrase"\` — phrase must appear verbatim
  224. - \`-term\` or \`-"phrase"\` — exclude documents containing this
  225. Good lex examples:
  226. - \`"connection pool" timeout -redis\`
  227. - \`"machine learning" -sports -athlete\`
  228. - \`handleError async typescript\`
  229. **vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words.
  230. - \`how does the rate limiter handle burst traffic?\`
  231. - \`what is the tradeoff between consistency and availability?\`
  232. **hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics.
  233. - \`The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.\`
  234. ## Strategy
  235. Combine types for best results. First sub-query gets 2× weight — put your strongest signal first.
  236. | Goal | Approach |
  237. |------|----------|
  238. | Know exact term/name | \`lex\` only |
  239. | Concept search | \`vec\` only |
  240. | Best recall | \`lex\` + \`vec\` |
  241. | Complex/nuanced | \`lex\` + \`vec\` + \`hyde\` |
  242. | Unknown vocabulary | Use a standalone natural-language query (no typed lines) so the server can auto-expand it |
  243. ## Examples
  244. Simple lookup:
  245. \`\`\`json
  246. [{ "type": "lex", "query": "CAP theorem" }]
  247. \`\`\`
  248. Best recall on a technical topic:
  249. \`\`\`json
  250. [
  251. { "type": "lex", "query": "\\"connection pool\\" timeout -redis" },
  252. { "type": "vec", "query": "why do database connections time out under load" },
  253. { "type": "hyde", "query": "Connection pool exhaustion occurs when all connections are in use and new requests must wait. This typically happens under high concurrency when queries run longer than expected." }
  254. ]
  255. \`\`\`
  256. Intent-aware lex (C++ performance, not sports):
  257. \`\`\`json
  258. [
  259. { "type": "lex", "query": "\\"C++ performance\\" optimization -sports -athlete" },
  260. { "type": "vec", "query": "how to optimize C++ program performance" }
  261. ]
  262. \`\`\``,
  263. annotations: { readOnlyHint: true, openWorldHint: false },
  264. inputSchema: {
  265. searches: z.array(subSearchSchema).min(1).max(10).describe(
  266. "Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight."
  267. ),
  268. limit: z.number().optional().default(10).describe("Max results (default: 10)"),
  269. minScore: z.number().optional().default(0).describe("Min relevance 0-1 (default: 0)"),
  270. collections: z.array(z.string()).optional().describe("Filter to collections (OR match)"),
  271. },
  272. },
  273. async ({ searches, limit, minScore, collections }) => {
  274. // Map to internal format
  275. const subSearches: StructuredSubSearch[] = searches.map(s => ({
  276. type: s.type,
  277. query: s.query,
  278. }));
  279. // Use default collections if none specified
  280. const effectiveCollections = collections ?? getDefaultCollectionNames();
  281. const results = await structuredSearch(store, subSearches, {
  282. collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
  283. limit,
  284. minScore,
  285. });
  286. // Use first lex or vec query for snippet extraction
  287. const primaryQuery = searches.find(s => s.type === 'lex')?.query
  288. || searches.find(s => s.type === 'vec')?.query
  289. || searches[0]?.query || "";
  290. const filtered: SearchResultItem[] = results.map(r => {
  291. const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300);
  292. return {
  293. docid: `#${r.docid}`,
  294. file: r.displayPath,
  295. title: r.title,
  296. score: Math.round(r.score * 100) / 100,
  297. context: r.context,
  298. snippet: addLineNumbers(snippet, line),
  299. };
  300. });
  301. return {
  302. content: [{ type: "text", text: formatSearchSummary(filtered, primaryQuery) }],
  303. structuredContent: { results: filtered },
  304. };
  305. }
  306. );
  307. // ---------------------------------------------------------------------------
  308. // Tool: qmd_get (Retrieve document)
  309. // ---------------------------------------------------------------------------
  310. server.registerTool(
  311. "get",
  312. {
  313. title: "Get Document",
  314. description: "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.",
  315. annotations: { readOnlyHint: true, openWorldHint: false },
  316. inputSchema: {
  317. file: z.string().describe("File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"),
  318. fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
  319. maxLines: z.number().optional().describe("Maximum number of lines to return"),
  320. lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
  321. },
  322. },
  323. async ({ file, fromLine, maxLines, lineNumbers }) => {
  324. // Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided
  325. let parsedFromLine = fromLine;
  326. let lookup = file;
  327. const colonMatch = lookup.match(/:(\d+)$/);
  328. if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
  329. parsedFromLine = parseInt(colonMatch[1], 10);
  330. lookup = lookup.slice(0, -colonMatch[0].length);
  331. }
  332. const result = store.findDocument(lookup, { includeBody: false });
  333. if ("error" in result) {
  334. let msg = `Document not found: ${file}`;
  335. if (result.similarFiles.length > 0) {
  336. msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`;
  337. }
  338. return {
  339. content: [{ type: "text", text: msg }],
  340. isError: true,
  341. };
  342. }
  343. const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? "";
  344. let text = body;
  345. if (lineNumbers) {
  346. const startLine = parsedFromLine || 1;
  347. text = addLineNumbers(text, startLine);
  348. }
  349. if (result.context) {
  350. text = `<!-- Context: ${result.context} -->\n\n` + text;
  351. }
  352. return {
  353. content: [{
  354. type: "resource",
  355. resource: {
  356. uri: `qmd://${encodeQmdPath(result.displayPath)}`,
  357. name: result.displayPath,
  358. title: result.title,
  359. mimeType: "text/markdown",
  360. text,
  361. },
  362. }],
  363. };
  364. }
  365. );
  366. // ---------------------------------------------------------------------------
  367. // Tool: qmd_multi_get (Retrieve multiple documents)
  368. // ---------------------------------------------------------------------------
  369. server.registerTool(
  370. "multi_get",
  371. {
  372. title: "Multi-Get Documents",
  373. description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
  374. annotations: { readOnlyHint: true, openWorldHint: false },
  375. inputSchema: {
  376. pattern: z.string().describe("Glob pattern or comma-separated list of file paths"),
  377. maxLines: z.number().optional().describe("Maximum lines per file"),
  378. maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"),
  379. lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
  380. },
  381. },
  382. async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
  383. const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
  384. if (docs.length === 0 && errors.length === 0) {
  385. return {
  386. content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
  387. isError: true,
  388. };
  389. }
  390. const content: ({ type: "text"; text: string } | { type: "resource"; resource: { uri: string; name: string; title?: string; mimeType: string; text: string } })[] = [];
  391. if (errors.length > 0) {
  392. content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
  393. }
  394. for (const result of docs) {
  395. if (result.skipped) {
  396. content.push({
  397. type: "text",
  398. text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`,
  399. });
  400. continue;
  401. }
  402. let text = result.doc.body || "";
  403. if (maxLines !== undefined) {
  404. const lines = text.split("\n");
  405. text = lines.slice(0, maxLines).join("\n");
  406. if (lines.length > maxLines) {
  407. text += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
  408. }
  409. }
  410. if (lineNumbers) {
  411. text = addLineNumbers(text);
  412. }
  413. if (result.doc.context) {
  414. text = `<!-- Context: ${result.doc.context} -->\n\n` + text;
  415. }
  416. content.push({
  417. type: "resource",
  418. resource: {
  419. uri: `qmd://${encodeQmdPath(result.doc.displayPath)}`,
  420. name: result.doc.displayPath,
  421. title: result.doc.title,
  422. mimeType: "text/markdown",
  423. text,
  424. },
  425. });
  426. }
  427. return { content };
  428. }
  429. );
  430. // ---------------------------------------------------------------------------
  431. // Tool: qmd_status (Index status)
  432. // ---------------------------------------------------------------------------
  433. server.registerTool(
  434. "status",
  435. {
  436. title: "Index Status",
  437. description: "Show the status of the QMD index: collections, document counts, and health information.",
  438. annotations: { readOnlyHint: true, openWorldHint: false },
  439. inputSchema: {},
  440. },
  441. async () => {
  442. const status: StatusResult = store.getStatus();
  443. const summary = [
  444. `QMD Index Status:`,
  445. ` Total documents: ${status.totalDocuments}`,
  446. ` Needs embedding: ${status.needsEmbedding}`,
  447. ` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
  448. ` Collections: ${status.collections.length}`,
  449. ];
  450. for (const col of status.collections) {
  451. summary.push(` - ${col.path} (${col.documents} docs)`);
  452. }
  453. return {
  454. content: [{ type: "text", text: summary.join('\n') }],
  455. structuredContent: status,
  456. };
  457. }
  458. );
  459. return server;
  460. }
  461. // =============================================================================
  462. // Transport: stdio (default)
  463. // =============================================================================
  464. export async function startMcpServer(): Promise<void> {
  465. const store = createStore();
  466. const server = createMcpServer(store);
  467. const transport = new StdioServerTransport();
  468. await server.connect(transport);
  469. }
  470. // =============================================================================
  471. // Transport: Streamable HTTP
  472. // =============================================================================
  473. export type HttpServerHandle = {
  474. httpServer: import("http").Server;
  475. port: number;
  476. stop: () => Promise<void>;
  477. };
  478. /**
  479. * Start MCP server over Streamable HTTP (JSON responses, no SSE).
  480. * Binds to localhost only. Returns a handle for shutdown and port discovery.
  481. */
  482. export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise<HttpServerHandle> {
  483. const store = createStore();
  484. // Session map: each client gets its own McpServer + Transport pair (MCP spec requirement).
  485. // The store is shared — it's stateless SQLite, safe for concurrent access.
  486. const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
  487. async function createSession(): Promise<WebStandardStreamableHTTPServerTransport> {
  488. const transport = new WebStandardStreamableHTTPServerTransport({
  489. sessionIdGenerator: () => randomUUID(),
  490. enableJsonResponse: true,
  491. onsessioninitialized: (sessionId: string) => {
  492. sessions.set(sessionId, transport);
  493. log(`${ts()} New session ${sessionId} (${sessions.size} active)`);
  494. },
  495. });
  496. const server = createMcpServer(store);
  497. await server.connect(transport);
  498. transport.onclose = () => {
  499. if (transport.sessionId) {
  500. sessions.delete(transport.sessionId);
  501. }
  502. };
  503. return transport;
  504. }
  505. const startTime = Date.now();
  506. const quiet = options?.quiet ?? false;
  507. /** Format timestamp for request logging */
  508. function ts(): string {
  509. return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS
  510. }
  511. /** Extract a human-readable label from a JSON-RPC body */
  512. function describeRequest(body: any): string {
  513. const method = body?.method ?? "unknown";
  514. if (method === "tools/call") {
  515. const tool = body.params?.name ?? "?";
  516. const args = body.params?.arguments;
  517. // Show query string if present, truncated
  518. if (args?.query) {
  519. const q = String(args.query).slice(0, 80);
  520. return `tools/call ${tool} "${q}"`;
  521. }
  522. if (args?.path) return `tools/call ${tool} ${args.path}`;
  523. if (args?.pattern) return `tools/call ${tool} ${args.pattern}`;
  524. return `tools/call ${tool}`;
  525. }
  526. return method;
  527. }
  528. function log(msg: string): void {
  529. if (!quiet) console.error(msg);
  530. }
  531. // Helper to collect request body
  532. async function collectBody(req: IncomingMessage): Promise<string> {
  533. const chunks: Buffer[] = [];
  534. for await (const chunk of req) chunks.push(chunk as Buffer);
  535. return Buffer.concat(chunks).toString();
  536. }
  537. const httpServer = createServer(async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
  538. const reqStart = Date.now();
  539. const pathname = nodeReq.url || "/";
  540. try {
  541. if (pathname === "/health" && nodeReq.method === "GET") {
  542. const body = JSON.stringify({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000) });
  543. nodeRes.writeHead(200, { "Content-Type": "application/json" });
  544. nodeRes.end(body);
  545. log(`${ts()} GET /health (${Date.now() - reqStart}ms)`);
  546. return;
  547. }
  548. // REST endpoint: POST /search — structured search without MCP protocol
  549. // REST endpoint: POST /query (alias: /search) — structured search without MCP protocol
  550. if ((pathname === "/query" || pathname === "/search") && nodeReq.method === "POST") {
  551. const rawBody = await collectBody(nodeReq);
  552. const params = JSON.parse(rawBody);
  553. // Validate required fields
  554. if (!params.searches || !Array.isArray(params.searches)) {
  555. nodeRes.writeHead(400, { "Content-Type": "application/json" });
  556. nodeRes.end(JSON.stringify({ error: "Missing required field: searches (array)" }));
  557. return;
  558. }
  559. // Map to internal format
  560. const subSearches: StructuredSubSearch[] = params.searches.map((s: any) => ({
  561. type: s.type as 'lex' | 'vec' | 'hyde',
  562. query: String(s.query || ""),
  563. }));
  564. // Use default collections if none specified
  565. const effectiveCollections = params.collections ?? getDefaultCollectionNames();
  566. const results = await structuredSearch(store, subSearches, {
  567. collections: effectiveCollections.length > 0 ? effectiveCollections : undefined,
  568. limit: params.limit ?? 10,
  569. minScore: params.minScore ?? 0,
  570. });
  571. // Use first lex or vec query for snippet extraction
  572. const primaryQuery = params.searches.find((s: any) => s.type === 'lex')?.query
  573. || params.searches.find((s: any) => s.type === 'vec')?.query
  574. || params.searches[0]?.query || "";
  575. const formatted = results.map(r => {
  576. const { line, snippet } = extractSnippet(r.bestChunk, primaryQuery, 300);
  577. return {
  578. docid: `#${r.docid}`,
  579. file: r.displayPath,
  580. title: r.title,
  581. score: Math.round(r.score * 100) / 100,
  582. context: r.context,
  583. snippet: addLineNumbers(snippet, line),
  584. };
  585. });
  586. nodeRes.writeHead(200, { "Content-Type": "application/json" });
  587. nodeRes.end(JSON.stringify({ results: formatted }));
  588. log(`${ts()} POST /query ${params.searches.length} queries (${Date.now() - reqStart}ms)`);
  589. return;
  590. }
  591. if (pathname === "/mcp" && nodeReq.method === "POST") {
  592. const rawBody = await collectBody(nodeReq);
  593. const body = JSON.parse(rawBody);
  594. const label = describeRequest(body);
  595. const url = `http://localhost:${port}${pathname}`;
  596. const headers: Record<string, string> = {};
  597. for (const [k, v] of Object.entries(nodeReq.headers)) {
  598. if (typeof v === "string") headers[k] = v;
  599. }
  600. // Route to existing session or create new one on initialize
  601. const sessionId = headers["mcp-session-id"];
  602. let transport: WebStandardStreamableHTTPServerTransport;
  603. if (sessionId) {
  604. const existing = sessions.get(sessionId);
  605. if (!existing) {
  606. nodeRes.writeHead(404, { "Content-Type": "application/json" });
  607. nodeRes.end(JSON.stringify({
  608. jsonrpc: "2.0",
  609. error: { code: -32001, message: "Session not found" },
  610. id: body?.id ?? null,
  611. }));
  612. return;
  613. }
  614. transport = existing;
  615. } else if (isInitializeRequest(body)) {
  616. transport = await createSession();
  617. } else {
  618. nodeRes.writeHead(400, { "Content-Type": "application/json" });
  619. nodeRes.end(JSON.stringify({
  620. jsonrpc: "2.0",
  621. error: { code: -32000, message: "Bad Request: Missing session ID" },
  622. id: body?.id ?? null,
  623. }));
  624. return;
  625. }
  626. const request = new Request(url, { method: "POST", headers, body: rawBody });
  627. const response = await transport.handleRequest(request, { parsedBody: body });
  628. nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
  629. nodeRes.end(Buffer.from(await response.arrayBuffer()));
  630. log(`${ts()} POST /mcp ${label} (${Date.now() - reqStart}ms)`);
  631. return;
  632. }
  633. if (pathname === "/mcp") {
  634. const headers: Record<string, string> = {};
  635. for (const [k, v] of Object.entries(nodeReq.headers)) {
  636. if (typeof v === "string") headers[k] = v;
  637. }
  638. // GET/DELETE must have a valid session
  639. const sessionId = headers["mcp-session-id"];
  640. if (!sessionId) {
  641. nodeRes.writeHead(400, { "Content-Type": "application/json" });
  642. nodeRes.end(JSON.stringify({
  643. jsonrpc: "2.0",
  644. error: { code: -32000, message: "Bad Request: Missing session ID" },
  645. id: null,
  646. }));
  647. return;
  648. }
  649. const transport = sessions.get(sessionId);
  650. if (!transport) {
  651. nodeRes.writeHead(404, { "Content-Type": "application/json" });
  652. nodeRes.end(JSON.stringify({
  653. jsonrpc: "2.0",
  654. error: { code: -32001, message: "Session not found" },
  655. id: null,
  656. }));
  657. return;
  658. }
  659. const url = `http://localhost:${port}${pathname}`;
  660. const rawBody = nodeReq.method !== "GET" && nodeReq.method !== "HEAD" ? await collectBody(nodeReq) : undefined;
  661. const request = new Request(url, { method: nodeReq.method || "GET", headers, ...(rawBody ? { body: rawBody } : {}) });
  662. const response = await transport.handleRequest(request);
  663. nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
  664. nodeRes.end(Buffer.from(await response.arrayBuffer()));
  665. return;
  666. }
  667. nodeRes.writeHead(404);
  668. nodeRes.end("Not Found");
  669. } catch (err) {
  670. console.error("HTTP handler error:", err);
  671. nodeRes.writeHead(500);
  672. nodeRes.end("Internal Server Error");
  673. }
  674. });
  675. await new Promise<void>((resolve, reject) => {
  676. httpServer.on("error", reject);
  677. httpServer.listen(port, "localhost", () => resolve());
  678. });
  679. const actualPort = (httpServer.address() as import("net").AddressInfo).port;
  680. let stopping = false;
  681. const stop = async () => {
  682. if (stopping) return;
  683. stopping = true;
  684. for (const transport of sessions.values()) {
  685. await transport.close();
  686. }
  687. sessions.clear();
  688. httpServer.close();
  689. store.close();
  690. await disposeDefaultLlamaCpp();
  691. };
  692. process.on("SIGTERM", async () => {
  693. console.error("Shutting down (SIGTERM)...");
  694. await stop();
  695. process.exit(0);
  696. });
  697. process.on("SIGINT", async () => {
  698. console.error("Shutting down (SIGINT)...");
  699. await stop();
  700. process.exit(0);
  701. });
  702. log(`QMD MCP server listening on http://localhost:${actualPort}/mcp`);
  703. return { httpServer, port: actualPort, stop };
  704. }
  705. // Run if this is the main module
  706. if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/mcp.ts") || process.argv[1]?.endsWith("/mcp.js")) {
  707. startMcpServer().catch(console.error);
  708. }