mcp.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  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 { z } from "zod";
  17. import {
  18. createStore,
  19. extractSnippet,
  20. addLineNumbers,
  21. hybridQuery,
  22. vectorSearchQuery,
  23. DEFAULT_MULTI_GET_MAX_BYTES,
  24. } from "./store.js";
  25. import type { Store } from "./store.js";
  26. import { getCollection, getGlobalContext } 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. Only `search` (BM25) is available.");
  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. // --- When to use which tool (escalation ladder) ---
  110. // Tool schemas describe parameters; instructions describe strategy.
  111. lines.push("");
  112. lines.push("Search:");
  113. lines.push(" - `search` (~30ms) — keyword and exact phrase matching.");
  114. lines.push(" - `vector_search` (~2s) — meaning-based, finds adjacent concepts even when vocabulary differs.");
  115. lines.push(" - `deep_search` (~10s) — auto-expands the query into variations, searches each by keyword and meaning, reranks for top hits.");
  116. // --- Retrieval workflow ---
  117. lines.push("");
  118. lines.push("Retrieval:");
  119. lines.push(" - `get` — single document by path or docid (#abc123). Supports line offset (`file.md:100`).");
  120. lines.push(" - `multi_get` — batch retrieve by glob (`journals/2025-05*.md`) or comma-separated list.");
  121. // --- Non-obvious things that prevent mistakes ---
  122. lines.push("");
  123. lines.push("Tips:");
  124. lines.push(" - File paths in results are relative to their collection.");
  125. lines.push(" - Use `minScore: 0.5` to filter low-confidence results.");
  126. lines.push(" - Results include a `context` field describing the content type.");
  127. return lines.join("\n");
  128. }
  129. /**
  130. * Create an MCP server with all QMD tools, resources, and prompts registered.
  131. * Shared by both stdio and HTTP transports.
  132. */
  133. function createMcpServer(store: Store): McpServer {
  134. const server = new McpServer(
  135. { name: "qmd", version: "0.9.9" },
  136. { instructions: buildInstructions(store) },
  137. );
  138. // ---------------------------------------------------------------------------
  139. // Resource: qmd://{path} - read-only access to documents by path
  140. // Note: No list() - documents are discovered via search tools
  141. // ---------------------------------------------------------------------------
  142. server.registerResource(
  143. "document",
  144. new ResourceTemplate("qmd://{+path}", { list: undefined }),
  145. {
  146. title: "QMD Document",
  147. description: "A markdown document from your QMD knowledge base. Use search tools to discover documents.",
  148. mimeType: "text/markdown",
  149. },
  150. async (uri, { path }) => {
  151. // Decode URL-encoded path (MCP clients send encoded URIs)
  152. const pathStr = Array.isArray(path) ? path.join('/') : (path || '');
  153. const decodedPath = decodeURIComponent(pathStr);
  154. // Parse virtual path: collection/relative/path
  155. const parts = decodedPath.split('/');
  156. const collection = parts[0] || '';
  157. const relativePath = parts.slice(1).join('/');
  158. // Find document by collection and path, join with content table
  159. let doc = store.db.prepare(`
  160. SELECT d.collection, d.path, d.title, c.doc as body
  161. FROM documents d
  162. JOIN content c ON c.hash = d.hash
  163. WHERE d.collection = ? AND d.path = ? AND d.active = 1
  164. `).get(collection, relativePath) as { collection: string; path: string; title: string; body: string } | null;
  165. // Try suffix match if exact match fails
  166. if (!doc) {
  167. doc = store.db.prepare(`
  168. SELECT d.collection, d.path, d.title, c.doc as body
  169. FROM documents d
  170. JOIN content c ON c.hash = d.hash
  171. WHERE d.path LIKE ? AND d.active = 1
  172. LIMIT 1
  173. `).get(`%${relativePath}`) as { collection: string; path: string; title: string; body: string } | null;
  174. }
  175. if (!doc) {
  176. return { contents: [{ uri: uri.href, text: `Document not found: ${decodedPath}` }] };
  177. }
  178. // Construct virtual path for context lookup
  179. const virtualPath = `qmd://${doc.collection}/${doc.path}`;
  180. const context = store.getContextForFile(virtualPath);
  181. let text = addLineNumbers(doc.body); // Default to line numbers
  182. if (context) {
  183. text = `<!-- Context: ${context} -->\n\n` + text;
  184. }
  185. const displayName = `${doc.collection}/${doc.path}`;
  186. return {
  187. contents: [{
  188. uri: uri.href,
  189. name: displayName,
  190. title: doc.title || doc.path,
  191. mimeType: "text/markdown",
  192. text,
  193. }],
  194. };
  195. }
  196. );
  197. // ---------------------------------------------------------------------------
  198. // Tool: qmd_search (keyword)
  199. // ---------------------------------------------------------------------------
  200. server.registerTool(
  201. "search",
  202. {
  203. title: "Keyword Search",
  204. description: "Search by keyword. Finds documents containing exact words and phrases in the query.",
  205. annotations: { readOnlyHint: true, openWorldHint: false },
  206. inputSchema: {
  207. query: z.string().describe("Search query - keywords or phrases to find"),
  208. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  209. minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
  210. collection: z.string().optional().describe("Filter to a specific collection by name"),
  211. },
  212. },
  213. async ({ query, limit, minScore, collection }) => {
  214. const results = store.searchFTS(query, limit || 10, collection);
  215. const filtered: SearchResultItem[] = results
  216. .filter(r => r.score >= (minScore || 0))
  217. .map(r => {
  218. const { line, snippet } = extractSnippet(r.body || "", query, 300, r.chunkPos);
  219. return {
  220. docid: `#${r.docid}`,
  221. file: r.displayPath,
  222. title: r.title,
  223. score: Math.round(r.score * 100) / 100,
  224. context: store.getContextForFile(r.filepath),
  225. snippet: addLineNumbers(snippet, line), // Default to line numbers
  226. };
  227. });
  228. return {
  229. content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
  230. structuredContent: { results: filtered },
  231. };
  232. }
  233. );
  234. // ---------------------------------------------------------------------------
  235. // Tool: qmd_vector_search (Vector semantic search)
  236. // ---------------------------------------------------------------------------
  237. server.registerTool(
  238. "vector_search",
  239. {
  240. title: "Vector Search",
  241. description: "Search by meaning. Finds relevant documents even when they use different words than the query — handles synonyms, paraphrases, and related concepts.",
  242. annotations: { readOnlyHint: true, openWorldHint: false },
  243. inputSchema: {
  244. query: z.string().describe("Natural language query - describe what you're looking for"),
  245. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  246. minScore: z.number().optional().default(0.3).describe("Minimum relevance score 0-1 (default: 0.3)"),
  247. collection: z.string().optional().describe("Filter to a specific collection by name"),
  248. },
  249. },
  250. async ({ query, limit, minScore, collection }) => {
  251. const results = await vectorSearchQuery(store, query, { collection, limit, minScore });
  252. if (results.length === 0) {
  253. // Distinguish "no embeddings" from "no matches" — check if vector table exists
  254. const tableExists = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  255. if (!tableExists) {
  256. return {
  257. content: [{ type: "text", text: "Vector index not found. Run 'qmd embed' first to create embeddings." }],
  258. isError: true,
  259. };
  260. }
  261. }
  262. const filtered: SearchResultItem[] = results.map(r => {
  263. const { line, snippet } = extractSnippet(r.body, query, 300);
  264. return {
  265. docid: `#${r.docid}`,
  266. file: r.displayPath,
  267. title: r.title,
  268. score: Math.round(r.score * 100) / 100,
  269. context: r.context,
  270. snippet: addLineNumbers(snippet, line),
  271. };
  272. });
  273. return {
  274. content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
  275. structuredContent: { results: filtered },
  276. };
  277. }
  278. );
  279. // ---------------------------------------------------------------------------
  280. // Tool: qmd_deep_search (Deep search with expansion + reranking)
  281. // ---------------------------------------------------------------------------
  282. server.registerTool(
  283. "deep_search",
  284. {
  285. title: "Deep Search",
  286. description: "Deep search. Auto-expands the query into variations, searches each by keyword and meaning, and reranks for top hits across all results.",
  287. annotations: { readOnlyHint: true, openWorldHint: false },
  288. inputSchema: {
  289. query: z.string().describe("Natural language query - describe what you're looking for"),
  290. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  291. minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
  292. collection: z.string().optional().describe("Filter to a specific collection by name"),
  293. },
  294. },
  295. async ({ query, limit, minScore, collection }) => {
  296. const results = await hybridQuery(store, query, { collection, limit, minScore });
  297. const filtered: SearchResultItem[] = results.map(r => {
  298. const { line, snippet } = extractSnippet(r.bestChunk, query, 300);
  299. return {
  300. docid: `#${r.docid}`,
  301. file: r.displayPath,
  302. title: r.title,
  303. score: Math.round(r.score * 100) / 100,
  304. context: r.context,
  305. snippet: addLineNumbers(snippet, line),
  306. };
  307. });
  308. return {
  309. content: [{ type: "text", text: formatSearchSummary(filtered, query) }],
  310. structuredContent: { results: filtered },
  311. };
  312. }
  313. );
  314. // ---------------------------------------------------------------------------
  315. // Tool: qmd_get (Retrieve document)
  316. // ---------------------------------------------------------------------------
  317. server.registerTool(
  318. "get",
  319. {
  320. title: "Get Document",
  321. 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.",
  322. annotations: { readOnlyHint: true, openWorldHint: false },
  323. inputSchema: {
  324. 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)"),
  325. fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
  326. maxLines: z.number().optional().describe("Maximum number of lines to return"),
  327. lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
  328. },
  329. },
  330. async ({ file, fromLine, maxLines, lineNumbers }) => {
  331. // Support :line suffix in `file` (e.g. "foo.md:120") when fromLine isn't provided
  332. let parsedFromLine = fromLine;
  333. let lookup = file;
  334. const colonMatch = lookup.match(/:(\d+)$/);
  335. if (colonMatch && colonMatch[1] && parsedFromLine === undefined) {
  336. parsedFromLine = parseInt(colonMatch[1], 10);
  337. lookup = lookup.slice(0, -colonMatch[0].length);
  338. }
  339. const result = store.findDocument(lookup, { includeBody: false });
  340. if ("error" in result) {
  341. let msg = `Document not found: ${file}`;
  342. if (result.similarFiles.length > 0) {
  343. msg += `\n\nDid you mean one of these?\n${result.similarFiles.map(s => ` - ${s}`).join('\n')}`;
  344. }
  345. return {
  346. content: [{ type: "text", text: msg }],
  347. isError: true,
  348. };
  349. }
  350. const body = store.getDocumentBody(result, parsedFromLine, maxLines) ?? "";
  351. let text = body;
  352. if (lineNumbers) {
  353. const startLine = parsedFromLine || 1;
  354. text = addLineNumbers(text, startLine);
  355. }
  356. if (result.context) {
  357. text = `<!-- Context: ${result.context} -->\n\n` + text;
  358. }
  359. return {
  360. content: [{
  361. type: "resource",
  362. resource: {
  363. uri: `qmd://${encodeQmdPath(result.displayPath)}`,
  364. name: result.displayPath,
  365. title: result.title,
  366. mimeType: "text/markdown",
  367. text,
  368. },
  369. }],
  370. };
  371. }
  372. );
  373. // ---------------------------------------------------------------------------
  374. // Tool: qmd_multi_get (Retrieve multiple documents)
  375. // ---------------------------------------------------------------------------
  376. server.registerTool(
  377. "multi_get",
  378. {
  379. title: "Multi-Get Documents",
  380. description: "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
  381. annotations: { readOnlyHint: true, openWorldHint: false },
  382. inputSchema: {
  383. pattern: z.string().describe("Glob pattern or comma-separated list of file paths"),
  384. maxLines: z.number().optional().describe("Maximum lines per file"),
  385. maxBytes: z.number().optional().default(10240).describe("Skip files larger than this (default: 10240 = 10KB)"),
  386. lineNumbers: z.boolean().optional().default(false).describe("Add line numbers to output (format: 'N: content')"),
  387. },
  388. },
  389. async ({ pattern, maxLines, maxBytes, lineNumbers }) => {
  390. const { docs, errors } = store.findDocuments(pattern, { includeBody: true, maxBytes: maxBytes || DEFAULT_MULTI_GET_MAX_BYTES });
  391. if (docs.length === 0 && errors.length === 0) {
  392. return {
  393. content: [{ type: "text", text: `No files matched pattern: ${pattern}` }],
  394. isError: true,
  395. };
  396. }
  397. const content: ({ type: "text"; text: string } | { type: "resource"; resource: { uri: string; name: string; title?: string; mimeType: string; text: string } })[] = [];
  398. if (errors.length > 0) {
  399. content.push({ type: "text", text: `Errors:\n${errors.join('\n')}` });
  400. }
  401. for (const result of docs) {
  402. if (result.skipped) {
  403. content.push({
  404. type: "text",
  405. text: `[SKIPPED: ${result.doc.displayPath} - ${result.skipReason}. Use 'qmd_get' with file="${result.doc.displayPath}" to retrieve.]`,
  406. });
  407. continue;
  408. }
  409. let text = result.doc.body || "";
  410. if (maxLines !== undefined) {
  411. const lines = text.split("\n");
  412. text = lines.slice(0, maxLines).join("\n");
  413. if (lines.length > maxLines) {
  414. text += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
  415. }
  416. }
  417. if (lineNumbers) {
  418. text = addLineNumbers(text);
  419. }
  420. if (result.doc.context) {
  421. text = `<!-- Context: ${result.doc.context} -->\n\n` + text;
  422. }
  423. content.push({
  424. type: "resource",
  425. resource: {
  426. uri: `qmd://${encodeQmdPath(result.doc.displayPath)}`,
  427. name: result.doc.displayPath,
  428. title: result.doc.title,
  429. mimeType: "text/markdown",
  430. text,
  431. },
  432. });
  433. }
  434. return { content };
  435. }
  436. );
  437. // ---------------------------------------------------------------------------
  438. // Tool: qmd_status (Index status)
  439. // ---------------------------------------------------------------------------
  440. server.registerTool(
  441. "status",
  442. {
  443. title: "Index Status",
  444. description: "Show the status of the QMD index: collections, document counts, and health information.",
  445. annotations: { readOnlyHint: true, openWorldHint: false },
  446. inputSchema: {},
  447. },
  448. async () => {
  449. const status: StatusResult = store.getStatus();
  450. const summary = [
  451. `QMD Index Status:`,
  452. ` Total documents: ${status.totalDocuments}`,
  453. ` Needs embedding: ${status.needsEmbedding}`,
  454. ` Vector index: ${status.hasVectorIndex ? 'yes' : 'no'}`,
  455. ` Collections: ${status.collections.length}`,
  456. ];
  457. for (const col of status.collections) {
  458. summary.push(` - ${col.path} (${col.documents} docs)`);
  459. }
  460. return {
  461. content: [{ type: "text", text: summary.join('\n') }],
  462. structuredContent: status,
  463. };
  464. }
  465. );
  466. return server;
  467. }
  468. // =============================================================================
  469. // Transport: stdio (default)
  470. // =============================================================================
  471. export async function startMcpServer(): Promise<void> {
  472. const store = createStore();
  473. const server = createMcpServer(store);
  474. const transport = new StdioServerTransport();
  475. await server.connect(transport);
  476. }
  477. // =============================================================================
  478. // Transport: Streamable HTTP
  479. // =============================================================================
  480. export type HttpServerHandle = {
  481. httpServer: import("http").Server;
  482. port: number;
  483. stop: () => Promise<void>;
  484. };
  485. /**
  486. * Start MCP server over Streamable HTTP (JSON responses, no SSE).
  487. * Binds to localhost only. Returns a handle for shutdown and port discovery.
  488. */
  489. export async function startMcpHttpServer(port: number, options?: { quiet?: boolean }): Promise<HttpServerHandle> {
  490. const store = createStore();
  491. const mcpServer = createMcpServer(store);
  492. const transport = new WebStandardStreamableHTTPServerTransport({
  493. sessionIdGenerator: () => randomUUID(),
  494. enableJsonResponse: true,
  495. });
  496. await mcpServer.connect(transport);
  497. const startTime = Date.now();
  498. const quiet = options?.quiet ?? false;
  499. /** Format timestamp for request logging */
  500. function ts(): string {
  501. return new Date().toISOString().slice(11, 23); // HH:mm:ss.SSS
  502. }
  503. /** Extract a human-readable label from a JSON-RPC body */
  504. function describeRequest(body: any): string {
  505. const method = body?.method ?? "unknown";
  506. if (method === "tools/call") {
  507. const tool = body.params?.name ?? "?";
  508. const args = body.params?.arguments;
  509. // Show query string if present, truncated
  510. if (args?.query) {
  511. const q = String(args.query).slice(0, 80);
  512. return `tools/call ${tool} "${q}"`;
  513. }
  514. if (args?.path) return `tools/call ${tool} ${args.path}`;
  515. if (args?.pattern) return `tools/call ${tool} ${args.pattern}`;
  516. return `tools/call ${tool}`;
  517. }
  518. return method;
  519. }
  520. function log(msg: string): void {
  521. if (!quiet) console.error(msg);
  522. }
  523. // Helper to collect request body
  524. async function collectBody(req: IncomingMessage): Promise<string> {
  525. const chunks: Buffer[] = [];
  526. for await (const chunk of req) chunks.push(chunk as Buffer);
  527. return Buffer.concat(chunks).toString();
  528. }
  529. const httpServer = createServer(async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
  530. const reqStart = Date.now();
  531. const pathname = nodeReq.url || "/";
  532. try {
  533. if (pathname === "/health" && nodeReq.method === "GET") {
  534. const body = JSON.stringify({ status: "ok", uptime: Math.floor((Date.now() - startTime) / 1000) });
  535. nodeRes.writeHead(200, { "Content-Type": "application/json" });
  536. nodeRes.end(body);
  537. log(`${ts()} GET /health (${Date.now() - reqStart}ms)`);
  538. return;
  539. }
  540. if (pathname === "/mcp" && nodeReq.method === "POST") {
  541. const rawBody = await collectBody(nodeReq);
  542. const body = JSON.parse(rawBody);
  543. const label = describeRequest(body);
  544. const url = `http://localhost:${port}${pathname}`;
  545. const headers: Record<string, string> = {};
  546. for (const [k, v] of Object.entries(nodeReq.headers)) {
  547. if (typeof v === "string") headers[k] = v;
  548. }
  549. const request = new Request(url, { method: "POST", headers, body: rawBody });
  550. const response = await transport.handleRequest(request, { parsedBody: body });
  551. nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
  552. nodeRes.end(Buffer.from(await response.arrayBuffer()));
  553. log(`${ts()} POST /mcp ${label} (${Date.now() - reqStart}ms)`);
  554. return;
  555. }
  556. if (pathname === "/mcp") {
  557. const url = `http://localhost:${port}${pathname}`;
  558. const headers: Record<string, string> = {};
  559. for (const [k, v] of Object.entries(nodeReq.headers)) {
  560. if (typeof v === "string") headers[k] = v;
  561. }
  562. const rawBody = nodeReq.method !== "GET" && nodeReq.method !== "HEAD" ? await collectBody(nodeReq) : undefined;
  563. const request = new Request(url, { method: nodeReq.method || "GET", headers, ...(rawBody ? { body: rawBody } : {}) });
  564. const response = await transport.handleRequest(request);
  565. nodeRes.writeHead(response.status, Object.fromEntries(response.headers));
  566. nodeRes.end(Buffer.from(await response.arrayBuffer()));
  567. return;
  568. }
  569. nodeRes.writeHead(404);
  570. nodeRes.end("Not Found");
  571. } catch (err) {
  572. console.error("HTTP handler error:", err);
  573. nodeRes.writeHead(500);
  574. nodeRes.end("Internal Server Error");
  575. }
  576. });
  577. await new Promise<void>((resolve, reject) => {
  578. httpServer.on("error", reject);
  579. httpServer.listen(port, "localhost", () => resolve());
  580. });
  581. const actualPort = (httpServer.address() as import("net").AddressInfo).port;
  582. let stopping = false;
  583. const stop = async () => {
  584. if (stopping) return;
  585. stopping = true;
  586. await transport.close();
  587. httpServer.close();
  588. store.close();
  589. await disposeDefaultLlamaCpp();
  590. };
  591. process.on("SIGTERM", async () => {
  592. console.error("Shutting down (SIGTERM)...");
  593. await stop();
  594. process.exit(0);
  595. });
  596. process.on("SIGINT", async () => {
  597. console.error("Shutting down (SIGINT)...");
  598. await stop();
  599. process.exit(0);
  600. });
  601. log(`QMD MCP server listening on http://localhost:${actualPort}/mcp`);
  602. return { httpServer, port: actualPort, stop };
  603. }
  604. // Run if this is the main module
  605. if (fileURLToPath(import.meta.url) === process.argv[1] || process.argv[1]?.endsWith("/mcp.ts") || process.argv[1]?.endsWith("/mcp.js")) {
  606. startMcpServer().catch(console.error);
  607. }