export declare const mcpServerTemplate = "import path from \"node:path\";\nimport fs from \"node:fs\";\nimport {\n McpServer,\n ResourceTemplate,\n} from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, createEmbeddings } from \"@/services/llm\";\nimport {\n reduceDims,\n quantizeInt8,\n decodeInt8,\n cosineFloatInt8,\n} from \"@/services/mcp/vector\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/** A doc chunk with its stored int8-quantized embedding. */\ntype IndexedChunk = DocsChunk & { embedding: Int8Array };\n\nexport const MCP_MAX_REQUEST_BYTES = 64 * 1024;\nexport const MCP_MAX_TOOL_ARGUMENT_BYTES = 8 * 1024;\nexport const MCP_MAX_RESULT_BYTES = 256 * 1024;\n\nexport const searchDocsArgsSchema = z\n .object({\n query: z.string().min(1).max(2000),\n limit: z.number().int().min(1).max(20).optional(),\n })\n .strict();\n\nexport const getDocArgsSchema = z\n .object({ path: z.string().min(1).max(500) })\n .strict();\n\nexport const listDocsArgsSchema = z\n .object({ directory: z.string().max(500).optional() })\n .strict();\n\nexport function serializeMCPResult(value: unknown): {\n text: string;\n tooLarge: boolean;\n} {\n const text = JSON.stringify(value, null, 2) ?? \"null\";\n if (new TextEncoder().encode(text).byteLength <= MCP_MAX_RESULT_BYTES) {\n return { text, tooLarge: false };\n }\n return {\n text: JSON.stringify({\n error: \"Tool result exceeds the maximum response size\",\n }),\n tooLarge: true,\n };\n}\n\n/**\n * Thrown when a query arrives but no usable prebuilt embeddings index exists AND\n * the doc set is too large to embed within a single serverless request. Surfaced\n * to the client as a clear \"temporarily unavailable\" message instead of letting\n * the request hang until the platform's function timeout (the old failure mode:\n * the chat connected, streamed heartbeats, and never answered on large docs).\n */\nexport class IndexNotBuiltError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"IndexNotBuiltError\";\n }\n}\n\n/**\n * Max chunks we will embed on demand inside one request when the prebuilt index\n * is missing. Above this, embedding the whole set (one round trip per BATCH_SIZE\n * chunks) cannot finish within a serverless function's time limit, so the chat\n * would hang forever. Only enforced in production - `next dev` has no time cap,\n * so it keeps embedding on demand and works without running the build. Override\n * per deployment with RAG_RUNTIME_EMBED_MAX_CHUNKS (0 = always require a prebuilt\n * index). Parsed defensively so a bad value falls back to the default rather\n * than silently disabling the guard; note `Number(x) || 400` would drop a valid 0.\n */\nfunction resolveRuntimeEmbedMax(): number {\n const raw = process.env.RAG_RUNTIME_EMBED_MAX_CHUNKS;\n if (raw !== undefined && raw !== \"\") {\n const parsed = Number(raw);\n if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed);\n }\n return 400;\n}\nconst RUNTIME_EMBED_MAX_CHUNKS = resolveRuntimeEmbedMax();\n\n/**\n * In-memory cache for document embeddings.\n * Built lazily on first use since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: IndexedChunk[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise | null = null;\n\n/**\n * Human-readable reason the index is unavailable, or null when healthy. Surfaced\n * by getIndexStatus (and GET /api/rag) so a missing/broken index is diagnosable\n * from outside without reading server logs.\n */\nlet indexUnavailableReason: string | null = null;\n\n/**\n * Absolute path to the embeddings index precomputed at build time by\n * scripts/build-docs-index.mts. Bundled into serverless functions via\n * outputFileTracingIncludes in next.config.ts.\n */\nconst INDEX_FILE = path.join(\n process.cwd(),\n \"services\",\n \"mcp\",\n \"docs-index.json\",\n);\n\n/**\n * Load embeddings precomputed at build time. Returns null when the file is\n * missing/empty, was built with a different provider/model/dimension than the\n * current config, or is not int8-quantized - query and document vectors must\n * come from the same embedding model and the same transform (dims + int8).\n * A null return makes the caller fall back to embedding on demand at runtime.\n */\nfunction loadPrecomputedIndex(): IndexedChunk[] | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(INDEX_FILE, \"utf8\")) as {\n provider?: string;\n embeddingModel?: string;\n dims?: number;\n quantization?: string;\n chunks?: (DocsChunk & { embedding: string })[];\n };\n if (!parsed.chunks || parsed.chunks.length === 0) return null;\n const config = getLLMConfig();\n // Guard on the exact transform: the same dims value at build and query time\n // guarantees reduceDims produces matching-length vectors on both sides.\n if (\n parsed.provider !== config.provider ||\n parsed.embeddingModel !== config.embeddingModel ||\n parsed.dims !== config.embeddingDims ||\n parsed.quantization !== \"int8\"\n ) {\n return null;\n }\n const decoded: IndexedChunk[] = [];\n let expectedLen = -1;\n for (const c of parsed.chunks) {\n const embedding = decodeInt8(c.embedding);\n // Reject a corrupt index rather than scoring against ragged vectors.\n if (expectedLen === -1) expectedLen = embedding.length;\n else if (embedding.length !== expectedLen) return null;\n decoded.push({\n id: c.id,\n text: c.text,\n path: c.path,\n uri: c.uri,\n embedding,\n });\n }\n return decoded;\n } catch {\n return null;\n }\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n indexUnavailableReason = null;\n try {\n // Prefer embeddings precomputed at build time - avoids re-embedding the\n // entire doc set on every cold start (the main cause of slow first chats).\n if (!force) {\n const precomputed = loadPrecomputedIndex();\n if (precomputed) {\n docsIndex.chunks = precomputed;\n docsIndex.ready = true;\n return;\n }\n }\n\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n // Reaching here means no usable prebuilt index was loaded (missing, empty,\n // or built with a different provider/model/dims). Embedding the whole set on\n // demand only completes in time for small doc sets; for large ones it runs\n // one round trip per BATCH_SIZE chunks and blows past the serverless function\n // limit, so the chat connects and then hangs forever with no answer. In\n // production, fail fast with an actionable error instead of hanging (this\n // also stops a client-forced `refresh` from burning embedding quota).\n if (\n process.env.NODE_ENV === \"production\" &&\n chunks.length > RUNTIME_EMBED_MAX_CHUNKS\n ) {\n indexUnavailableReason =\n `Prebuilt embeddings index missing; refusing to embed ${chunks.length} ` +\n `chunks at request time (limit ${RUNTIME_EMBED_MAX_CHUNKS}). Run the build ` +\n `with an embedding API key set so scripts/build-docs-index.mts writes ` +\n `services/mcp/docs-index.json, then redeploy.`;\n console.error(`[doccupine] ${indexUnavailableReason}`);\n throw new IndexNotBuiltError(\n \"The AI assistant is temporarily unavailable: its documentation search \" +\n \"index has not been built for this deployment. If you are the site \" +\n \"owner, redeploy with an embedding API key available at build time.\",\n );\n }\n\n // Small enough (or local dev): embed on demand. This still re-embeds on every\n // cold start, so a prebuilt index is strongly preferred in production.\n console.warn(\n `[doccupine] No prebuilt embeddings index found; embedding ${chunks.length} ` +\n `chunks on demand. Precompute at build time to avoid this on each cold start.`,\n );\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits.\n // Reduce + quantize to the same int8 representation as the precomputed\n // index so searchDocs scores identically on either path.\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const built: IndexedChunk[] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n for (let j = 0; j < batchVectors.length; j++) {\n built.push({\n ...chunks[i + j],\n embedding: quantizeInt8(\n reduceDims(batchVectors[j], config.embeddingDims),\n ),\n });\n }\n }\n\n docsIndex.chunks = built;\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(\n force = false,\n signal?: AbortSignal,\n): Promise {\n signal?.throwIfAborted();\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n } else if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n\n const build = indexReady;\n if (!signal) return build;\n\n // Embedding clients do not expose AbortSignal parameters. Keep the shared\n // index build alive for other requests, but stop this caller waiting as soon\n // as its request is cancelled.\n await new Promise((resolve, reject) => {\n const abort = () => reject(signal.reason);\n signal.addEventListener(\"abort\", abort, { once: true });\n void build.then(resolve, reject).finally(() => {\n signal.removeEventListener(\"abort\", abort);\n });\n });\n signal.throwIfAborted();\n}\n\nfunction throwIfCancelled(signal?: AbortSignal): void {\n signal?.throwIfAborted();\n}\n\nasync function embedQuery(\n query: string,\n signal?: AbortSignal,\n): Promise {\n throwIfCancelled(signal);\n // LangChain's provider-neutral Embeddings interface has no signal option.\n const vector = await getEmbeddings().embedQuery(query);\n throwIfCancelled(signal);\n return vector;\n}\n\nfunction normalizeSearchLimit(limit: number): number {\n if (!Number.isFinite(limit)) return 6;\n return Math.max(1, Math.min(20, Math.floor(limit)));\n}\n\nfunction toolResult(value: unknown) {\n const serialized = serializeMCPResult(value);\n return {\n content: [{ type: \"text\" as const, text: serialized.text }],\n ...(serialized.tooLarge ? { isError: true as const } : {}),\n };\n}\n\nfunction toolError(message: string) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: message }),\n },\n ],\n isError: true as const,\n };\n}\n\nfunction resourceText(value: unknown): string {\n return serializeMCPResult(value).text;\n}\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n signal?: AbortSignal,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex(false, signal);\n throwIfCancelled(signal);\n\n // Reduce the query vector with the exact same transform used at index time so\n // dimensions line up. Scoring runs directly against the int8 vectors - cosine\n // is scale-invariant, so no dequantization is needed.\n const rawQueryVector = await embedQuery(query, signal);\n const queryVector = reduceDims(rawQueryVector, getLLMConfig().embeddingDims);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineFloatInt8(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, normalizeSearchLimit(limit));\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): {\n ready: boolean;\n chunkCount: number;\n reason: string | null;\n} {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n reason: indexUnavailableReason,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.registerTool(\n \"search_docs\",\n {\n description: DOCS_TOOLS[0].description,\n inputSchema: searchDocsArgsSchema,\n },\n async ({ query, limit }, { signal }) => {\n const results = await searchDocs(query, limit ?? 6, signal);\n return toolResult(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n );\n },\n );\n\n // Register the get_doc tool\n server.registerTool(\n \"get_doc\",\n {\n description: DOCS_TOOLS[1].description,\n inputSchema: getDocArgsSchema,\n },\n async ({ path }, { signal }) => {\n signal.throwIfAborted();\n const doc = await getDoc({ path });\n signal.throwIfAborted();\n if (!doc) {\n return toolError(\"Document not found\");\n }\n return toolResult(doc);\n },\n );\n\n // Register the list_docs tool\n server.registerTool(\n \"list_docs\",\n {\n description: DOCS_TOOLS[2].description,\n inputSchema: listDocsArgsSchema,\n },\n async ({ directory }, { signal }) => {\n signal.throwIfAborted();\n const docs = await listDocs({ directory });\n signal.throwIfAborted();\n return toolResult(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n );\n },\n );\n\n // Register the documentation index as a resource\n server.registerResource(\n \"docs-list\",\n \"docs://list\",\n {\n title: \"Documentation index\",\n description:\n \"JSON index of every documentation page: name, path, and docs:// URI readable with resources/read\",\n mimeType: \"application/json\",\n },\n async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n mimeType: \"application/json\",\n text: resourceText(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n ),\n },\n ],\n };\n },\n );\n\n // Register every documentation page as a readable docs:// resource.\n // `{+path}` is a reserved expansion so nested routes keep their slashes.\n // The fixed docs://list resource above is matched before this template.\n server.registerResource(\n \"doc\",\n new ResourceTemplate(\"docs://{+path}\", { list: undefined }),\n {\n title: \"Documentation page\",\n description:\n \"The Markdown content of one documentation page, addressed by the docs:// URI from the docs://list index\",\n mimeType: \"text/markdown\",\n },\n async (uri, _variables, { signal }) => {\n signal.throwIfAborted();\n const doc = await getDoc({ path: uri.href });\n signal.throwIfAborted();\n if (!doc) {\n throw new Error(`Document not found: ${uri.href}`);\n }\n return {\n contents: [\n { uri: doc.uri, mimeType: \"text/markdown\", text: doc.content },\n ],\n };\n },\n );\n\n return server;\n}\n";