diff --git a/TOC-ai.md b/TOC-ai.md index e79d9a34cc2be..e42e79ceef66f 100644 --- a/TOC-ai.md +++ b/TOC-ai.md @@ -70,6 +70,7 @@ - [Amazon Bedrock](/ai/integrations/vector-search-integrate-with-amazon-bedrock.md) - MCP Server - [Overview](/ai/integrations/tidb-mcp-server.md) + - [TiDB Docs MCP Server](/ai/integrations/tidb-docs-mcp-server.md) - [Claude Code](/ai/integrations/tidb-mcp-claude-code.md) - [Claude Desktop](/ai/integrations/tidb-mcp-claude-desktop.md) - [Cursor](/ai/integrations/tidb-mcp-cursor.md) diff --git a/ai/integrations/tidb-docs-mcp-server.md b/ai/integrations/tidb-docs-mcp-server.md new file mode 100644 index 0000000000000..de871519797ab --- /dev/null +++ b/ai/integrations/tidb-docs-mcp-server.md @@ -0,0 +1,210 @@ +--- +title: TiDB Docs MCP Server +summary: Connect AI clients to TiDB documentation through an MCP server with search tools and markdown resources. +--- + +# TiDB Docs MCP Server + +TiDB Docs MCP Server exposes TiDB documentation to MCP-compatible AI clients such as Claude Code, Claude Desktop, VS Code, Cursor, and other tools. + +It supports: + +- **STDIO transport** for local development +- **HTTP transport** for shared environments (for example, staging) +- **Bearer token authentication** +- **Source isolation** (for example, `staging` vs `prod`) + +## What you get + +The server provides structured tools and resources for docs access: + +- Search by feature, topic, path, and full-text +- Fetch full markdown for a single document on demand +- List topics and feature tokens +- Reload index after docs updates + +## Prerequisites + +- Node.js 18 or later +- TiDB docs repository cloned locally + +## Start the server + +### Start with STDIO transport + +```bash +npm run docs-mcp:serve +``` + +Optionally use `docs-staging` as source: + +```bash +DOCS_API_SOURCE_DIR=/workspaces/docs-staging npm run docs-mcp:serve +``` + +### Start with HTTP transport + +```bash +DOCS_MCP_TRANSPORT=http \ +DOCS_MCP_HTTP_HOST=0.0.0.0 \ +DOCS_MCP_HTTP_PORT=3100 \ +DOCS_MCP_AUTH_TOKEN= \ +DOCS_MCP_SOURCE_MAP='{"staging":"/workspaces/docs-staging","prod":"/workspaces/docs"}' \ +npm run docs-mcp:serve:http +``` + +Endpoints: + +- MCP endpoint: `POST /mcp` +- Health check: `GET /healthz` + +## Authentication + +If `DOCS_MCP_AUTH_TOKEN` is set, all MCP HTTP calls must include: + +```http +Authorization: Bearer +``` + +## Source isolation + +Use `DOCS_MCP_SOURCE_MAP` to map source keys to directories: + +```bash +DOCS_MCP_SOURCE_MAP='{"staging":"/workspaces/docs-staging","prod":"/workspaces/docs"}' +``` + +Then select source per request: + +```http +x-docs-source: staging +``` + +## Supported tools + +### Read-only tools + +- `search_docs` +- `get_doc_content` +- `list_topics` +- `list_features` + +### Admin tool + +- `reload_docs_index` + +## Supported resources + +- `docs://schema` +- `docs://index/meta` +- `docs://doc/` + +Example: + +- `docs://doc/tidb-cloud%2Fbackup-and-restore-serverless.md` + +## Client configuration examples + +### Claude Code (`.mcp.json`, STDIO) + +```json +{ + "mcpServers": { + "tidb-docs": { + "command": "node", + "args": ["scripts/docs-mcp-server.js"], + "env": { + "DOCS_API_SOURCE_DIR": "/workspaces/docs-staging" + } + } + } +} +``` + +### Generic MCP HTTP client + +Use your MCP client's HTTP transport option with: + +- URL: `https://docs-api-staging.pingcap.com/mcp` (or your own endpoint) +- Header: `Authorization: Bearer ` +- Header (optional): `x-docs-source: staging` + +## HTTP JSON-RPC example + +```bash +curl -X POST "http://127.0.0.1:3100/mcp" \ + -H "content-type: application/json" \ + -H "authorization: Bearer " \ + -H "x-docs-source: staging" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/call", + "params":{ + "name":"search_docs", + "arguments":{"feature":"tidb_max_dist_task_nodes","limit":3} + } + }' +``` + +## Validate your deployment + +### 1. Health check + +```bash +curl http://:3100/healthz +``` + +Expected: + +- `{"ok":true}` + +### 2. Check available tools + +```bash +curl -s -X POST "http://:3100/mcp" \ + -H "content-type: application/json" \ + -H "authorization: Bearer " \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +Expected tools: + +- `search_docs` +- `get_doc_content` +- `list_topics` +- `list_features` +- `reload_docs_index` + +### 3. Verify staging source and placeholder replacement + +```bash +curl -s -X POST "http://:3100/mcp" \ + -H "content-type: application/json" \ + -H "authorization: Bearer " \ + -H "x-docs-source: staging" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_docs","arguments":{"path":"tidb-cloud/backup-and-restore-serverless.md","limit":1}}}' +``` + +Check: + +- `meta.sourceKey` is `staging` +- Returned title/content does not include unresolved placeholders like `{{{ .starter }}}` + +## Troubleshooting + +- **401 Unauthorized** + - Verify `Authorization: Bearer ` and `DOCS_MCP_AUTH_TOKEN`. +- **Wrong docs source** + - Verify `x-docs-source` and `DOCS_MCP_SOURCE_MAP`. +- **No results for expected queries** + - Run `reload_docs_index` after docs updates. +- **Cannot connect** + - Check host/port and network access to `/mcp`. + +## Design notes + +- `search_docs` is optimized for lightweight response by default. +- Use `get_doc_content` when full markdown is required. +- Template variables (for example, `{{{ .starter }}}`) are resolved via `variables.json` in the selected source directory. + diff --git a/api/docs-json-api.md b/api/docs-json-api.md new file mode 100644 index 0000000000000..9a3077879f898 --- /dev/null +++ b/api/docs-json-api.md @@ -0,0 +1,88 @@ +--- +title: Docs JSON API (Experimental) +summary: Provide a structured JSON API for TiDB docs with topic and feature filters. +--- + +# Docs JSON API (Experimental) + +This API layer exposes structured metadata for markdown docs. + +## Why + +- Query docs by feature token (for example, `tidb_max_dist_task_nodes`) +- Query docs by topic/category +- Return structured schema instead of raw markdown only +- Keep list APIs fast by default, and fetch full content on demand + +## Data schema + +Each doc record includes: + +- `id` +- `path` +- `title` +- `summary` +- `product` +- `topics` +- `features` +- `headings` +- `frontMatter` +- `frontMatterRaw` +- `updatedAt` + +## Build index + +```bash +npm run docs-api:build +``` + +Default output file: `tmp/docs-api-index.json` + +## Run API server + +```bash +npm run docs-api:serve +``` + +Default host and port: `127.0.0.1:3000` + +## Endpoints + +- `GET /healthz` +- `GET /schema` +- `GET /topics` +- `GET /features` +- `GET /features?prefix=tidb_` +- `GET /docs` +- `GET /docs?feature=tidb_max_dist_task_nodes` +- `GET /docs?topic=tidb-cloud` +- `GET /docs?q=resource control` +- `GET /docs?feature=tidb_max_dist_task_nodes&limit=10&offset=0` +- `GET /docs?topic=tidb-cloud&includeContent=true` (returns markdown content in list response) +- `GET /docs/content?path=tidb-cloud/backup-and-restore.md` +- `GET /docs/content?id=tidb-cloud/backup-and-restore` +- `GET /reload` (reload in-memory index) + +## Search and performance behavior + +- `q` uses path, title, summary, and full-text matching. +- `/docs` does **not** return full markdown content by default. +- Use `/docs/content` to fetch full markdown content for a single document. +- If needed, set `includeContent=true` on `/docs` for small result sets. + +## Environment variables + +- `DOCS_API_HOST` (default `127.0.0.1`) +- `DOCS_API_PORT` (default `3000`) +- `DOCS_API_SOURCE_DIR` (default: if `../docs-staging` exists, use it; otherwise current working directory) +- `DOCS_API_INDEX_FILE` (optional prebuilt JSON index path) + +## Source priority + +The API loads markdown files from the source directory in this order: + +1. `DOCS_API_SOURCE_DIR` (if set) +2. `../docs-staging` (if exists) +3. current working directory + +Template variables in markdown such as `{{{ .starter }}}` are replaced using `variables.json` in the selected source directory. diff --git a/api/docs-mcp-server.md b/api/docs-mcp-server.md new file mode 100644 index 0000000000000..de871519797ab --- /dev/null +++ b/api/docs-mcp-server.md @@ -0,0 +1,210 @@ +--- +title: TiDB Docs MCP Server +summary: Connect AI clients to TiDB documentation through an MCP server with search tools and markdown resources. +--- + +# TiDB Docs MCP Server + +TiDB Docs MCP Server exposes TiDB documentation to MCP-compatible AI clients such as Claude Code, Claude Desktop, VS Code, Cursor, and other tools. + +It supports: + +- **STDIO transport** for local development +- **HTTP transport** for shared environments (for example, staging) +- **Bearer token authentication** +- **Source isolation** (for example, `staging` vs `prod`) + +## What you get + +The server provides structured tools and resources for docs access: + +- Search by feature, topic, path, and full-text +- Fetch full markdown for a single document on demand +- List topics and feature tokens +- Reload index after docs updates + +## Prerequisites + +- Node.js 18 or later +- TiDB docs repository cloned locally + +## Start the server + +### Start with STDIO transport + +```bash +npm run docs-mcp:serve +``` + +Optionally use `docs-staging` as source: + +```bash +DOCS_API_SOURCE_DIR=/workspaces/docs-staging npm run docs-mcp:serve +``` + +### Start with HTTP transport + +```bash +DOCS_MCP_TRANSPORT=http \ +DOCS_MCP_HTTP_HOST=0.0.0.0 \ +DOCS_MCP_HTTP_PORT=3100 \ +DOCS_MCP_AUTH_TOKEN= \ +DOCS_MCP_SOURCE_MAP='{"staging":"/workspaces/docs-staging","prod":"/workspaces/docs"}' \ +npm run docs-mcp:serve:http +``` + +Endpoints: + +- MCP endpoint: `POST /mcp` +- Health check: `GET /healthz` + +## Authentication + +If `DOCS_MCP_AUTH_TOKEN` is set, all MCP HTTP calls must include: + +```http +Authorization: Bearer +``` + +## Source isolation + +Use `DOCS_MCP_SOURCE_MAP` to map source keys to directories: + +```bash +DOCS_MCP_SOURCE_MAP='{"staging":"/workspaces/docs-staging","prod":"/workspaces/docs"}' +``` + +Then select source per request: + +```http +x-docs-source: staging +``` + +## Supported tools + +### Read-only tools + +- `search_docs` +- `get_doc_content` +- `list_topics` +- `list_features` + +### Admin tool + +- `reload_docs_index` + +## Supported resources + +- `docs://schema` +- `docs://index/meta` +- `docs://doc/` + +Example: + +- `docs://doc/tidb-cloud%2Fbackup-and-restore-serverless.md` + +## Client configuration examples + +### Claude Code (`.mcp.json`, STDIO) + +```json +{ + "mcpServers": { + "tidb-docs": { + "command": "node", + "args": ["scripts/docs-mcp-server.js"], + "env": { + "DOCS_API_SOURCE_DIR": "/workspaces/docs-staging" + } + } + } +} +``` + +### Generic MCP HTTP client + +Use your MCP client's HTTP transport option with: + +- URL: `https://docs-api-staging.pingcap.com/mcp` (or your own endpoint) +- Header: `Authorization: Bearer ` +- Header (optional): `x-docs-source: staging` + +## HTTP JSON-RPC example + +```bash +curl -X POST "http://127.0.0.1:3100/mcp" \ + -H "content-type: application/json" \ + -H "authorization: Bearer " \ + -H "x-docs-source: staging" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"tools/call", + "params":{ + "name":"search_docs", + "arguments":{"feature":"tidb_max_dist_task_nodes","limit":3} + } + }' +``` + +## Validate your deployment + +### 1. Health check + +```bash +curl http://:3100/healthz +``` + +Expected: + +- `{"ok":true}` + +### 2. Check available tools + +```bash +curl -s -X POST "http://:3100/mcp" \ + -H "content-type: application/json" \ + -H "authorization: Bearer " \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' +``` + +Expected tools: + +- `search_docs` +- `get_doc_content` +- `list_topics` +- `list_features` +- `reload_docs_index` + +### 3. Verify staging source and placeholder replacement + +```bash +curl -s -X POST "http://:3100/mcp" \ + -H "content-type: application/json" \ + -H "authorization: Bearer " \ + -H "x-docs-source: staging" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_docs","arguments":{"path":"tidb-cloud/backup-and-restore-serverless.md","limit":1}}}' +``` + +Check: + +- `meta.sourceKey` is `staging` +- Returned title/content does not include unresolved placeholders like `{{{ .starter }}}` + +## Troubleshooting + +- **401 Unauthorized** + - Verify `Authorization: Bearer ` and `DOCS_MCP_AUTH_TOKEN`. +- **Wrong docs source** + - Verify `x-docs-source` and `DOCS_MCP_SOURCE_MAP`. +- **No results for expected queries** + - Run `reload_docs_index` after docs updates. +- **Cannot connect** + - Check host/port and network access to `/mcp`. + +## Design notes + +- `search_docs` is optimized for lightweight response by default. +- Use `get_doc_content` when full markdown is required. +- Template variables (for example, `{{{ .starter }}}`) are resolved via `variables.json` in the selected source directory. + diff --git a/package.json b/package.json index 4e5b303151bdf..44d9f9a60e66b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,12 @@ "main": "index.js", "license": "MIT", "type": "module", + "scripts": { + "docs-api:build": "node scripts/build-docs-api-index.js", + "docs-api:serve": "node scripts/docs-api-server.js", + "docs-mcp:serve": "node scripts/docs-mcp-server.js", + "docs-mcp:serve:http": "node scripts/docs-mcp-server.js" + }, "dependencies": { "axios": "^1.4.0", "glob": "^8.0.3", diff --git a/scripts/build-docs-api-index.js b/scripts/build-docs-api-index.js new file mode 100644 index 0000000000000..813f06a704ab9 --- /dev/null +++ b/scripts/build-docs-api-index.js @@ -0,0 +1,23 @@ +import * as fs from "fs"; +import path from "path"; +import { buildDocsIndex, resolveDefaultSourceDir } from "./docs-api-lib.js"; + +const args = process.argv.slice(2); +const outputArg = args[0] || "tmp/docs-api-index.json"; +const rootArg = + args[1] || process.env.DOCS_API_SOURCE_DIR || resolveDefaultSourceDir(process.cwd()); + +const outputPath = path.resolve(outputArg); +const outputDir = path.dirname(outputPath); + +if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); +} + +const sourceDir = path.resolve(rootArg); +const index = buildDocsIndex(sourceDir); +fs.writeFileSync(outputPath, JSON.stringify(index, null, 2), "utf8"); + +console.log( + `Docs API index generated: ${outputPath} (${index.totalDocs} docs, ${index.features.length} features) from source: ${sourceDir}` +); diff --git a/scripts/docs-api-lib.js b/scripts/docs-api-lib.js new file mode 100644 index 0000000000000..8f79107c61718 --- /dev/null +++ b/scripts/docs-api-lib.js @@ -0,0 +1,456 @@ +import * as fs from "fs"; +import path from "path"; + +const DOC_IGNORE_DIRS = new Set(["node_modules", ".git", "media", "tmp"]); +const DOC_IGNORE_FILES = new Set(["api/docs-json-api.md", "api/docs-mcp-server.md"]); + +const MAX_SUMMARY_LENGTH = 220; + +const toPosixPath = (filePath) => filePath.replaceAll("\\", "/"); + +const safeString = (value) => (typeof value === "string" ? value : ""); + +const slugify = (input = "") => + input + .toLowerCase() + .trim() + .replace(/[`~!@#$%^&*()+=[\]{}|\\:;"'<>,.?/]+/g, "") + .replace(/\s+/g, "-"); + +const getValueByPath = (obj, keyPath) => { + return ( + keyPath + .split(".") + .reduce((acc, key) => (acc !== undefined && acc !== null ? acc[key] : ""), obj) ?? "" + ); +}; + +const replaceTemplateVariables = (content, variables = {}) => { + const variablePattern = /{{{\s*\.(.+?)\s*}}}/g; + return content.replace(variablePattern, (match, variablePath) => { + const value = getValueByPath(variables, variablePath.trim()); + if (value === undefined || value === null || value === "") { + return match; + } + return String(value); + }); +}; + +const parseScalar = (raw) => { + const value = raw.trim(); + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10); + if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +}; + +const parseSimpleYaml = (raw = "") => { + const result = {}; + let currentArrayKey = null; + + raw.split(/\r?\n/).forEach((line) => { + if (!line.trim() || line.trim().startsWith("#")) { + return; + } + + const kvMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (kvMatch) { + const key = kvMatch[1]; + const value = kvMatch[2]; + if (!value.trim()) { + result[key] = []; + currentArrayKey = key; + } else { + result[key] = parseScalar(value); + currentArrayKey = null; + } + return; + } + + const listMatch = line.match(/^\s*-\s*(.*)$/); + if (listMatch && currentArrayKey) { + result[currentArrayKey].push(parseScalar(listMatch[1])); + return; + } + + currentArrayKey = null; + }); + + return result; +}; + +const extractFrontMatter = (content) => { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) { + return { + raw: "", + data: {}, + }; + } + return { + raw: match[1], + data: parseSimpleYaml(match[1]), + }; +}; + +const stripFrontMatter = (content) => + content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, ""); + +const stripInlineMarkdown = (text) => + text + .replace(/`([^`]+)`/g, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") + .replace(/[*_~>#]/g, "") + .replace(/\s+/g, " ") + .trim(); + +const markdownToSearchText = (content) => { + const withoutFrontMatter = stripFrontMatter(content); + return stripInlineMarkdown( + withoutFrontMatter + .replace(/```[\s\S]*?```/g, " ") + .replace(/<[^>]+>/g, " ") + .replace(/{{<[^>]+>}}/g, " ") + .replace(/\|/g, " ") + .replace(/\r?\n/g, " ") + ); +}; + +const collectMarkdownFiles = (rootDir) => { + const results = []; + const walk = (dir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (entry.name.startsWith(".") || DOC_IGNORE_DIRS.has(entry.name)) continue; + walk(path.join(dir, entry.name)); + continue; + } + if (!entry.isFile()) continue; + if (!entry.name.endsWith(".md")) continue; + const absPath = path.join(dir, entry.name); + const relativePath = toPosixPath(path.relative(rootDir, absPath)); + if (DOC_IGNORE_FILES.has(relativePath)) continue; + results.push(absPath); + } + }; + walk(rootDir); + return results; +}; + +const parseHeadingsAndSummary = (content) => { + const lines = stripFrontMatter(content).split(/\r?\n/); + const headings = []; + let summary = ""; + let inCodeBlock = false; + let paragraphBuffer = []; + + const flushParagraph = () => { + if (summary || paragraphBuffer.length === 0) { + paragraphBuffer = []; + return; + } + const text = stripInlineMarkdown(paragraphBuffer.join(" ").trim()); + if (text) { + summary = truncate(text); + } + paragraphBuffer = []; + }; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (line.startsWith("```")) { + inCodeBlock = !inCodeBlock; + continue; + } + if (inCodeBlock) continue; + + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + flushParagraph(); + const level = headingMatch[1].length; + const text = stripInlineMarkdown(headingMatch[2]); + if (text) { + headings.push({ + level, + text, + slug: slugify(text), + }); + } + continue; + } + + if (!line) { + flushParagraph(); + continue; + } + + if ( + line.startsWith("- ") || + line.startsWith("* ") || + line.startsWith("> ") || + line.startsWith("|") || + /^\d+\.\s+/.test(line) + ) { + continue; + } + + paragraphBuffer.push(line); + } + + flushParagraph(); + return { headings, summary }; +}; + +const inferProduct = (docPath) => { + if (docPath.startsWith("tidb-cloud/")) return "tidb-cloud"; + if (docPath.startsWith("dm/")) return "dm"; + if (docPath.startsWith("br/")) return "br"; + if (docPath.startsWith("ticdc/")) return "ticdc"; + if (docPath.startsWith("tiflash/")) return "tiflash"; + if (docPath.startsWith("tiup/")) return "tiup"; + return "tidb"; +}; + +const extractFeatures = (content, frontMatterData) => { + const features = new Set(); + const varRegex = /\b[a-z]+(?:_[a-z0-9]+){2,}\b/g; + for (const match of content.matchAll(varRegex)) { + const token = match[0]; + if ( + token.startsWith("tidb_") || + token.startsWith("tikv_") || + token.startsWith("pd_") || + token.startsWith("tiflash_") + ) { + features.add(token); + } + } + + const fmFeatureKeys = ["feature", "features", "tag", "tags"]; + fmFeatureKeys.forEach((key) => { + const value = frontMatterData[key]; + if (Array.isArray(value)) { + value.forEach((item) => { + if (typeof item === "string" && item.trim()) { + features.add(item.trim()); + } + }); + return; + } + if (typeof value === "string" && value.trim()) { + features.add(value.trim()); + } + }); + + return [...features]; +}; + +const truncate = (text, limit = MAX_SUMMARY_LENGTH) => { + if (!text) return ""; + if (text.length <= limit) return text; + return `${text.slice(0, limit - 3)}...`; +}; + +const normalizeTopics = (docPath, frontMatterData) => { + const segments = docPath + .replace(/\.md$/, "") + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + const topics = new Set(segments.slice(0, -1)); + + const fmTopicKeys = ["topic", "topics", "category", "categories"]; + fmTopicKeys.forEach((key) => { + const value = frontMatterData[key]; + if (Array.isArray(value)) { + value.forEach((item) => { + if (typeof item === "string" && item.trim()) topics.add(item.trim()); + }); + return; + } + if (typeof value === "string" && value.trim()) { + topics.add(value.trim()); + } + }); + + return [...topics]; +}; + +const parseMarkdownDoc = (rootDir, absPath, variables) => { + const relativePath = toPosixPath(path.relative(rootDir, absPath)); + const originalRaw = fs.readFileSync(absPath, "utf8"); + const raw = replaceTemplateVariables(originalRaw, variables); + const { data: frontMatter, raw: frontMatterRaw } = extractFrontMatter(raw); + const { headings, summary } = parseHeadingsAndSummary(raw); + let title = safeString(frontMatter.title); + if (!title) { + const h1 = headings.find((item) => item.level === 1); + if (h1) title = h1.text; + } + + if (!title) { + title = path.basename(relativePath, ".md"); + } + + const docStat = fs.statSync(absPath); + const features = extractFeatures(raw, frontMatter).sort(); + const topics = normalizeTopics(relativePath, frontMatter).sort(); + const searchText = markdownToSearchText(raw).toLowerCase(); + + return { + id: relativePath.replace(/\.md$/, ""), + path: relativePath, + title, + summary, + product: inferProduct(relativePath), + topics, + features, + headings, + frontMatter, + frontMatterRaw, + updatedAt: docStat.mtime.toISOString(), + _searchText: searchText, + }; +}; + +export const loadTemplateVariables = (rootDir = process.cwd()) => { + const normalizedRoot = path.resolve(rootDir); + const variablesPath = path.join(normalizedRoot, "variables.json"); + let variables = {}; + if (fs.existsSync(variablesPath)) { + try { + variables = JSON.parse(fs.readFileSync(variablesPath, "utf8")); + } catch (error) { + console.warn( + `Warning: failed to parse variables.json at ${variablesPath}, continuing without variable replacement.` + ); + } + } + return variables; +}; + +export const loadDocContentByPath = (rootDir, docPath, variables) => { + const normalizedRoot = path.resolve(rootDir); + const normalizedDocPath = docPath.replaceAll("\\", "/").replace(/^\/+/, ""); + const absPath = path.join(normalizedRoot, normalizedDocPath); + if (!absPath.startsWith(normalizedRoot)) { + throw new Error("Invalid path."); + } + if (!fs.existsSync(absPath)) { + throw new Error("Document not found."); + } + const raw = fs.readFileSync(absPath, "utf8"); + return replaceTemplateVariables(raw, variables); +}; + +export const buildDocsIndex = (rootDir = process.cwd()) => { + const normalizedRoot = path.resolve(rootDir); + const variables = loadTemplateVariables(normalizedRoot); + + const mdFiles = collectMarkdownFiles(normalizedRoot); + + const docs = mdFiles + .map((absPath) => parseMarkdownDoc(normalizedRoot, absPath, variables)) + .sort((a, b) => a.path.localeCompare(b.path)); + + const topicSet = new Set(); + const featureSet = new Set(); + docs.forEach((doc) => { + doc.topics.forEach((topic) => topicSet.add(topic)); + doc.features.forEach((feature) => featureSet.add(feature)); + }); + + return { + schemaVersion: "1.0.0", + generatedAt: new Date().toISOString(), + totalDocs: docs.length, + topics: [...topicSet].sort(), + features: [...featureSet].sort(), + docs, + }; +}; + +export const resolveDefaultSourceDir = (baseDir = process.cwd()) => { + const normalizedBase = path.resolve(baseDir); + const siblingDocsStaging = path.resolve(normalizedBase, "..", "docs-staging"); + if (fs.existsSync(siblingDocsStaging) && fs.statSync(siblingDocsStaging).isDirectory()) { + return siblingDocsStaging; + } + return normalizedBase; +}; + +export const docsApiSchema = { + schemaVersion: "1.0.0", + endpoints: { + "/docs": { + method: "GET", + query: { + feature: "Exact feature token filter, case-insensitive.", + topic: "Topic/category filter, case-insensitive.", + q: "Keyword match in path/title/summary/full-text, case-insensitive.", + includeContent: "Whether to include markdown content in list results. Default false.", + path: "Exact document path filter, case-insensitive.", + limit: "Page size. Default 20, max 100.", + offset: "Pagination offset. Default 0.", + }, + response: { + meta: { + total: "Matched document count before pagination.", + limit: "Applied page size.", + offset: "Applied offset.", + returned: "Number of docs in data.", + }, + data: "Array", + }, + }, + "/topics": { + method: "GET", + response: "Array", + }, + "/features": { + method: "GET", + query: { + prefix: "Optional prefix filter.", + }, + response: "Array", + }, + "/schema": { + method: "GET", + response: "This schema document.", + }, + "/docs/content": { + method: "GET", + query: { + path: "Exact document path, e.g. tidb-cloud/backup-and-restore.md", + id: "Document id, e.g. tidb-cloud/backup-and-restore", + }, + response: "Single DocRecord with markdown content.", + }, + "/healthz": { + method: "GET", + response: "{ ok: true }", + }, + }, + docRecord: { + id: "string", + path: "string", + title: "string", + summary: "string", + product: "string", + topics: "string[]", + features: "string[]", + headings: "Array<{level:number,text:string,slug:string}>", + frontMatter: "object", + frontMatterRaw: "string", + updatedAt: "ISO-8601 string", + }, +}; diff --git a/scripts/docs-api-server.js b/scripts/docs-api-server.js new file mode 100644 index 0000000000000..8e433a1e17b55 --- /dev/null +++ b/scripts/docs-api-server.js @@ -0,0 +1,198 @@ +import * as fs from "fs"; +import http from "http"; +import path from "path"; +import { + buildDocsIndex, + docsApiSchema, + loadDocContentByPath, + loadTemplateVariables, + resolveDefaultSourceDir, +} from "./docs-api-lib.js"; + +const PORT = Number.parseInt(process.env.DOCS_API_PORT || "3000", 10); +const HOST = process.env.DOCS_API_HOST || "127.0.0.1"; +const SOURCE_DIR = path.resolve( + process.env.DOCS_API_SOURCE_DIR || resolveDefaultSourceDir(process.cwd()) +); +const PREBUILT_INDEX = process.env.DOCS_API_INDEX_FILE; +const TEMPLATE_VARIABLES = loadTemplateVariables(SOURCE_DIR); + +const loadIndex = () => { + if (PREBUILT_INDEX) { + const filePath = path.resolve(PREBUILT_INDEX); + if (fs.existsSync(filePath)) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } + } + return buildDocsIndex(SOURCE_DIR); +}; + +let docsIndex = loadIndex(); + +const toInt = (value, fallback) => { + const num = Number.parseInt(value, 10); + return Number.isNaN(num) ? fallback : num; +}; + +const containsCI = (text, keyword) => + text.toLowerCase().includes(keyword.toLowerCase()); + +const isTruthy = (value) => { + if (!value) return false; + return ["1", "true", "yes", "on"].includes(value.toLowerCase()); +}; + +const json = (res, statusCode, payload) => { + res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(payload, null, 2)); +}; + +const toPublicDoc = (doc, options = {}) => { + const includeContent = options.includeContent === true; + const result = { + id: doc.id, + path: doc.path, + title: doc.title, + summary: doc.summary, + product: doc.product, + topics: doc.topics, + features: doc.features, + headings: doc.headings, + frontMatter: doc.frontMatter, + frontMatterRaw: doc.frontMatterRaw, + updatedAt: doc.updatedAt, + }; + if (includeContent) { + try { + result.content = loadDocContentByPath(SOURCE_DIR, doc.path, TEMPLATE_VARIABLES); + result.contentType = "text/markdown"; + } catch (error) { + result.content = ""; + result.contentType = "text/markdown"; + result.contentError = String(error.message || error); + } + } + return result; +}; + +const filterDocs = (docs, query) => { + const feature = query.get("feature"); + const topic = query.get("topic"); + const keyword = query.get("q"); + const pathFilter = query.get("path"); + const includeContent = isTruthy(query.get("includeContent")); + const limit = Math.min(Math.max(toInt(query.get("limit"), 20), 1), 100); + const offset = Math.max(toInt(query.get("offset"), 0), 0); + + let rows = docs; + + if (feature) { + rows = rows.filter((doc) => + doc.features.some((item) => item.toLowerCase() === feature.toLowerCase()) + ); + } + if (topic) { + rows = rows.filter((doc) => + doc.topics.some((item) => item.toLowerCase() === topic.toLowerCase()) + ); + } + if (pathFilter) { + rows = rows.filter((doc) => doc.path.toLowerCase() === pathFilter.toLowerCase()); + } + if (keyword) { + const loweredKeyword = keyword.toLowerCase(); + rows = rows.filter((doc) => { + return ( + containsCI(doc.path, loweredKeyword) || + containsCI(doc.title, loweredKeyword) || + containsCI(doc.summary, loweredKeyword) || + containsCI(doc._searchText || "", loweredKeyword) + ); + }); + } + + const total = rows.length; + const paged = rows.slice(offset, offset + limit); + + return { + meta: { + total, + limit, + offset, + returned: paged.length, + includeContent, + }, + data: paged.map((doc) => toPublicDoc(doc, { includeContent })), + }; +}; + +const server = http.createServer((req, res) => { + if (!req.url) { + return json(res, 400, { error: "Invalid request URL." }); + } + + const url = new URL(req.url, `http://${HOST}:${PORT}`); + const pathname = url.pathname; + + if (req.method !== "GET") { + return json(res, 405, { error: "Only GET is supported." }); + } + + if (pathname === "/healthz") { + return json(res, 200, { ok: true }); + } + if (pathname === "/schema") { + return json(res, 200, docsApiSchema); + } + if (pathname === "/topics") { + return json(res, 200, { data: docsIndex.topics }); + } + if (pathname === "/features") { + const prefix = url.searchParams.get("prefix"); + if (!prefix) { + return json(res, 200, { data: docsIndex.features }); + } + const filtered = docsIndex.features.filter((f) => + f.toLowerCase().startsWith(prefix.toLowerCase()) + ); + return json(res, 200, { data: filtered }); + } + if (pathname === "/reload") { + docsIndex = loadIndex(); + return json(res, 200, { + ok: true, + totalDocs: docsIndex.totalDocs, + generatedAt: docsIndex.generatedAt, + }); + } + if (pathname === "/docs") { + return json(res, 200, filterDocs(docsIndex.docs, url.searchParams)); + } + if (pathname === "/docs/content") { + const pathParam = url.searchParams.get("path"); + const idParam = url.searchParams.get("id"); + if (!pathParam && !idParam) { + return json(res, 400, { error: "Either path or id is required." }); + } + + const doc = docsIndex.docs.find((item) => { + if (pathParam && item.path.toLowerCase() === pathParam.toLowerCase()) return true; + if (idParam && item.id.toLowerCase() === idParam.toLowerCase()) return true; + return false; + }); + + if (!doc) { + return json(res, 404, { error: "Document not found." }); + } + + return json(res, 200, { data: toPublicDoc(doc, { includeContent: true }) }); + } + + return json(res, 404, { error: "Not found." }); +}); + +server.listen(PORT, HOST, () => { + console.log( + `Docs API server running at http://${HOST}:${PORT} (docs: ${docsIndex.totalDocs}, source: ${SOURCE_DIR})` + ); +}); diff --git a/scripts/docs-mcp-server.js b/scripts/docs-mcp-server.js new file mode 100644 index 0000000000000..02b9290ed48ae --- /dev/null +++ b/scripts/docs-mcp-server.js @@ -0,0 +1,563 @@ +import http from "http"; +import { + buildDocsIndex, + docsApiSchema, + loadDocContentByPath, + loadTemplateVariables, + resolveDefaultSourceDir, +} from "./docs-api-lib.js"; + +const SERVER_NAME = "tidb-docs-mcp"; +const SERVER_VERSION = "0.2.0"; +const PROTOCOL_VERSION = "2024-11-05"; + +const TRANSPORT = (process.env.DOCS_MCP_TRANSPORT || "stdio").toLowerCase(); +const HTTP_HOST = process.env.DOCS_MCP_HTTP_HOST || "127.0.0.1"; +const HTTP_PORT = Number.parseInt(process.env.DOCS_MCP_HTTP_PORT || "3100", 10); +const AUTH_TOKEN = process.env.DOCS_MCP_AUTH_TOKEN || ""; +const SOURCE_MAP = parseJsonMap(process.env.DOCS_MCP_SOURCE_MAP || ""); + +const DEFAULT_SOURCE_DIR = process.env.DOCS_API_SOURCE_DIR || resolveDefaultSourceDir(process.cwd()); +const stateCache = new Map(); + +function parseJsonMap(raw) { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch (_error) {} + return {}; +} + +function normalizeSourceConfig(sourceKey) { + if (sourceKey && SOURCE_MAP[sourceKey]) { + return { + sourceKey, + sourceDir: SOURCE_MAP[sourceKey], + }; + } + return { + sourceKey: sourceKey || "default", + sourceDir: DEFAULT_SOURCE_DIR, + }; +} + +function getSourceState(sourceKey) { + const cfg = normalizeSourceConfig(sourceKey); + const cacheKey = `${cfg.sourceKey}::${cfg.sourceDir}`; + if (!stateCache.has(cacheKey)) { + stateCache.set(cacheKey, { + sourceKey: cfg.sourceKey, + sourceDir: cfg.sourceDir, + templateVariables: loadTemplateVariables(cfg.sourceDir), + docsIndex: buildDocsIndex(cfg.sourceDir), + }); + } + return stateCache.get(cacheKey); +} + +function refreshSourceState(sourceKey) { + const cfg = normalizeSourceConfig(sourceKey); + const cacheKey = `${cfg.sourceKey}::${cfg.sourceDir}`; + const next = { + sourceKey: cfg.sourceKey, + sourceDir: cfg.sourceDir, + templateVariables: loadTemplateVariables(cfg.sourceDir), + docsIndex: buildDocsIndex(cfg.sourceDir), + }; + stateCache.set(cacheKey, next); + return next; +} + +function toInt(value, fallback) { + const num = Number.parseInt(value, 10); + return Number.isNaN(num) ? fallback : num; +} + +function containsCI(text, keyword) { + return String(text || "") + .toLowerCase() + .includes(String(keyword || "").toLowerCase()); +} + +function stripPrivateFields(sourceState, doc, includeContent = false) { + const result = { + id: doc.id, + path: doc.path, + title: doc.title, + summary: doc.summary, + product: doc.product, + topics: doc.topics, + features: doc.features, + headings: doc.headings, + frontMatter: doc.frontMatter, + frontMatterRaw: doc.frontMatterRaw, + updatedAt: doc.updatedAt, + }; + if (includeContent) { + result.content = loadDocContentByPath( + sourceState.sourceDir, + doc.path, + sourceState.templateVariables + ); + result.contentType = "text/markdown"; + } + return result; +} + +function searchDocs(sourceState, args = {}) { + const feature = args.feature; + const topic = args.topic; + const keyword = args.q; + const pathFilter = args.path; + const includeContent = args.includeContent === true; + const limit = Math.min(Math.max(toInt(args.limit, 20), 1), 100); + const offset = Math.max(toInt(args.offset, 0), 0); + + let rows = sourceState.docsIndex.docs; + + if (feature) { + rows = rows.filter((doc) => + doc.features.some((item) => item.toLowerCase() === String(feature).toLowerCase()) + ); + } + if (topic) { + rows = rows.filter((doc) => + doc.topics.some((item) => item.toLowerCase() === String(topic).toLowerCase()) + ); + } + if (pathFilter) { + rows = rows.filter((doc) => doc.path.toLowerCase() === String(pathFilter).toLowerCase()); + } + if (keyword) { + rows = rows.filter((doc) => { + return ( + containsCI(doc.path, keyword) || + containsCI(doc.title, keyword) || + containsCI(doc.summary, keyword) || + containsCI(doc._searchText || "", keyword) + ); + }); + } + + const total = rows.length; + const data = rows + .slice(offset, offset + limit) + .map((doc) => stripPrivateFields(sourceState, doc, includeContent)); + + return { + meta: { + total, + limit, + offset, + returned: data.length, + includeContent, + sourceKey: sourceState.sourceKey, + sourceDir: sourceState.sourceDir, + }, + data, + }; +} + +function getDocByPathOrId(sourceState, args = {}) { + const docPath = args.path; + const docId = args.id; + if (!docPath && !docId) { + throw new Error("Either path or id is required."); + } + const doc = sourceState.docsIndex.docs.find((item) => { + if (docPath && item.path.toLowerCase() === String(docPath).toLowerCase()) return true; + if (docId && item.id.toLowerCase() === String(docId).toLowerCase()) return true; + return false; + }); + if (!doc) throw new Error("Document not found."); + return stripPrivateFields(sourceState, doc, true); +} + +function listFeatures(sourceState, args = {}) { + const prefix = String(args.prefix || ""); + if (!prefix) return sourceState.docsIndex.features; + return sourceState.docsIndex.features.filter((item) => + item.toLowerCase().startsWith(prefix.toLowerCase()) + ); +} + +function getResourceByUri(sourceState, uri) { + if (uri === "docs://schema") { + return { + uri, + mimeType: "application/json", + text: JSON.stringify(docsApiSchema, null, 2), + }; + } + if (uri === "docs://index/meta") { + return { + uri, + mimeType: "application/json", + text: JSON.stringify( + { + schemaVersion: sourceState.docsIndex.schemaVersion, + generatedAt: sourceState.docsIndex.generatedAt, + totalDocs: sourceState.docsIndex.totalDocs, + totalTopics: sourceState.docsIndex.topics.length, + totalFeatures: sourceState.docsIndex.features.length, + sourceKey: sourceState.sourceKey, + sourceDir: sourceState.sourceDir, + }, + null, + 2 + ), + }; + } + if (uri.startsWith("docs://doc/")) { + const rawPath = decodeURIComponent(uri.replace("docs://doc/", "")); + const content = loadDocContentByPath( + sourceState.sourceDir, + rawPath, + sourceState.templateVariables + ); + return { + uri, + mimeType: "text/markdown", + text: content, + }; + } + throw new Error(`Unsupported resource URI: ${uri}`); +} + +function buildResourceList(sourceState) { + return [ + { + uri: "docs://schema", + name: "Docs API Schema", + description: "Schema and endpoint model for docs capabilities.", + mimeType: "application/json", + }, + { + uri: "docs://index/meta", + name: "Docs Index Meta", + description: "Index metadata such as counts and generated timestamp.", + mimeType: "application/json", + }, + ...sourceState.docsIndex.docs.map((doc) => ({ + uri: `docs://doc/${encodeURIComponent(doc.path)}`, + name: doc.title, + description: doc.path, + mimeType: "text/markdown", + })), + ]; +} + +const TOOL_DEFS = [ + { + name: "search_docs", + description: "Search TiDB docs by feature/topic/path/full-text. Returns lightweight records by default.", + inputSchema: { + type: "object", + properties: { + feature: { type: "string" }, + topic: { type: "string" }, + q: { type: "string" }, + path: { type: "string" }, + limit: { type: "integer", minimum: 1, maximum: 100 }, + offset: { type: "integer", minimum: 0 }, + includeContent: { type: "boolean", default: false }, + }, + additionalProperties: false, + }, + }, + { + name: "get_doc_content", + description: "Get full markdown content by document path or id.", + inputSchema: { + type: "object", + properties: { + path: { type: "string" }, + id: { type: "string" }, + }, + additionalProperties: false, + }, + }, + { + name: "list_topics", + description: "List all available topics/categories in the docs index.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + { + name: "list_features", + description: "List all recognized feature tokens, optionally filtered by prefix.", + inputSchema: { + type: "object", + properties: { + prefix: { type: "string" }, + }, + additionalProperties: false, + }, + }, + { + name: "reload_docs_index", + description: "Reload docs index from disk (use after docs update).", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, +]; + +function textResult(payload) { + return { + content: [ + { + type: "text", + text: JSON.stringify(payload, null, 2), + }, + ], + }; +} + +function buildHandlers(sourceState) { + return { + initialize: (params) => ({ + protocolVersion: PROTOCOL_VERSION, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + capabilities: { + tools: {}, + resources: {}, + }, + instructions: + "Use search_docs for discovery and get_doc_content for full markdown. Prefer lightweight responses unless full content is required.", + clientInfo: params?.clientInfo || null, + }), + "notifications/initialized": () => null, + "tools/list": () => ({ + tools: TOOL_DEFS, + }), + "tools/call": (params) => { + const name = params?.name; + const args = params?.arguments || {}; + if (name === "search_docs") return textResult(searchDocs(sourceState, args)); + if (name === "get_doc_content") + return textResult({ data: getDocByPathOrId(sourceState, args) }); + if (name === "list_topics") return textResult({ data: sourceState.docsIndex.topics }); + if (name === "list_features") return textResult({ data: listFeatures(sourceState, args) }); + if (name === "reload_docs_index") { + const refreshed = refreshSourceState(sourceState.sourceKey); + return textResult({ + ok: true, + totalDocs: refreshed.docsIndex.totalDocs, + generatedAt: refreshed.docsIndex.generatedAt, + sourceKey: refreshed.sourceKey, + sourceDir: refreshed.sourceDir, + }); + } + throw new Error(`Unknown tool: ${name}`); + }, + "resources/list": () => ({ + resources: buildResourceList(sourceState), + }), + "resources/read": (params) => ({ + contents: [getResourceByUri(sourceState, params?.uri)], + }), + ping: () => ({}), + }; +} + +function processRpcMessage(msg, sourceKey) { + const sourceState = getSourceState(sourceKey); + const handlers = buildHandlers(sourceState); + + if (msg.jsonrpc !== "2.0") { + return { + jsonrpc: "2.0", + id: msg.id ?? null, + error: { + code: -32600, + message: "Invalid Request", + }, + }; + } + + const method = msg.method; + const handler = handlers[method]; + if (!handler) { + return { + jsonrpc: "2.0", + id: msg.id ?? null, + error: { + code: -32601, + message: "Method not found", + }, + }; + } + + try { + const result = handler(msg.params); + if (msg.id === undefined || method === "notifications/initialized") { + return null; + } + return { + jsonrpc: "2.0", + id: msg.id, + result: result ?? {}, + }; + } catch (error) { + return { + jsonrpc: "2.0", + id: msg.id ?? null, + error: { + code: -32000, + message: String(error.message || error), + }, + }; + } +} + +function validateAuth(headers) { + if (!AUTH_TOKEN) return true; + const raw = headers.authorization || ""; + if (!raw.toLowerCase().startsWith("bearer ")) return false; + const token = raw.slice(7).trim(); + return token === AUTH_TOKEN; +} + +function parseBodyJson(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + try { + const body = Buffer.concat(chunks).toString("utf8"); + resolve(body ? JSON.parse(body) : {}); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); +} + +function startHttpServer() { + const server = http.createServer(async (req, res) => { + if (req.url === "/healthz" && req.method === "GET") { + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ ok: true })); + return; + } + + if (req.url !== "/mcp" || req.method !== "POST") { + res.writeHead(404, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Not found" })); + return; + } + + if (!validateAuth(req.headers)) { + res.writeHead(401, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + + const sourceKey = (req.headers["x-docs-source"] || "default").toString(); + + try { + const json = await parseBodyJson(req); + const response = processRpcMessage(json, sourceKey); + if (!response) { + res.writeHead(204); + res.end(); + return; + } + res.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(response)); + } catch (error) { + res.writeHead(400, { "content-type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + id: null, + error: { + code: -32700, + message: `Parse error: ${String(error.message || error)}`, + }, + }) + ); + } + }); + + server.listen(HTTP_PORT, HTTP_HOST, () => { + process.stderr.write( + `[${SERVER_NAME}] http ready at http://${HTTP_HOST}:${HTTP_PORT}/mcp (defaultSource=${DEFAULT_SOURCE_DIR})\n` + ); + }); +} + +function startStdioServer() { + let inputBuffer = Buffer.alloc(0); + + const writeMessage = (message) => { + const json = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n`; + process.stdout.write(header + json); + }; + + const parseMessages = () => { + while (true) { + const separator = inputBuffer.indexOf("\r\n\r\n"); + if (separator === -1) return; + + const headerRaw = inputBuffer.slice(0, separator).toString("utf8"); + const lengthLine = headerRaw + .split("\r\n") + .find((line) => line.toLowerCase().startsWith("content-length:")); + if (!lengthLine) { + inputBuffer = Buffer.alloc(0); + return; + } + const length = Number.parseInt(lengthLine.split(":")[1]?.trim() || "0", 10); + const bodyStart = separator + 4; + const bodyEnd = bodyStart + length; + if (inputBuffer.length < bodyEnd) return; + + const body = inputBuffer.slice(bodyStart, bodyEnd).toString("utf8"); + inputBuffer = inputBuffer.slice(bodyEnd); + + let msg; + try { + msg = JSON.parse(body); + } catch (_error) { + writeMessage({ + jsonrpc: "2.0", + id: null, + error: { code: -32700, message: "Parse error" }, + }); + continue; + } + const response = processRpcMessage(msg, "default"); + if (response) writeMessage(response); + } + }; + + process.stdin.on("data", (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, chunk]); + parseMessages(); + }); + + process.stdin.on("end", () => process.exit(0)); + process.stderr.write( + `[${SERVER_NAME}] stdio ready (defaultSource=${DEFAULT_SOURCE_DIR})\n` + ); +} + +if (TRANSPORT === "http") { + startHttpServer(); +} else { + startStdioServer(); +} +