UNPKG

@apideck/docs-mcp

Version:

MCP server that lets agents search and retrieve your documentation at query time instead of relying on stale training data.

211 lines • 7.89 kB
#!/usr/bin/env node // SPDX-License-Identifier: MIT /** * docs-mcp CLI. * * start — MCP over stdio (for Claude Code, Cursor, Claude Desktop, ...). * serve — MCP over Streamable HTTP on a local port (same handler as the Vercel function). * audit — check the docs are complete, current and well structured before exposing them. * search — run a query against the index from the terminal. * * No shebang in source: tsc strips it; scripts/postbuild.mjs adds it to dist/bin/docs-mcp.js. */ import { realpathSync } from 'node:fs'; import http from 'node:http'; import { fileURLToPath } from 'node:url'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import { buildApplication, buildCommand, buildRouteMap, numberParser, run } from '@stricli/core'; import { createHandler } from '../api/mcp.js'; import { auditDocs, formatAuditReport } from '../src/audit.js'; import { createStore, resolveConfig } from '../src/config.js'; import { createServer } from '../src/server.js'; const log = (msg) => { process.stderr.write(`docs-mcp: ${msg}\n`); }; const commonFlags = { docs: { kind: 'parsed', parse: String, brief: 'Directory containing the markdown docs (default: $DOCS_DIR or ./docs)', optional: true, }, 'base-url': { kind: 'parsed', parse: String, brief: 'Public site root used to build page URLs, e.g. https://docs.example.com (default: $DOCS_BASE_URL)', optional: true, }, about: { kind: 'parsed', parse: String, brief: 'One-line description of the docs, shown to agents (default: $DOCS_ABOUT)', optional: true, }, name: { kind: 'parsed', parse: String, brief: 'Server name reported to clients (default: $DOCS_NAME or docs-mcp)', optional: true, }, }; const applyFlags = (flags) => { if (flags.docs !== undefined) process.env['DOCS_DIR'] = flags.docs; if (flags['base-url'] !== undefined) process.env['DOCS_BASE_URL'] = flags['base-url']; if (flags.about !== undefined) process.env['DOCS_ABOUT'] = flags.about; if (flags.name !== undefined) process.env['DOCS_NAME'] = flags.name; }; const loadStore = async (flags) => { applyFlags(flags); const config = resolveConfig(); const store = createStore(config); await store.load(); return { config, store }; }; const startCommand = buildCommand({ func: async (flags) => { const { config, store } = await loadStore(flags); const stats = store.stats(); log(`indexed ${stats.pages} pages / ${stats.sections} sections from ${config.docsDir}`); const stop = flags.watch ? store.watch((err) => { if (err) log(`reindex failed: ${err.message}`); else log(`reindexed: ${store.stats().pages} pages`); }) : () => undefined; const server = createServer({ store, name: config.name, ...(config.about !== undefined ? { about: config.about } : {}), }); const transport = new StdioServerTransport(); const shutdown = () => { stop(); void server.close().finally(() => process.exit(0)); }; process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); await server.connect(transport); }, parameters: { flags: { ...commonFlags, watch: { kind: 'boolean', brief: 'Re-index when files under the docs directory change', default: true, }, }, }, docs: { brief: 'Run the MCP server over stdio' }, }); const serveCommand = buildCommand({ func: async (flags) => { const { config, store } = await loadStore(flags); log(`indexed ${store.stats().pages} pages from ${config.docsDir}`); if (flags.watch) { store.watch((err) => { if (err) log(`reindex failed: ${err.message}`); else log(`reindexed: ${store.stats().pages} pages`); }); } const httpHandler = createHandler({ store }); const server = http.createServer((req, res) => { void httpHandler(req, res); }); server.listen(flags.port, () => { log(`MCP endpoint: http://localhost:${flags.port}/mcp`); }); }, parameters: { flags: { ...commonFlags, port: { kind: 'parsed', parse: numberParser, brief: 'Port to listen on', default: '3000' }, watch: { kind: 'boolean', brief: 'Re-index when files under the docs directory change', default: true, }, }, }, docs: { brief: 'Run the MCP server over Streamable HTTP' }, }); const auditCommand = buildCommand({ func: async (flags) => { const { store } = await loadStore(flags); const report = auditDocs(store, { staleDays: flags['stale-days'], minWords: flags['min-words'], maxSectionWords: flags['max-section-words'], }); process.stdout.write(`${flags.json ? JSON.stringify(report, null, 2) : formatAuditReport(report)}\n`); process.exitCode = report.summary.errors > 0 ? 1 : 0; }, parameters: { flags: { ...commonFlags, 'stale-days': { kind: 'parsed', parse: numberParser, brief: 'Flag pages older than this', default: '180' }, 'min-words': { kind: 'parsed', parse: numberParser, brief: 'Flag pages shorter than this', default: '50' }, 'max-section-words': { kind: 'parsed', parse: numberParser, brief: 'Flag sections longer than this with no sub-headings', default: '1500', }, json: { kind: 'boolean', brief: 'Print the report as JSON', default: false }, }, }, docs: { brief: 'Audit the docs for completeness, freshness and structure (exit 1 on errors)' }, }); const searchCommand = buildCommand({ func: async (flags, query) => { const { store } = await loadStore(flags); const hits = store.search(query, { limit: flags.limit }); if (hits.length === 0) { process.stdout.write(`No results for "${query}"\n`); return; } for (const h of hits) { const ref = h.anchor ? `${h.path}#${h.anchor}` : h.path; process.stdout.write(`${h.score.toFixed(2)} ${ref}\n ${h.title}${h.heading ? ` › ${h.heading}` : ''}\n ${h.snippet}\n\n`); } }, parameters: { flags: { ...commonFlags, limit: { kind: 'parsed', parse: numberParser, brief: 'Max results', default: '8' }, }, positional: { kind: 'tuple', parameters: [{ brief: 'Search query', parse: String, placeholder: 'query' }], }, }, docs: { brief: 'Search the docs from the terminal' }, }); const app = buildApplication(buildRouteMap({ routes: { start: startCommand, serve: serveCommand, audit: auditCommand, search: searchCommand }, docs: { brief: 'MCP server that lets agents search and read your docs at query time' }, }), { name: 'docs-mcp' }); export const runCli = (argv = process.argv.slice(2)) => run(app, argv, { process: process }); export const isCliEntrypoint = () => { const entry = process.argv[1]; if (!entry) return false; try { return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } }; if (isCliEntrypoint()) { void runCli(); } //# sourceMappingURL=docs-mcp.js.map