@writedocs/generator 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/astro.config.mjs CHANGED
@@ -36,6 +36,7 @@ import { remarkUnknownComponentFallback } from './src/lib/mdx-unknown-components
36
36
  import { remarkExtractInlineReactComponents } from './src/lib/mdx-inline-react.js';
37
37
  import { writedocsTempDir, writedocsBuildStagingDir } from './src/lib/writedocs-temp-dir.js';
38
38
  import { stylesAssetFallback } from './src/lib/styles-asset-integration.js';
39
+ import { mcpDevServer } from './src/lib/mcp-dev-integration.js';
39
40
  import { report } from './src/lib/cli-report.js';
40
41
  import {
41
42
  loadDocsConfig,
@@ -309,6 +310,9 @@ export default defineConfig({
309
310
  // field list and styles-asset-integration.js for how it's actually
310
311
  // served/copied.
311
312
  stylesAssetFallback(contentDir),
313
+ // /mcp in `writedocs dev`, as the build's dist/_worker.js serves it -
314
+ // see src/lib/mcp-dev-integration.js.
315
+ mcpDevServer({ version: JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8')).version }),
312
316
  ],
313
317
  output: 'static',
314
318
  // Dual Shiki themes for fenced code blocks (```) in MDX content, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@writedocs/generator",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "Static site generator for docs — a writedocs.json + MDX folder in, a static site out.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "exports": {
10
10
  "./components": "./src/components/index.ts",
11
11
  "./config-schema": "./src/lib/config-schema.js",
12
- "./writedocs.schema.json": "./writedocs.schema.json"
12
+ "./writedocs.schema.json": "./writedocs.schema.json",
13
+ "./mcp": "./src/mcp/server.js"
13
14
  },
14
15
  "files": [
15
16
  "bin",
package/src/cli/build.js CHANGED
@@ -5,6 +5,7 @@ import { runPagefind } from './run-pagefind.js';
5
5
  import { preflightCheck, sameDriveCheck, writableInstallCheck } from './preflight.js';
6
6
  import { generateApiPages } from './generate-api-pages.js';
7
7
  import { writeRedirectsFile } from './write-redirects-file.js';
8
+ import { writeMcpFiles } from './write-mcp-files.js';
8
9
  import { writedocsBuildStagingDir } from '../lib/writedocs-temp-dir.js';
9
10
  import { log, step, plural, duration, displayPath, formatProblems, color, CliExit } from './output.js';
10
11
  import { describeError, builtRoute, authorWarning, verboseLine } from './astro-output.js';
@@ -112,6 +113,13 @@ export async function runBuild({ contentDir, packageRoot, verbose = false }) {
112
113
  // - this turns those into real instant edge redirects on hosts that read
113
114
  // a `_redirects` file (Cloudflare Pages, Netlify), purely additively.
114
115
  writeRedirectsFile(distDir, contentDir);
116
+ // The site's MCP server at /mcp: mcp-index.json is already in dist/ (an
117
+ // Astro route); this adds the Cloudflare _worker.js that serves it - see
118
+ // write-mcp-files.js for every host.
119
+ const { version: writedocsVersion } = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf-8'));
120
+ const mcp = writeMcpFiles(distDir, contentDir, writedocsVersion);
121
+ if (mcp.written.length) notes.push('MCP server at /mcp - dist/_worker.js runs it on Cloudflare; mcp-index.json holds the pages.');
122
+ if (mcp.skipped) addWarning(null, `No _worker.js for the MCP server: ${mcp.skipped}.`);
115
123
 
116
124
  const indexing = step('Indexing search');
117
125
  try {
@@ -0,0 +1,96 @@
1
+ // After `writedocs build`: the files that make dist/ serve the site's MCP
2
+ // server at /mcp on Cloudflare, with no setup of the host's own - next to
3
+ // mcp-index.json, which the build itself writes (src/pages/[mcpIndex].json.ts).
4
+ //
5
+ // _worker.js the MCP server (src/mcp/server.js, inlined - no imports)
6
+ // plus a small fetch handler: /mcp goes to the server,
7
+ // everything else to the static files (env.ASSETS).
8
+ // - Cloudflare Pages runs it on its own ("advanced mode").
9
+ // - Cloudflare Workers with static assets: point `main` at
10
+ // dist/_worker.js, assets at dist/ with an ASSETS binding.
11
+ // _routes.json Pages: only /mcp runs the worker; every other path stays a
12
+ // plain static file (and `_redirects` keeps applying).
13
+ // .assetsignore Workers: don't publish _worker.js / _routes.json as files.
14
+ //
15
+ // Anywhere else - Netlify, Vercel, the WriteDocs platform's serving Worker -
16
+ // import handleMcpHttp from `@writedocs/generator/mcp` and give it
17
+ // mcp-index.json; a purely static host (GitHub Pages, S3) can't run /mcp.
18
+ //
19
+ // A project that brings its own _worker.js or _routes.json (in public/), or a
20
+ // Pages `functions/` folder (which Pages ignores once a _worker.js exists),
21
+ // keeps them: the worker isn't written, and the build says so.
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const SERVER_SOURCE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'mcp', 'server.js');
27
+ const IGNORED_BY_WORKERS = ['_worker.js', '_routes.json'];
28
+
29
+ /** The `_worker.js` source: server.js without its `export`s, and the fetch
30
+ * handler. `version` is the writedocs version, reported by the server. */
31
+ export function mcpWorkerSource(version) {
32
+ const server = fs.readFileSync(SERVER_SOURCE, 'utf-8').replace(/^export (?=(?:const|let|async function|function) )/gm, '');
33
+ return `// Generated by writedocs ${version} - the site's MCP server at /mcp.
34
+ // Cloudflare Pages runs this file on its own; on Cloudflare Workers, use it as
35
+ // \`main\` with dist/ as the static assets (binding ASSETS). See
36
+ // https://github.com/writedocs/writedocs (src/cli/write-mcp-files.js).
37
+
38
+ ${server}
39
+ const WRITEDOCS_VERSION = ${JSON.stringify(version)};
40
+
41
+ // The page index, read once per isolate from the site's own static files.
42
+ let indexPromise = null;
43
+ function loadIndex(request, env) {
44
+ if (!indexPromise) {
45
+ indexPromise = env.ASSETS.fetch(new URL('/mcp-index.json', request.url))
46
+ .then((res) => {
47
+ if (!res.ok) throw new Error('mcp-index.json: HTTP ' + res.status);
48
+ return res.json();
49
+ })
50
+ .catch((err) => {
51
+ indexPromise = null;
52
+ throw err;
53
+ });
54
+ }
55
+ return indexPromise;
56
+ }
57
+
58
+ export default {
59
+ async fetch(request, env) {
60
+ const { pathname } = new URL(request.url);
61
+ if (pathname === '/mcp' || pathname === '/mcp/') {
62
+ return handleMcpHttp(request, { loadIndex: () => loadIndex(request, env), version: WRITEDOCS_VERSION });
63
+ }
64
+ return env.ASSETS.fetch(request);
65
+ },
66
+ };
67
+ `;
68
+ }
69
+
70
+ /** Writes the files into `distDir`. Returns { written: [names], skipped:
71
+ * reason or null } - nothing at all when there's no mcp-index.json
72
+ * (writedocs.json `"mcp": false`). */
73
+ export function writeMcpFiles(distDir, contentDir, version) {
74
+ if (!fs.existsSync(path.join(distDir, 'mcp-index.json'))) return { written: [], skipped: null };
75
+
76
+ const own = ['_worker.js', '_routes.json'].filter((name) => fs.existsSync(path.join(distDir, name)));
77
+ if (own.length) {
78
+ return { written: [], skipped: `the project has its own ${own.join(' and ')} - /mcp needs its handler added there (see @writedocs/generator/mcp)` };
79
+ }
80
+ if (fs.existsSync(path.join(contentDir, 'functions'))) {
81
+ return { written: [], skipped: 'the project has a Cloudflare Pages functions/ folder, which a _worker.js would switch off - add /mcp there with @writedocs/generator/mcp' };
82
+ }
83
+
84
+ fs.writeFileSync(path.join(distDir, '_worker.js'), mcpWorkerSource(version));
85
+ fs.writeFileSync(path.join(distDir, '_routes.json'), JSON.stringify({ version: 1, include: ['/mcp', '/mcp/'], exclude: [] }, null, 2) + '\n');
86
+
87
+ const ignorePath = path.join(distDir, '.assetsignore');
88
+ const existing = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, 'utf-8') : '';
89
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
90
+ const missing = IGNORED_BY_WORKERS.filter((name) => !lines.includes(name));
91
+ if (missing.length) {
92
+ const prefix = existing && !existing.endsWith('\n') ? `${existing}\n` : existing;
93
+ fs.writeFileSync(ignorePath, `${prefix}${missing.join('\n')}\n`);
94
+ }
95
+ return { written: ['_worker.js', '_routes.json', '.assetsignore'], skipped: null };
96
+ }
@@ -54,9 +54,11 @@ function mapOutsideInlineCode(text, transform) {
54
54
  }
55
55
 
56
56
  const PLACEHOLDER = /\[\[\s*([\w.-]+)\s*\]\]/g;
57
- // A standalone `{key}` expression - not an attribute value (`prop={key}`),
58
- // which the build leaves alone too.
59
- const MINTLIFY_PLACEHOLDER = /(?<!=\s*)\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
57
+ // A standalone `{key}` or `{{key}}` expression (Mintlify writes the latter;
58
+ // MDX reads it as `{key}` inside an expression) - not an attribute value
59
+ // (`prop={key}`), which the build leaves alone too. The double form first,
60
+ // or its outer braces would be left around the value.
61
+ const MINTLIFY_PLACEHOLDER = /(?<!=\s*)\{\s*\{\s*([A-Za-z_$][\w$]*)\s*\}\s*\}|(?<!=\s*)\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
60
62
 
61
63
  /** writedocs.json `variables` - `[[key]]`, and Mintlify's `{key}` - replaced
62
64
  * the way the build replaces them (lib/mdx-substitute-variables.js): outside
@@ -67,7 +69,10 @@ export function substituteVariables(text, variables) {
67
69
  return mapOutsideCode(text, (prose) =>
68
70
  prose
69
71
  .replace(PLACEHOLDER, (match, key) => (has(key) ? String(variables[key]) : match))
70
- .replace(MINTLIFY_PLACEHOLDER, (match, key) => (has(key) ? String(variables[key]) : match))
72
+ .replace(MINTLIFY_PLACEHOLDER, (match, doubled, single) => {
73
+ const key = doubled ?? single;
74
+ return has(key) ? String(variables[key]) : match;
75
+ })
71
76
  );
72
77
  }
73
78
 
@@ -522,6 +522,11 @@ const docsConfigSchema = z.object({
522
522
  // The "Copy page" dropdown - see contextMenuSchema above. Absent by
523
523
  // default (no menu, no .md routes).
524
524
  contextMenu: contextMenuSchema.optional(),
525
+ // The site's MCP server - an AI tool connects to `/mcp` and searches and
526
+ // reads the docs. On by default: `writedocs build` writes the page index
527
+ // (mcp-index.json) and a Cloudflare-ready `_worker.js` into dist/ (see
528
+ // src/cli/write-mcp-files.js). `false` leaves all of it out.
529
+ mcp: z.boolean().default(true),
525
530
  // See redirectSchema above. Wired directly into Astro's own `redirects`
526
531
  // config option in astro.config.mjs.
527
532
  redirects: z.array(redirectSchema).default([]),
@@ -999,6 +999,11 @@ export const docsConfigSchema = z.object({
999
999
  // The "Copy page" dropdown - see contextMenuSchema above. Absent by
1000
1000
  // default (no menu, no .md routes).
1001
1001
  contextMenu: contextMenuSchema.optional(),
1002
+ // The site's MCP server - an AI tool connects to `/mcp` and searches and
1003
+ // reads the docs. On by default: `writedocs build` writes the page index
1004
+ // (mcp-index.json) and a Cloudflare-ready `_worker.js` into dist/ (see
1005
+ // src/cli/write-mcp-files.js). `false` leaves all of it out.
1006
+ mcp: z.boolean().default(true),
1002
1007
  // See redirectSchema above. Wired directly into Astro's own `redirects`
1003
1008
  // config option in astro.config.mjs.
1004
1009
  redirects: z.array(redirectSchema).default([]),
@@ -166,6 +166,7 @@ export const DESCRIPTIONS = {
166
166
  'seo.noindex': 'Ask search engines not to index pages, and leave them out of sitemap.xml.',
167
167
  contextMenu: 'Adds a "Copy page" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + ".md".',
168
168
  'contextMenu.openIn': 'Which AI assistants the menu offers. Default: all three.',
169
+ mcp: 'An MCP server at /mcp, so AI tools can search and read the docs. The build adds its index and a Cloudflare-ready _worker.js to dist/. Default true; false leaves them out.',
169
170
  redirects: 'Redirects from old addresses. Each matches one exact path.',
170
171
  'redirects[].source': 'The old path, like "/old-page".',
171
172
  'redirects[].destination': 'Where to send it, like "/docs/new-page/".',
@@ -0,0 +1,60 @@
1
+ // `writedocs dev`: the site's MCP server at /mcp, as the build serves it - the
2
+ // same handler (src/mcp/server.js), answering from the dev server's own
3
+ // /mcp-index.json, so an edit shows up on the next request. Lets an author
4
+ // point an MCP client at http://localhost:4321/mcp before deploying.
5
+ import { handleMcpHttp } from '../mcp/server.js';
6
+
7
+ function readBody(req) {
8
+ return new Promise((resolve, reject) => {
9
+ const chunks = [];
10
+ req.on('data', (chunk) => chunks.push(chunk));
11
+ req.on('end', () => resolve(Buffer.concat(chunks)));
12
+ req.on('error', reject);
13
+ });
14
+ }
15
+
16
+ export function mcpDevServer({ version }) {
17
+ return {
18
+ name: 'writedocs-mcp-dev',
19
+ hooks: {
20
+ 'astro:server:setup': ({ server }) => {
21
+ server.middlewares.use(async (req, res, next) => {
22
+ const pathname = (req.url ?? '').split('?')[0];
23
+ if (pathname !== '/mcp' && pathname !== '/mcp/') return next();
24
+ try {
25
+ const origin = `http://${req.headers.host}`;
26
+ const headers = new Headers();
27
+ for (const [key, value] of Object.entries(req.headers)) {
28
+ if (typeof value === 'string') headers.set(key, value);
29
+ }
30
+ const body = req.method === 'POST' ? await readBody(req) : undefined;
31
+ const request = new Request(origin + req.url, { method: req.method, headers, body });
32
+ let missing = false;
33
+ const response = await handleMcpHttp(request, {
34
+ version,
35
+ loadIndex: async () => {
36
+ const index = await fetch(`${origin}/mcp-index.json`);
37
+ if (!index.ok) {
38
+ missing = true;
39
+ return { meta: {}, docs: [] };
40
+ }
41
+ return index.json();
42
+ },
43
+ });
44
+ if (missing) {
45
+ res.statusCode = 404;
46
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
47
+ res.end('No MCP server: writedocs.json has "mcp": false.');
48
+ return;
49
+ }
50
+ res.statusCode = response.status;
51
+ response.headers.forEach((value, key) => res.setHeader(key, value));
52
+ res.end(Buffer.from(await response.arrayBuffer()));
53
+ } catch (err) {
54
+ next(err);
55
+ }
56
+ });
57
+ },
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,101 @@
1
+ // mcp-index.json - everything the site's MCP server (src/mcp/server.js)
2
+ // answers from: every page an agent may read, as it should read it. Built
3
+ // from the same pipeline as llms-full.txt (markdownForAgents(): snippets
4
+ // inlined, `variables` filled in, <Visibility> for agents), in navigation
5
+ // order, with each page's real URL and the version/language it sits under in
6
+ // the navigation - what search_docs filters on.
7
+ import { getCollection, type CollectionEntry } from 'astro:content';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import {
11
+ loadDocsConfig,
12
+ normalizeEntryId,
13
+ findAllPages,
14
+ resolveSiteUrl,
15
+ fileIdForEntry,
16
+ resolveSections,
17
+ flattenNav,
18
+ } from './config';
19
+ import { markdownForAgents } from './agent-markdown.js';
20
+ import { orderByNavigation } from './llms.js';
21
+ import { writedocsTempDir } from './writedocs-temp-dir.js';
22
+
23
+ type DocsEntry = CollectionEntry<'pages'> | CollectionEntry<'generatedDocs'>;
24
+
25
+ export interface McpDoc {
26
+ path: string;
27
+ url: string;
28
+ title: string;
29
+ description: string;
30
+ content: string;
31
+ version: string;
32
+ language: string;
33
+ }
34
+
35
+ export interface McpIndex {
36
+ meta: { name: string; description: string; siteUrl: string; generator: 'writedocs' };
37
+ docs: McpDoc[];
38
+ }
39
+
40
+ export async function mcpIndex(): Promise<McpIndex> {
41
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
42
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT || process.cwd();
43
+ const config = loadDocsConfig(contentDir);
44
+ const siteUrl = resolveSiteUrl(config) ?? '';
45
+
46
+ const hasGeneratedDocs = fs.existsSync(path.join(writedocsTempDir(contentDir), 'generated-docs'));
47
+ const hasPages = findAllPages(contentDir).length > 0;
48
+ const [pagesEntries, generatedDocsEntries] = await Promise.all([
49
+ hasPages ? getCollection('pages') : Promise.resolve([]),
50
+ hasGeneratedDocs ? getCollection('generatedDocs') : Promise.resolve([]),
51
+ ]);
52
+ const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
53
+
54
+ // The version and language each page sits under, from its section's path
55
+ // through the navigation (tabs > versions > languages > ...).
56
+ const sections = resolveSections(config.navigation);
57
+ const placeOf = (fileId: string) => {
58
+ const section = sections.find((s) => flattenNav(s.pages).some((e) => e.slug === fileId));
59
+ const place = { version: '', language: '' };
60
+ for (const segment of section?.path ?? []) {
61
+ const item = segment.items[segment.index] as { version?: string; language?: string };
62
+ if (segment.kind === 'version' && item.version) place.version = item.version;
63
+ if (segment.kind === 'language' && item.language) place.language = item.language;
64
+ }
65
+ return place;
66
+ };
67
+
68
+ const docs = entries
69
+ // Same pages llms.txt lists: not `noindex`, not a frontmatter `url`
70
+ // (an external link, with no content of its own).
71
+ .filter((entry) => !entry.data.seo?.noindex && !entry.data.url)
72
+ .map((entry) => {
73
+ const slug = normalizeEntryId(entry.id);
74
+ const fileId = fileIdForEntry(contentDir, packageRoot, entry);
75
+ let content = markdownForAgents(entry.body ?? '', {
76
+ file: entry.filePath ? path.resolve(packageRoot, entry.filePath) : undefined,
77
+ contentDir,
78
+ variables: config.variables,
79
+ }).trim();
80
+ // An OpenAPI operation page renders from the spec, so its source body
81
+ // is near-empty - name the operation, at least.
82
+ if (entry.data.openapi) content = [`Operation: ${entry.data.openapi}`, content].filter(Boolean).join('\n\n');
83
+ return {
84
+ slug,
85
+ fileId,
86
+ doc: {
87
+ path: fileId,
88
+ url: `${siteUrl}${slug === 'index' ? '/' : `/${slug}/`}`,
89
+ title: entry.data.title,
90
+ description: entry.data.description ?? '',
91
+ content,
92
+ ...placeOf(fileId),
93
+ },
94
+ };
95
+ });
96
+
97
+ return {
98
+ meta: { name: config.name, description: config.description ?? '', siteUrl, generator: 'writedocs' },
99
+ docs: orderByNavigation(docs, config.navigation).map((d) => d.doc),
100
+ };
101
+ }
@@ -0,0 +1,262 @@
1
+ // The site's MCP server - lets an AI tool (Claude, Cursor, ...) list, search
2
+ // and read the docs over the Model Context Protocol, at /mcp.
3
+ //
4
+ // Dependency-free and Web-standard (Request in, Response out), so the same
5
+ // code runs wherever the site does: the Cloudflare `_worker.js` that
6
+ // `writedocs build` writes into dist/ (src/cli/write-mcp-files.js - this file
7
+ // is inlined into it), and any other host or serving Worker that imports
8
+ // `@writedocs/generator/mcp` and calls handleMcpHttp() for /mcp. The docs
9
+ // themselves come from the site's mcp-index.json (src/lib/mcp-index.ts),
10
+ // which the build writes next to the pages.
11
+ //
12
+ // Streamable HTTP transport, JSON responses only (no server-sent events),
13
+ // which every protocol version below allows. Keep this file free of imports:
14
+ // the Cloudflare worker inlines its source as-is.
15
+
16
+ // MCP protocol versions this server speaks, newest first.
17
+ export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
18
+
19
+ export const TOOLS = [
20
+ {
21
+ name: 'list_docs',
22
+ description: 'List every documentation page with its title, path, URL and description.',
23
+ inputSchema: { type: 'object', properties: {} },
24
+ },
25
+ {
26
+ name: 'get_doc',
27
+ description:
28
+ 'Get the full content of one or more documentation pages, as Markdown. Pass a path or URL from list_docs or search_docs (the page URL works with or without its site address), or an array of them for a batch read.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ path: {
33
+ oneOf: [
34
+ { type: 'string', description: 'A page path or URL, e.g. "docs/quickstart" or "/docs/quickstart/"' },
35
+ { type: 'array', items: { type: 'string' }, description: 'Several page paths or URLs' },
36
+ ],
37
+ },
38
+ },
39
+ required: ['path'],
40
+ },
41
+ },
42
+ {
43
+ name: 'search_docs',
44
+ description:
45
+ 'Search the documentation by keywords, returning the best-matching pages with a snippet each. Follow up with get_doc to read a page in full.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ query: { type: 'string', description: 'Search terms' },
50
+ limit: { type: 'number', description: 'Max results (default 5, max 20)' },
51
+ version: { type: 'string', description: 'Only pages of this docs version' },
52
+ language: { type: 'string', description: 'Only pages in this language' },
53
+ },
54
+ required: ['query'],
55
+ },
56
+ },
57
+ ];
58
+
59
+ const CORS = {
60
+ 'Access-Control-Allow-Origin': '*',
61
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
62
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Protocol-Version, Mcp-Session-Id',
63
+ };
64
+
65
+ const json = (body, status = 200, headers = {}) =>
66
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...CORS, ...headers } });
67
+
68
+ /**
69
+ * Handles one HTTP request to /mcp. `loadIndex()` returns (or resolves to) the
70
+ * site's mcp-index.json, parsed; `version` is reported as the server's.
71
+ *
72
+ * POST JSON-RPC - one message or a batch
73
+ * GET an MCP client asking for an event stream (Accept:
74
+ * text/event-stream) gets 405, as the spec says; anyone else (a
75
+ * browser) gets a description of the server
76
+ * OPTIONS CORS preflight
77
+ */
78
+ export async function handleMcpHttp(request, { loadIndex, version = '' } = {}) {
79
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS });
80
+ if (request.method === 'GET') {
81
+ if ((request.headers.get('accept') ?? '').includes('text/event-stream')) {
82
+ return new Response(null, { status: 405, headers: { Allow: 'POST, OPTIONS', ...CORS } });
83
+ }
84
+ const index = await loadIndex();
85
+ return json({
86
+ name: index.meta?.name ?? 'docs',
87
+ description: 'An MCP server for these docs. Connect an MCP client to this URL (Streamable HTTP).',
88
+ server: { name: 'writedocs', version },
89
+ protocolVersions: PROTOCOL_VERSIONS,
90
+ pages: index.docs?.length ?? 0,
91
+ tools: TOOLS.map((t) => ({ name: t.name, description: t.description })),
92
+ });
93
+ }
94
+ if (request.method !== 'POST') return new Response(null, { status: 405, headers: { Allow: 'GET, POST, OPTIONS', ...CORS } });
95
+
96
+ let body;
97
+ try {
98
+ body = await request.json();
99
+ } catch {
100
+ return json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, 400);
101
+ }
102
+ const index = await loadIndex();
103
+ if (Array.isArray(body)) {
104
+ const responses = body.map((message) => handleMcpMessage(message, index, { version })).filter(Boolean);
105
+ return responses.length ? json(responses) : new Response(null, { status: 202, headers: CORS });
106
+ }
107
+ const response = handleMcpMessage(body, index, { version });
108
+ return response ? json(response) : new Response(null, { status: 202, headers: CORS });
109
+ }
110
+
111
+ /** One JSON-RPC message -> its response, or null for a notification. */
112
+ export function handleMcpMessage(message, index, { version = '' } = {}) {
113
+ if (!message || typeof message !== 'object') {
114
+ return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };
115
+ }
116
+ const id = message.id ?? null;
117
+ if (message.id === undefined && String(message.method ?? '').startsWith('notifications/')) return null;
118
+
119
+ switch (message.method) {
120
+ case 'initialize': {
121
+ // The client's version when this server speaks it, else the newest one
122
+ // it does - the client then decides whether it can go on.
123
+ const requested = message.params?.protocolVersion;
124
+ return {
125
+ jsonrpc: '2.0',
126
+ id,
127
+ result: {
128
+ protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
129
+ capabilities: { tools: {} },
130
+ serverInfo: { name: 'writedocs', title: index.meta?.name, version },
131
+ instructions: index.meta?.name
132
+ ? `Documentation for ${index.meta.name}. Use search_docs to find pages, get_doc to read them.`
133
+ : undefined,
134
+ },
135
+ };
136
+ }
137
+ case 'ping':
138
+ return { jsonrpc: '2.0', id, result: {} };
139
+ case 'tools/list':
140
+ return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
141
+ case 'tools/call': {
142
+ const { name, arguments: args = {} } = message.params ?? {};
143
+ return { jsonrpc: '2.0', id, result: callTool(name, args ?? {}, index) };
144
+ }
145
+ default:
146
+ return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${message.method}` } };
147
+ }
148
+ }
149
+
150
+ const text = (value, isError = false) => ({ content: [{ type: 'text', text: value }], ...(isError ? { isError: true } : {}) });
151
+
152
+ function callTool(name, args, index) {
153
+ const docs = index.docs ?? [];
154
+ switch (name) {
155
+ case 'list_docs': {
156
+ if (!docs.length) return text('This site has no pages.');
157
+ return text(
158
+ docs
159
+ .map((d) => {
160
+ let line = `${d.path} - ${d.title} (${d.url})`;
161
+ if (d.description) line += `\n ${d.description}`;
162
+ const meta = [d.version, d.language].filter(Boolean).join(', ');
163
+ if (meta) line += `\n [${meta}]`;
164
+ return line;
165
+ })
166
+ .join('\n')
167
+ );
168
+ }
169
+
170
+ case 'get_doc': {
171
+ const paths = Array.isArray(args.path) ? args.path : [args.path];
172
+ const found = [];
173
+ const missing = [];
174
+ for (const p of paths) {
175
+ const doc = findDoc(docs, p);
176
+ if (!doc) {
177
+ missing.push(String(p ?? ''));
178
+ continue;
179
+ }
180
+ const parts = [`# ${doc.title}`, '', `URL: ${doc.url}`];
181
+ if (doc.description) parts.push('', `> ${doc.description}`);
182
+ parts.push('', doc.content);
183
+ found.push(parts.join('\n'));
184
+ }
185
+ const quoted = missing.map((p) => `"${p}"`).join(', ');
186
+ if (!found.length) return text(`No page found for ${quoted}. Use list_docs or search_docs to find page paths.`, true);
187
+ if (missing.length) found.push(`> No page found for ${quoted}.`);
188
+ return text(found.join('\n\n---\n\n'));
189
+ }
190
+
191
+ case 'search_docs': {
192
+ const query = String(args.query ?? '').trim();
193
+ if (!query) return text('The query is empty.', true);
194
+ // A limit that isn't a positive number (a model sent "abc", 0) falls
195
+ // back to the default instead of returning nothing.
196
+ const requested = Number(args.limit);
197
+ const limit = Number.isFinite(requested) && requested >= 1 ? Math.min(Math.floor(requested), 20) : 5;
198
+ let pool = docs;
199
+ if (args.version) pool = pool.filter((d) => d.version === String(args.version));
200
+ if (args.language) pool = pool.filter((d) => d.language === String(args.language));
201
+ const results = search(pool, query).slice(0, limit);
202
+ if (!results.length) return text(`No pages match "${query}".`);
203
+ return text(
204
+ results
205
+ .map((d) => {
206
+ const meta = [d.version, d.language].filter(Boolean).join(', ');
207
+ return `**${d.title}** (\`${d.path}\`) ${d.url}${meta ? ` [${meta}]` : ''}\n${d.description || snippet(d.content, query)}`;
208
+ })
209
+ .join('\n\n')
210
+ );
211
+ }
212
+
213
+ default:
214
+ return text(`Unknown tool: ${name}`, true);
215
+ }
216
+ }
217
+
218
+ /** The page a path points at, however it's written: its path ("docs/setup"),
219
+ * its URL with or without slashes, the full site URL, a ".md" address, or
220
+ * "/" for the home page. */
221
+ export function findDoc(docs, value) {
222
+ const raw = String(value ?? '').trim();
223
+ if (!raw) return undefined;
224
+ const exact = docs.find((d) => d.path === raw || d.url === raw);
225
+ if (exact) return exact;
226
+ const key = (s) => {
227
+ const p = String(s)
228
+ .replace(/^https?:\/\/[^/]+/i, '')
229
+ .replace(/[?#].*$/, '')
230
+ .replace(/\.mdx?$/i, '')
231
+ .replace(/^\/+|\/+$/g, '')
232
+ .replace(/(^|\/)index$/, '');
233
+ return p || 'index';
234
+ };
235
+ const wanted = key(raw);
236
+ return docs.find((d) => key(d.url) === wanted || key(d.path) === wanted);
237
+ }
238
+
239
+ /** Pages scored by how often the query's words occur - title matches count
240
+ * most - best first. */
241
+ function search(docs, query) {
242
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
243
+ const count = (haystack, word) => haystack.split(word).length - 1;
244
+ return docs
245
+ .map((doc) => {
246
+ const title = doc.title.toLowerCase();
247
+ const body = `${doc.description ?? ''} ${doc.content}`.toLowerCase();
248
+ const score = words.reduce((s, w) => s + count(title, w) * 5 + count(body, w), 0);
249
+ return { doc, score };
250
+ })
251
+ .filter((r) => r.score > 0)
252
+ .sort((a, b) => b.score - a.score)
253
+ .map((r) => r.doc);
254
+ }
255
+
256
+ function snippet(content, query) {
257
+ const word = query.toLowerCase().split(/\s+/)[0];
258
+ const at = content.toLowerCase().indexOf(word);
259
+ const start = Math.max(0, at - 60);
260
+ const end = Math.min(content.length, (at < 0 ? 0 : at) + 180);
261
+ return (start > 0 ? '...' : '') + content.slice(start, end).replace(/\s+/g, ' ').trim() + (end < content.length ? '...' : '');
262
+ }
@@ -0,0 +1,16 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { loadDocsConfig } from '../lib/config';
3
+ import { mcpIndex } from '../lib/mcp-index';
4
+
5
+ // /mcp-index.json - the pages the site's MCP server answers from (see
6
+ // lib/mcp-index.ts and src/mcp/server.js). A dynamic route only so it can be
7
+ // left out: writedocs.json `"mcp": false` gives no paths, so no file.
8
+ export async function getStaticPaths() {
9
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR || process.cwd();
10
+ return loadDocsConfig(contentDir).mcp ? [{ params: { mcpIndex: 'mcp-index' } }] : [];
11
+ }
12
+
13
+ export const GET: APIRoute = async () =>
14
+ new Response(JSON.stringify(await mcpIndex()), {
15
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
16
+ });
@@ -772,6 +772,12 @@
772
772
  "description": "Adds a \"Copy page\" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + \".md\".",
773
773
  "markdownDescription": "Adds a \"Copy page\" menu to pages - copy as Markdown, or open the page in an AI assistant - and a Markdown copy of each page at its address + \".md\"."
774
774
  },
775
+ "mcp": {
776
+ "default": true,
777
+ "type": "boolean",
778
+ "description": "An MCP server at /mcp, so AI tools can search and read the docs. The build adds its index and a Cloudflare-ready _worker.js to dist/. Default true; false leaves them out.",
779
+ "markdownDescription": "An MCP server at /mcp, so AI tools can search and read the docs. The build adds its index and a Cloudflare-ready _worker.js to dist/. Default true; false leaves them out."
780
+ },
775
781
  "redirects": {
776
782
  "default": [],
777
783
  "type": "array",