mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
[compiler] Init react-mcp-server
Just trying this out as a small hack for fun. Nothing serious is planned. Inits an MCP server that has 1 assistant prompt and two capabilities.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# React MCP Server (experimental)
|
||||
|
||||
An experimental MCP Server for React.
|
||||
|
||||
## Development
|
||||
|
||||
First, add this file if you're using Claude Desktop: `code ~/Library/Application\ Support/Claude/claude_desktop_config.json`. Copy the absolute path from `which node` and from `react/compiler/react-mcp-server/dist/index.js` and paste, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"react": {
|
||||
"command": "/Users/<username>/.asdf/shims/node",
|
||||
"args": [
|
||||
"/Users/<username>/code/react/compiler/packages/react-mcp-server/dist/index.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Next, run `yarn workspace react-mcp-server watch` from the `react/compiler` directory and make changes as needed. You will need to restart Claude everytime you want to try your changes.
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "react-mcp-server",
|
||||
"version": "0.0.0",
|
||||
"description": "React MCP Server (experimental)",
|
||||
"bin": {
|
||||
"react-mcp-server": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsup",
|
||||
"test": "echo 'no tests'",
|
||||
"watch": "yarn build --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/parser": "^7.26",
|
||||
"@babel/plugin-syntax-typescript": "^7.25.9",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"algoliasearch": "^5.23.3",
|
||||
"cheerio": "^1.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
"turndown": "^7.2.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/turndown": "^5.0.5"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/facebook/react.git",
|
||||
"directory": "compiler/packages/react-mcp-server"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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 * as BabelCore from '@babel/core';
|
||||
import {parseAsync, transformFromAstAsync} from '@babel/core';
|
||||
import BabelPluginReactCompiler, {
|
||||
type PluginOptions,
|
||||
} from 'babel-plugin-react-compiler/src';
|
||||
import * as prettier from 'prettier';
|
||||
|
||||
export let lastResult: BabelCore.BabelFileResult | null = null;
|
||||
|
||||
type CompileOptions = {
|
||||
text: string;
|
||||
file: string;
|
||||
options: Partial<PluginOptions> | null;
|
||||
};
|
||||
export async function compile({
|
||||
text,
|
||||
file,
|
||||
options,
|
||||
}: CompileOptions): Promise<BabelCore.BabelFileResult> {
|
||||
const ast = await parseAsync(text, {
|
||||
sourceFileName: file,
|
||||
parserOpts: {
|
||||
plugins: ['typescript', 'jsx'],
|
||||
},
|
||||
sourceType: 'module',
|
||||
});
|
||||
if (ast == null) {
|
||||
throw new Error('Could not parse');
|
||||
}
|
||||
const plugins =
|
||||
options != null
|
||||
? [[BabelPluginReactCompiler, options]]
|
||||
: [[BabelPluginReactCompiler]];
|
||||
const result = await transformFromAstAsync(ast, text, {
|
||||
filename: file,
|
||||
highlightCode: false,
|
||||
retainLines: true,
|
||||
plugins,
|
||||
sourceType: 'module',
|
||||
sourceFileName: file,
|
||||
});
|
||||
if (result?.code == null) {
|
||||
throw new Error(
|
||||
`Expected BabelPluginReactCompiler to compile successfully, got ${result}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
result.code = await prettier.format(result.code, {
|
||||
semi: false,
|
||||
parser: 'babel-ts',
|
||||
});
|
||||
if (result.code != null) {
|
||||
lastResult = result;
|
||||
}
|
||||
} catch (err) {
|
||||
// If prettier failed just log, no need to crash
|
||||
console.error(err);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 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 {
|
||||
McpServer,
|
||||
ResourceTemplate,
|
||||
} from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {z} from 'zod';
|
||||
import {compile} from './compiler';
|
||||
import {
|
||||
CompilerPipelineValue,
|
||||
printReactiveFunctionWithOutlined,
|
||||
printFunctionWithOutlined,
|
||||
} from 'babel-plugin-react-compiler/src';
|
||||
import * as cheerio from 'cheerio';
|
||||
import TurndownService from 'turndown';
|
||||
import {queryAlgolia} from './utils/algolia';
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
// TODO: how to verify this works?
|
||||
server.resource(
|
||||
'docs',
|
||||
new ResourceTemplate('docs://{message}', {list: undefined}),
|
||||
async (uri, {message}) => {
|
||||
const hits = await queryAlgolia(message);
|
||||
const pages: Array<string | null> = await Promise.all(
|
||||
hits.map(hit => {
|
||||
return fetch(hit.url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
|
||||
},
|
||||
}).then(res => {
|
||||
if (res.ok === true) {
|
||||
return res.text();
|
||||
} else {
|
||||
console.error(
|
||||
`Could not fetch docs: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const resultsMarkdown = pages
|
||||
.filter(html => html !== null)
|
||||
.map(html => {
|
||||
const $ = cheerio.load(html);
|
||||
// react.dev always has at least one <article> with the main content
|
||||
const article = $('article').html();
|
||||
if (article != null) {
|
||||
return {
|
||||
uri: uri.href,
|
||||
text: turndownService.turndown(article),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
uri: uri.href,
|
||||
// Fallback to converting the whole page to markdown
|
||||
text: turndownService.turndown($.html()),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
contents: resultsMarkdown,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'optimize',
|
||||
'Use React Compiler to optimize React code. Optionally, for debugging provide a pass name like "HIR" to see more information.',
|
||||
{
|
||||
text: z.string(),
|
||||
passName: z.string().optional(),
|
||||
},
|
||||
async ({text, passName}) => {
|
||||
const pipelinePasses = new Map<
|
||||
string,
|
||||
Array<PrintedCompilerPipelineValue>
|
||||
>();
|
||||
const recordPass: (
|
||||
result: PrintedCompilerPipelineValue,
|
||||
) => void = result => {
|
||||
const entry = pipelinePasses.get(result.name);
|
||||
if (Array.isArray(entry)) {
|
||||
entry.push(result);
|
||||
} else {
|
||||
pipelinePasses.set(result.name, [result]);
|
||||
}
|
||||
};
|
||||
const logIR = (result: CompilerPipelineValue): void => {
|
||||
switch (result.kind) {
|
||||
case 'ast': {
|
||||
break;
|
||||
}
|
||||
case 'hir': {
|
||||
recordPass({
|
||||
kind: 'hir',
|
||||
fnName: result.value.id,
|
||||
name: result.name,
|
||||
value: printFunctionWithOutlined(result.value),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'reactive': {
|
||||
recordPass({
|
||||
kind: 'reactive',
|
||||
fnName: result.value.id,
|
||||
name: result.name,
|
||||
value: printReactiveFunctionWithOutlined(result.value),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'debug': {
|
||||
recordPass({
|
||||
kind: 'debug',
|
||||
fnName: null,
|
||||
name: result.name,
|
||||
value: result.value,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const _: never = result;
|
||||
throw new Error(`Unhandled result ${result}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
const compilerOptions = {
|
||||
logger: {
|
||||
debugLogIRs: logIR,
|
||||
logEvent: () => {},
|
||||
},
|
||||
};
|
||||
try {
|
||||
const result = await compile({
|
||||
text,
|
||||
file: 'anonymous.tsx',
|
||||
options: compilerOptions,
|
||||
});
|
||||
if (result.code == null) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{type: 'text', text: 'Error: Could not compile'}],
|
||||
};
|
||||
}
|
||||
const requestedPasses: Array<{type: 'text'; text: string}> = [];
|
||||
if (passName != null) {
|
||||
const requestedPass = pipelinePasses.get(passName);
|
||||
if (requestedPass !== undefined) {
|
||||
for (const pipelineValue of requestedPass) {
|
||||
if (pipelineValue.name === passName) {
|
||||
requestedPasses.push({
|
||||
type: 'text',
|
||||
text: pipelineValue.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: [{type: 'text', text: result.code}, ...requestedPasses],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{type: 'text', text: `Error: ${err.stack}`}],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.prompt('review-code', {code: z.string()}, ({code}) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `# React Expert Assistant
|
||||
|
||||
## Role
|
||||
You are a React assistant that helps users write better React, following the rules of React in the react.dev docs.
|
||||
|
||||
## Available Resources
|
||||
- 'docs': Look up documentation from React.dev. Returns urls that you must retrieve so you can view its content.
|
||||
|
||||
## Available Tools
|
||||
- 'optimize': Run the users code through React Compiler
|
||||
|
||||
## Process
|
||||
1. Check if the users code follows the rules of React
|
||||
- Point out issues in the users code if it does not
|
||||
|
||||
2. Run the compiler on the users code and see if it can successfully optimize the code
|
||||
- If the same code is returned by the compiler, it has bailed out or there is nothing to optimize
|
||||
|
||||
3. Iterate
|
||||
- Guide the user on making adjustments to their code so that it can be successfully optimized.
|
||||
- If it was already successfully optimized, check how many items were cached previously and compare them to each new attempt. For example, you can refer to the cache size const $ = _c(n); as a rough heuristic, where n is the size of the cache as an integer. Higher is better.
|
||||
|
||||
## Special Instructions
|
||||
Make sure to use information from react.dev as the main reference for your React knowledge. Information from unofficial sources such as blogs and articles can also be used but may sometimes be outdated or contain poor advice.
|
||||
|
||||
## Example 1: <todo>
|
||||
|
||||
## Example 2: <todo>
|
||||
|
||||
Review the following code:
|
||||
|
||||
${code}
|
||||
`,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('React Compiler MCP Server running on stdio');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('Fatal error in main():', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// https://github.com/algolia/docsearch/blob/15ebcba606b281aa0dddc4ccb8feb19d396bf79e/packages/docsearch-react/src/types/DocSearchHit.ts
|
||||
type ContentType =
|
||||
| 'content'
|
||||
| 'lvl0'
|
||||
| 'lvl1'
|
||||
| 'lvl2'
|
||||
| 'lvl3'
|
||||
| 'lvl4'
|
||||
| 'lvl5'
|
||||
| 'lvl6';
|
||||
|
||||
interface DocSearchHitAttributeHighlightResult {
|
||||
value: string;
|
||||
matchLevel: 'full' | 'none' | 'partial';
|
||||
matchedWords: string[];
|
||||
fullyHighlighted?: boolean;
|
||||
}
|
||||
|
||||
interface DocSearchHitHighlightResultHierarchy {
|
||||
lvl0: DocSearchHitAttributeHighlightResult;
|
||||
lvl1: DocSearchHitAttributeHighlightResult;
|
||||
lvl2: DocSearchHitAttributeHighlightResult;
|
||||
lvl3: DocSearchHitAttributeHighlightResult;
|
||||
lvl4: DocSearchHitAttributeHighlightResult;
|
||||
lvl5: DocSearchHitAttributeHighlightResult;
|
||||
lvl6: DocSearchHitAttributeHighlightResult;
|
||||
}
|
||||
|
||||
interface DocSearchHitHighlightResult {
|
||||
content: DocSearchHitAttributeHighlightResult;
|
||||
hierarchy: DocSearchHitHighlightResultHierarchy;
|
||||
hierarchy_camel: DocSearchHitHighlightResultHierarchy[];
|
||||
}
|
||||
|
||||
interface DocSearchHitAttributeSnippetResult {
|
||||
value: string;
|
||||
matchLevel: 'full' | 'none' | 'partial';
|
||||
}
|
||||
|
||||
interface DocSearchHitSnippetResult {
|
||||
content: DocSearchHitAttributeSnippetResult;
|
||||
hierarchy: DocSearchHitHighlightResultHierarchy;
|
||||
hierarchy_camel: DocSearchHitHighlightResultHierarchy[];
|
||||
}
|
||||
|
||||
export declare type DocSearchHit = {
|
||||
objectID: string;
|
||||
content: string | null;
|
||||
url: string;
|
||||
url_without_anchor: string;
|
||||
type: ContentType;
|
||||
anchor: string | null;
|
||||
hierarchy: {
|
||||
lvl0: string;
|
||||
lvl1: string;
|
||||
lvl2: string | null;
|
||||
lvl3: string | null;
|
||||
lvl4: string | null;
|
||||
lvl5: string | null;
|
||||
lvl6: string | null;
|
||||
};
|
||||
_highlightResult: DocSearchHitHighlightResult;
|
||||
_snippetResult: DocSearchHitSnippetResult;
|
||||
_rankingInfo?: {
|
||||
promoted: boolean;
|
||||
nbTypos: number;
|
||||
firstMatchedWord: number;
|
||||
proximityDistance?: number;
|
||||
geoDistance: number;
|
||||
geoPrecision?: number;
|
||||
nbExactWords: number;
|
||||
words: number;
|
||||
filters: number;
|
||||
userScore: number;
|
||||
matchedGeoLocation?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
distance: number;
|
||||
};
|
||||
};
|
||||
_distinctSeqID?: number;
|
||||
__autocomplete_indexName?: string;
|
||||
__autocomplete_queryID?: string;
|
||||
__autocomplete_algoliaCredentials?: {
|
||||
appId: string;
|
||||
apiKey: string;
|
||||
};
|
||||
__autocomplete_id?: number;
|
||||
};
|
||||
|
||||
export type InternalDocSearchHit = DocSearchHit & {
|
||||
__docsearch_parent: InternalDocSearchHit | null;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 {DocSearchHit, InternalDocSearchHit} from '../types/algolia';
|
||||
import {liteClient, type Hit, type SearchResponse} from 'algoliasearch/lite';
|
||||
|
||||
// https://github.com/reactjs/react.dev/blob/55986965fbf69c2584040039c9586a01bd54eba7/src/siteConfig.js#L15-L19
|
||||
const ALGOLIA_CONFIG = {
|
||||
appId: '1FCF9AYYAT',
|
||||
apiKey: '1b7ad4e1c89e645e351e59d40544eda1',
|
||||
indexName: 'beta-react',
|
||||
};
|
||||
|
||||
export const ALGOLIA_CLIENT = liteClient(
|
||||
ALGOLIA_CONFIG.appId,
|
||||
ALGOLIA_CONFIG.apiKey,
|
||||
);
|
||||
|
||||
export function printHierarchy(
|
||||
hit: DocSearchHit | InternalDocSearchHit,
|
||||
): string {
|
||||
let val = `${hit.hierarchy.lvl0} > ${hit.hierarchy.lvl1}`;
|
||||
if (hit.hierarchy.lvl2 != null) {
|
||||
val = val.concat(` > ${hit.hierarchy.lvl2}`);
|
||||
}
|
||||
if (hit.hierarchy.lvl3 != null) {
|
||||
val = val.concat(` > ${hit.hierarchy.lvl3}`);
|
||||
}
|
||||
if (hit.hierarchy.lvl4 != null) {
|
||||
val = val.concat(` > ${hit.hierarchy.lvl4}`);
|
||||
}
|
||||
if (hit.hierarchy.lvl5 != null) {
|
||||
val = val.concat(` > ${hit.hierarchy.lvl5}`);
|
||||
}
|
||||
if (hit.hierarchy.lvl6 != null) {
|
||||
val = val.concat(` > ${hit.hierarchy.lvl6}`);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
export async function queryAlgolia(
|
||||
message: string | Array<string>,
|
||||
): Promise<Hit<DocSearchHit>[]> {
|
||||
const {results} = await ALGOLIA_CLIENT.search<DocSearchHit>({
|
||||
requests: [
|
||||
{
|
||||
query: Array.isArray(message) ? message.join('\n') : message,
|
||||
indexName: ALGOLIA_CONFIG.indexName,
|
||||
attributesToRetrieve: [
|
||||
'hierarchy.lvl0',
|
||||
'hierarchy.lvl1',
|
||||
'hierarchy.lvl2',
|
||||
'hierarchy.lvl3',
|
||||
'hierarchy.lvl4',
|
||||
'hierarchy.lvl5',
|
||||
'hierarchy.lvl6',
|
||||
'content',
|
||||
'url',
|
||||
],
|
||||
attributesToSnippet: [
|
||||
`hierarchy.lvl1:10`,
|
||||
`hierarchy.lvl2:10`,
|
||||
`hierarchy.lvl3:10`,
|
||||
`hierarchy.lvl4:10`,
|
||||
`hierarchy.lvl5:10`,
|
||||
`hierarchy.lvl6:10`,
|
||||
`content:10`,
|
||||
],
|
||||
snippetEllipsisText: '…',
|
||||
hitsPerPage: 30,
|
||||
attributesToHighlight: [
|
||||
'hierarchy.lvl0',
|
||||
'hierarchy.lvl1',
|
||||
'hierarchy.lvl2',
|
||||
'hierarchy.lvl3',
|
||||
'hierarchy.lvl4',
|
||||
'hierarchy.lvl5',
|
||||
'hierarchy.lvl6',
|
||||
'content',
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const firstResult = results[0] as SearchResponse<DocSearchHit>;
|
||||
const {hits} = firstResult;
|
||||
return hits;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "@tsconfig/strictest/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"rootDir": "../",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsxdev",
|
||||
"lib": ["ES2022"],
|
||||
|
||||
// weaken strictness from preset
|
||||
"importsNotUsedAsValues": "remove",
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noUnusedParameters": false,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"target": "ES2022",
|
||||
// ideally turn off only during dev, or on a per-file basis
|
||||
"noUnusedLocals": false,
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["src/**/*.ts"],
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {defineConfig} from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['./src/index.ts'],
|
||||
outDir: './dist',
|
||||
external: [],
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
dts: false,
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'es2022',
|
||||
banner: {
|
||||
js: `#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @lightSyntaxTransform
|
||||
* @noflow
|
||||
* @nolint
|
||||
* @preventMunge
|
||||
* @preserve-invariant-messages
|
||||
*/`,
|
||||
},
|
||||
});
|
||||
+869
-6
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user