server.js 33 KB

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