server.js 34 KB

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