Skip to content

Commit a27cc14

Browse files
Add MCP server endpoint for react.dev documentation
## Summary - Add MCP server at `/api/mcp` with `list_pages` and `get_page` tools, allowing LLM agents to discover and read React documentation programmatically - Extract shared `RouteItem`, `PageEntry`, `collectPages`, and `readContentFile` into `src/utils/docs.ts`, replacing duplicated logic in the MCP endpoint, markdown API, and llms.txt page - Add path traversal protection in `readContentFile` (validates resolved paths stay within `src/content/`) - Add in-memory content cache for repeated file reads - Restrict markdown API endpoint to GET method - Add `@modelcontextprotocol/sdk` and `zod` dependencies ## Test plan - `yarn tsc` passes - `yarn build` succeeds - `GET /api/md/reference/react/useState` returns markdown content - `GET /llms.txt` returns sitemap - `POST /api/mcp` responds to MCP protocol requests - Path traversal attempts (e.g. `../../CLAUDE`) return null, not file contents - Non-GET requests to `/api/md/*` return 405
1 parent 40ea071 commit a27cc14

File tree

6 files changed

+749
-51
lines changed

6 files changed

+749
-51
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@
4444
"react-collapsed": "4.0.4",
4545
"react-dom": "^19.0.0",
4646
"remark-frontmatter": "^4.0.1",
47-
"remark-gfm": "^3.0.1"
47+
"remark-gfm": "^3.0.1",
48+
"zod": "^4.0.0",
49+
"@modelcontextprotocol/sdk": "^1.12.0"
4850
},
4951
"devDependencies": {
5052
"@babel/core": "^7.12.9",

src/pages/api/mcp.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import type {NextApiRequest, NextApiResponse} from 'next';
9+
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
10+
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11+
import {z} from 'zod';
12+
13+
import sidebarLearn from '../../sidebarLearn.json';
14+
import sidebarReference from '../../sidebarReference.json';
15+
import sidebarBlog from '../../sidebarBlog.json';
16+
import sidebarCommunity from '../../sidebarCommunity.json';
17+
18+
import {
19+
type RouteItem,
20+
type PageEntry,
21+
type Sidebar,
22+
collectPages,
23+
readContentFile,
24+
} from '../../utils/docs';
25+
26+
// --- Sidebar types and page collection ---
27+
28+
interface Section {
29+
section: string;
30+
pages: PageEntry[];
31+
}
32+
33+
// Build page index at module load time (static data)
34+
const PAGE_INDEX: Section[] = (
35+
[sidebarLearn, sidebarReference, sidebarBlog, sidebarCommunity] as Sidebar[]
36+
).map((sidebar) => ({
37+
section: sidebar.title,
38+
pages: collectPages(sidebar.routes),
39+
}));
40+
41+
// --- MCP server (created once at module load) ---
42+
43+
const server = new McpServer(
44+
{
45+
name: 'react-docs',
46+
version: '1.0.0',
47+
},
48+
{
49+
capabilities: {
50+
tools: {},
51+
},
52+
}
53+
);
54+
55+
server.registerTool(
56+
'list_pages',
57+
{
58+
description:
59+
'List all available React documentation pages, grouped by section (Learn, Reference, Blog, Community). Returns JSON with titles and paths.',
60+
},
61+
async () => {
62+
return {
63+
content: [
64+
{
65+
type: 'text' as const,
66+
text: JSON.stringify(PAGE_INDEX, null, 2),
67+
},
68+
],
69+
};
70+
}
71+
);
72+
73+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK generic types cause TS2589
74+
(server.registerTool as any)(
75+
'get_page',
76+
{
77+
description:
78+
'Get the full markdown content of a React documentation page by its path. Use list_pages to discover available paths.',
79+
inputSchema: {
80+
path: z
81+
.string()
82+
.describe(
83+
'Page path without leading slash, e.g. "reference/react/useState" or "blog/2024/12/05/react-19"'
84+
),
85+
},
86+
},
87+
async ({path: pagePath}: {path: string}) => {
88+
const content = readContentFile(pagePath);
89+
if (content === null) {
90+
return {
91+
isError: true,
92+
content: [
93+
{
94+
type: 'text' as const,
95+
text: `Page not found: ${pagePath}`,
96+
},
97+
],
98+
};
99+
}
100+
return {
101+
content: [
102+
{
103+
type: 'text' as const,
104+
text: content,
105+
},
106+
],
107+
};
108+
}
109+
);
110+
111+
// --- Next.js API config ---
112+
113+
export const config = {
114+
api: {
115+
// The MCP SDK reads the raw body itself
116+
bodyParser: false,
117+
},
118+
};
119+
120+
// --- Request handler ---
121+
122+
export default async function handler(
123+
req: NextApiRequest,
124+
res: NextApiResponse
125+
) {
126+
if (req.method !== 'POST') {
127+
res.setHeader('Allow', 'POST');
128+
res.status(405).json({error: 'Method not allowed. Use POST for MCP.'});
129+
return;
130+
}
131+
132+
const transport = new StreamableHTTPServerTransport({
133+
sessionIdGenerator: undefined,
134+
});
135+
136+
await server.connect(transport);
137+
138+
await transport.handleRequest(req, res);
139+
}

src/pages/api/md/[...path].ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
*/
77

88
import type {NextApiRequest, NextApiResponse} from 'next';
9-
import fs from 'fs';
10-
import path from 'path';
9+
import {readContentFile} from '../../../utils/docs';
1110

1211
const FOOTER = `
1312
---
@@ -18,6 +17,11 @@ const FOOTER = `
1817
`;
1918

2019
export default function handler(req: NextApiRequest, res: NextApiResponse) {
20+
if (req.method !== 'GET') {
21+
res.setHeader('Allow', 'GET');
22+
return res.status(405).send('Method not allowed');
23+
}
24+
2125
const pathSegments = req.query.path;
2226
if (!pathSegments) {
2327
return res.status(404).send('Not found');
@@ -32,22 +36,12 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
3236
return res.status(404).send('Not found');
3337
}
3438

35-
// Try exact path first, then with /index
36-
const candidates = [
37-
path.join(process.cwd(), 'src/content', filePath + '.md'),
38-
path.join(process.cwd(), 'src/content', filePath, 'index.md'),
39-
];
40-
41-
for (const fullPath of candidates) {
42-
try {
43-
const content = fs.readFileSync(fullPath, 'utf8');
44-
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
45-
res.setHeader('Cache-Control', 'public, max-age=3600');
46-
return res.status(200).send(content + FOOTER);
47-
} catch {
48-
// Try next candidate
49-
}
39+
const content = readContentFile(filePath);
40+
if (content === null) {
41+
return res.status(404).send('Not found');
5042
}
5143

52-
res.status(404).send('Not found');
44+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
45+
res.setHeader('Cache-Control', 'public, max-age=3600');
46+
return res.status(200).send(content + FOOTER);
5347
}

src/pages/llms.txt.tsx

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,7 @@ import {siteConfig} from '../siteConfig';
1010
import sidebarLearn from '../sidebarLearn.json';
1111
import sidebarReference from '../sidebarReference.json';
1212

13-
interface RouteItem {
14-
title?: string;
15-
path?: string;
16-
routes?: RouteItem[];
17-
hasSectionHeader?: boolean;
18-
sectionHeader?: string;
19-
}
20-
21-
interface Sidebar {
22-
title: string;
23-
routes: RouteItem[];
24-
}
13+
import type {RouteItem, Sidebar} from '../utils/docs';
2514

2615
interface Page {
2716
title: string;

src/utils/docs.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import fs from 'fs';
9+
import path from 'path';
10+
11+
// --- Sidebar route types ---
12+
13+
export interface RouteItem {
14+
title?: string;
15+
path?: string;
16+
routes?: RouteItem[];
17+
hasSectionHeader?: boolean;
18+
sectionHeader?: string;
19+
}
20+
21+
export interface PageEntry {
22+
title: string;
23+
path: string;
24+
}
25+
26+
export interface Sidebar {
27+
title: string;
28+
path: string;
29+
routes: RouteItem[];
30+
}
31+
32+
// --- Page collection ---
33+
34+
/**
35+
* Walk sidebar routes and collect flat page entries.
36+
* Skips external links and section headers without paths.
37+
*/
38+
export function collectPages(routes: RouteItem[]): PageEntry[] {
39+
const pages: PageEntry[] = [];
40+
for (const route of routes) {
41+
// Skip section headers without paths
42+
if (route.hasSectionHeader && !route.path) {
43+
continue;
44+
}
45+
// Skip external links
46+
if (route.path?.startsWith('http')) {
47+
continue;
48+
}
49+
// Collect this page if it has a title and path
50+
if (route.title && route.path) {
51+
pages.push({
52+
title: route.title,
53+
// Strip leading slash for consistency
54+
path: route.path.replace(/^\//, ''),
55+
});
56+
}
57+
// Recurse into children
58+
if (route.routes) {
59+
pages.push(...collectPages(route.routes));
60+
}
61+
}
62+
return pages;
63+
}
64+
65+
// --- Markdown file resolution ---
66+
67+
const contentCache = new Map<string, string | null>();
68+
69+
/**
70+
* Resolve a page path (e.g. "reference/react/useState") to its markdown
71+
* content under src/content/. Returns null if no matching file exists.
72+
*
73+
* Validates resolved paths stay within src/content/ to prevent traversal.
74+
* Caches results in memory for repeated reads.
75+
*/
76+
export function readContentFile(pagePath: string): string | null {
77+
const cached = contentCache.get(pagePath);
78+
if (cached !== undefined) {
79+
return cached;
80+
}
81+
82+
const contentDir = path.resolve(process.cwd(), 'src/content');
83+
const candidates = [
84+
path.resolve(contentDir, pagePath + '.md'),
85+
path.resolve(contentDir, pagePath, 'index.md'),
86+
];
87+
88+
for (const fullPath of candidates) {
89+
// Prevent path traversal outside src/content/
90+
if (!fullPath.startsWith(contentDir + path.sep)) {
91+
continue;
92+
}
93+
if (fs.existsSync(fullPath)) {
94+
const content = fs.readFileSync(fullPath, 'utf8');
95+
contentCache.set(pagePath, content);
96+
return content;
97+
}
98+
}
99+
100+
contentCache.set(pagePath, null);
101+
return null;
102+
}

0 commit comments

Comments
 (0)