Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
"react-collapsed": "4.0.4",
"react-dom": "^19.0.0",
"remark-frontmatter": "^4.0.1",
"remark-gfm": "^3.0.1"
"remark-gfm": "^3.0.1",
"zod": "^4.0.0",
"@modelcontextprotocol/sdk": "^1.12.0"
},
"devDependencies": {
"@babel/core": "^7.12.9",
Expand Down
138 changes: 138 additions & 0 deletions src/pages/api/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import type {NextApiRequest, NextApiResponse} from 'next';
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {z} from 'zod';

import sidebarLearn from '../../sidebarLearn.json';
import sidebarReference from '../../sidebarReference.json';
import sidebarBlog from '../../sidebarBlog.json';
import sidebarCommunity from '../../sidebarCommunity.json';

import {
type PageEntry,
type Sidebar,
collectPages,
readContentFile,
} from '../../utils/docs';

// --- Sidebar types and page collection ---

interface Section {
section: string;
pages: PageEntry[];
}

// Build page index at module load time (static data)
const PAGE_INDEX: Section[] = (
[sidebarLearn, sidebarReference, sidebarBlog, sidebarCommunity] as Sidebar[]
).map((sidebar) => ({
section: sidebar.title,
pages: collectPages(sidebar.routes),
}));

// --- MCP server (created once at module load) ---

const server = new McpServer(
{
name: 'react-docs',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);

server.registerTool(
'list_pages',
{
description:
'List all available React documentation pages, grouped by section (Learn, Reference, Blog, Community). Returns JSON with titles and paths.',
},
async () => {
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(PAGE_INDEX, null, 2),
},
],
};
}
);

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- MCP SDK generic types cause TS2589
(server.registerTool as any)(
'get_page',
{
description:
'Get the full markdown content of a React documentation page by its path. Use list_pages to discover available paths.',
inputSchema: {
path: z
.string()
.describe(
'Page path without leading slash, e.g. "reference/react/useState" or "blog/2024/12/05/react-19"'
),
},
},
async ({path: pagePath}: {path: string}) => {
const content = readContentFile(pagePath);
if (content === null) {
return {
isError: true,
content: [
{
type: 'text' as const,
text: `Page not found: ${pagePath}`,
},
],
};
}
return {
content: [
{
type: 'text' as const,
text: content,
},
],
};
}
);

// --- Next.js API config ---

export const config = {
api: {
// The MCP SDK reads the raw body itself
bodyParser: false,
},
};

// --- Request handler ---

export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
res.status(405).json({error: 'Method not allowed. Use POST for MCP.'});
return;
}

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});

await server.connect(transport);

await transport.handleRequest(req, res);
}
30 changes: 12 additions & 18 deletions src/pages/api/md/[...path].ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
*/

import type {NextApiRequest, NextApiResponse} from 'next';
import fs from 'fs';
import path from 'path';
import {readContentFile} from '../../../utils/docs';

const FOOTER = `
---
Expand All @@ -18,6 +17,11 @@ const FOOTER = `
`;

export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET');
return res.status(405).send('Method not allowed');
}

const pathSegments = req.query.path;
if (!pathSegments) {
return res.status(404).send('Not found');
Expand All @@ -32,22 +36,12 @@ export default function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(404).send('Not found');
}

// Try exact path first, then with /index
const candidates = [
path.join(process.cwd(), 'src/content', filePath + '.md'),
path.join(process.cwd(), 'src/content', filePath, 'index.md'),
];

for (const fullPath of candidates) {
try {
const content = fs.readFileSync(fullPath, 'utf8');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=3600');
return res.status(200).send(content + FOOTER);
} catch {
// Try next candidate
}
const content = readContentFile(filePath);
if (content === null) {
return res.status(404).send('Not found');
}

res.status(404).send('Not found');
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=3600');
return res.status(200).send(content + FOOTER);
}
13 changes: 1 addition & 12 deletions src/pages/llms.txt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,7 @@ import {siteConfig} from '../siteConfig';
import sidebarLearn from '../sidebarLearn.json';
import sidebarReference from '../sidebarReference.json';

interface RouteItem {
title?: string;
path?: string;
routes?: RouteItem[];
hasSectionHeader?: boolean;
sectionHeader?: string;
}

interface Sidebar {
title: string;
routes: RouteItem[];
}
import type {RouteItem, Sidebar} from '../utils/docs';

interface Page {
title: string;
Expand Down
102 changes: 102 additions & 0 deletions src/utils/docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import fs from 'fs';
import path from 'path';

// --- Sidebar route types ---

export interface RouteItem {
title?: string;
path?: string;
routes?: RouteItem[];
hasSectionHeader?: boolean;
sectionHeader?: string;
}

export interface PageEntry {
title: string;
path: string;
}

export interface Sidebar {
title: string;
path: string;
routes: RouteItem[];
}

// --- Page collection ---

/**
* Walk sidebar routes and collect flat page entries.
* Skips external links and section headers without paths.
*/
export function collectPages(routes: RouteItem[]): PageEntry[] {
const pages: PageEntry[] = [];
for (const route of routes) {
// Skip section headers without paths
if (route.hasSectionHeader && !route.path) {
continue;
}
// Skip external links
if (route.path?.startsWith('http')) {
continue;
}
// Collect this page if it has a title and path
if (route.title && route.path) {
pages.push({
title: route.title,
// Strip leading slash for consistency
path: route.path.replace(/^\//, ''),
});
}
// Recurse into children
if (route.routes) {
pages.push(...collectPages(route.routes));
}
}
return pages;
}

// --- Markdown file resolution ---

const contentCache = new Map<string, string | null>();

/**
* Resolve a page path (e.g. "reference/react/useState") to its markdown
* content under src/content/. Returns null if no matching file exists.
*
* Validates resolved paths stay within src/content/ to prevent traversal.
* Caches results in memory for repeated reads.
*/
export function readContentFile(pagePath: string): string | null {
const cached = contentCache.get(pagePath);
if (cached !== undefined) {
return cached;
}

const contentDir = path.resolve(process.cwd(), 'src/content');
const candidates = [
path.resolve(contentDir, pagePath + '.md'),
path.resolve(contentDir, pagePath, 'index.md'),
];

for (const fullPath of candidates) {
// Prevent path traversal outside src/content/
if (!fullPath.startsWith(contentDir + path.sep)) {
continue;
}
if (fs.existsSync(fullPath)) {
const content = fs.readFileSync(fullPath, 'utf8');
contentCache.set(pagePath, content);
return content;
}
}

contentCache.set(pagePath, null);
return null;
}
Loading