server.ts 31 KB

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