diff --git a/compiler/packages/react-mcp-server/package.json b/compiler/packages/react-mcp-server/package.json index 49e5a542d6a41..d4c270513aa7f 100644 --- a/compiler/packages/react-mcp-server/package.json +++ b/compiler/packages/react-mcp-server/package.json @@ -8,6 +8,8 @@ "scripts": { "build": "rimraf dist && tsup", "test": "echo 'no tests'", + "dev": "concurrently --kill-others -n build,inspect \"yarn run watch\" \"wait-on dist/index.js && yarn run inspect\"", + "inspect": "npx @modelcontextprotocol/inspector node dist/index.js", "watch": "yarn build --watch" }, "dependencies": { diff --git a/compiler/packages/react-mcp-server/src/compiler/index.ts b/compiler/packages/react-mcp-server/src/compiler/index.ts index 8b8e494ccc1cc..0da206bd02eaa 100644 --- a/compiler/packages/react-mcp-server/src/compiler/index.ts +++ b/compiler/packages/react-mcp-server/src/compiler/index.ts @@ -14,6 +14,16 @@ import * as prettier from 'prettier'; export let lastResult: BabelCore.BabelFileResult | null = null; +export type PrintedCompilerPipelineValue = + | { + kind: 'hir'; + name: string; + fnName: string | null; + value: string; + } + | {kind: 'reactive'; name: string; fnName: string | null; value: string} + | {kind: 'debug'; name: string; fnName: string | null; value: string}; + type CompileOptions = { text: string; file: string; diff --git a/compiler/packages/react-mcp-server/src/index.ts b/compiler/packages/react-mcp-server/src/index.ts index 9f81de88ce9ab..e577128f86355 100644 --- a/compiler/packages/react-mcp-server/src/index.ts +++ b/compiler/packages/react-mcp-server/src/index.ts @@ -11,7 +11,7 @@ import { } from '@modelcontextprotocol/sdk/server/mcp.js'; import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'; import {z} from 'zod'; -import {compile} from './compiler'; +import {compile, type PrintedCompilerPipelineValue} from './compiler'; import { CompilerPipelineValue, printReactiveFunctionWithOutlined, @@ -22,32 +22,38 @@ import { import * as cheerio from 'cheerio'; import TurndownService from 'turndown'; import {queryAlgolia} from './utils/algolia'; +import assertExhaustive from './utils/assertExhaustive'; const turndownService = new TurndownService(); - -export type PrintedCompilerPipelineValue = - | { - kind: 'hir'; - name: string; - fnName: string | null; - value: string; - } - | {kind: 'reactive'; name: string; fnName: string | null; value: string} - | {kind: 'debug'; name: string; fnName: string | null; value: string}; - const server = new McpServer({ name: 'React', version: '0.0.0', }); +function slugify(heading: string): string { + return heading + .split(' ') + .map(w => w.toLowerCase()) + .join('-'); +} + // TODO: how to verify this works? server.resource( 'docs', new ResourceTemplate('docs://{message}', {list: undefined}), - async (uri, {message}) => { + async (_uri, {message}) => { const hits = await queryAlgolia(message); + const deduped = new Map(); + for (const hit of hits) { + // drop hashes to dedupe properly + const u = new URL(hit.url); + if (deduped.has(u.pathname)) { + continue; + } + deduped.set(u.pathname, hit); + } const pages: Array = await Promise.all( - hits.map(hit => { + Array.from(deduped.values()).map(hit => { return fetch(hit.url, { headers: { 'User-Agent': @@ -70,16 +76,17 @@ server.resource( .filter(html => html !== null) .map(html => { const $ = cheerio.load(html); + const title = encodeURIComponent(slugify($('h1').text())); // react.dev should always have at least one
with the main content const article = $('article').html(); if (article != null) { return { - uri: uri.href, + uri: `docs://${title}`, text: turndownService.turndown(article), }; } else { return { - uri: uri.href, + uri: `docs://${title}`, // Fallback to converting the whole page to markdown text: turndownService.turndown($.html()), }; @@ -97,7 +104,7 @@ server.tool( 'Compile code with React Compiler. Optionally, for debugging provide a pass name like "HIR" to see more information.', { text: z.string(), - passName: z.string().optional(), + passName: z.enum(['HIR', 'ReactiveFunction', 'All', '@DEBUG']).optional(), }, async ({text, passName}) => { const pipelinePasses = new Map< @@ -147,8 +154,7 @@ server.tool( break; } default: { - const _: never = result; - throw new Error(`Unhandled result ${result}`); + assertExhaustive(result, `Unhandled result ${result}`); } } }; @@ -188,6 +194,78 @@ server.tool( } const requestedPasses: Array<{type: 'text'; text: string}> = []; if (passName != null) { + switch (passName) { + case 'All': { + const hir = pipelinePasses.get('PropagateScopeDependenciesHIR'); + if (hir !== undefined) { + for (const pipelineValue of hir) { + requestedPasses.push({ + type: 'text' as const, + text: pipelineValue.value, + }); + } + } + const reactiveFunc = pipelinePasses.get('PruneHoistedContexts'); + if (reactiveFunc !== undefined) { + for (const pipelineValue of reactiveFunc) { + requestedPasses.push({ + type: 'text' as const, + text: pipelineValue.value, + }); + } + } + break; + } + case 'HIR': { + // Last pass before HIR -> ReactiveFunction + const requestedPass = pipelinePasses.get( + 'PropagateScopeDependenciesHIR', + ); + if (requestedPass !== undefined) { + for (const pipelineValue of requestedPass) { + requestedPasses.push({ + type: 'text' as const, + text: pipelineValue.value, + }); + } + } else { + console.error(`Could not find requested pass ${passName}`); + } + break; + } + case 'ReactiveFunction': { + // Last pass + const requestedPass = pipelinePasses.get('PruneHoistedContexts'); + if (requestedPass !== undefined) { + for (const pipelineValue of requestedPass) { + requestedPasses.push({ + type: 'text' as const, + text: pipelineValue.value, + }); + } + } else { + console.error(`Could not find requested pass ${passName}`); + } + break; + } + case '@DEBUG': { + for (const [, pipelinePass] of pipelinePasses) { + for (const pass of pipelinePass) { + requestedPasses.push({ + type: 'text' as const, + text: `${pass.name}\n\n${pass.value}`, + }); + } + } + break; + } + default: { + assertExhaustive( + passName, + `Unhandled passName option: ${passName}`, + ); + } + } const requestedPass = pipelinePasses.get(passName); if (requestedPass !== undefined) { for (const pipelineValue of requestedPass) { @@ -205,7 +283,7 @@ server.tool( if (typeof err.loc !== 'symbol') { return { type: 'text' as const, - text: `React Compiler bailed out: ${err.message}@${err.loc.start}:${err.loc.end}`, + text: `React Compiler bailed out:\n\n${err.message}@${err.loc.start.line}:${err.loc.end.line}`, }; } return null; diff --git a/compiler/packages/react-mcp-server/src/utils/assertExhaustive.ts b/compiler/packages/react-mcp-server/src/utils/assertExhaustive.ts new file mode 100644 index 0000000000000..2adfffa8d7ea4 --- /dev/null +++ b/compiler/packages/react-mcp-server/src/utils/assertExhaustive.ts @@ -0,0 +1,13 @@ +/** + * 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. + */ + +/** + * Trigger an exhaustiveness check in TypeScript and throw at runtime. + */ +export default function assertExhaustive(_: never, errorMsg: string): never { + throw new Error(errorMsg); +}