diff --git a/.eslintrc.js b/.eslintrc.js index c1eb5b34ebe82..1bb4e868d0694 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -496,6 +496,7 @@ module.exports = { 'packages/react-devtools-shared/src/devtools/views/**/*.js', 'packages/react-devtools-shared/src/hook.js', 'packages/react-devtools-shared/src/backend/console.js', + 'packages/react-devtools-shared/src/backend/fiber/renderer.js', 'packages/react-devtools-shared/src/backend/shared/DevToolsComponentStackFrame.js', 'packages/react-devtools-shared/src/frontend/utils/withPermissionsCheck.js', ], @@ -504,6 +505,7 @@ module.exports = { __IS_FIREFOX__: 'readonly', __IS_EDGE__: 'readonly', __IS_NATIVE__: 'readonly', + __IS_INTERNAL_MCP_BUILD__: 'readonly', __IS_INTERNAL_VERSION__: 'readonly', chrome: 'readonly', }, diff --git a/compiler/packages/react-mcp-server/src/index.ts b/compiler/packages/react-mcp-server/src/index.ts index 2ec747eac4dfd..2871027d64ce3 100644 --- a/compiler/packages/react-mcp-server/src/index.ts +++ b/compiler/packages/react-mcp-server/src/index.ts @@ -21,6 +21,7 @@ import {queryAlgolia} from './utils/algolia'; import assertExhaustive from './utils/assertExhaustive'; import {convert} from 'html-to-text'; import {measurePerformance} from './tools/runtimePerf'; +import {parseReactComponentTree} from './tools/componentTree'; function calculateMean(values: number[]): string { return values.length > 0 @@ -366,6 +367,45 @@ ${calculateMean(results.renderTime)} }, ); +server.tool( + 'parse-react-component-tree', + ` + This tool gets the component tree of a React App. + passing in a url will attempt to connect to the browser and get the current state of the component tree. If no url is passed in, + the default url will be used (http://localhost:3000). + + + - The url should be a full url with the protocol (http:// or https://) and the domain name (e.g. localhost:3000). + - Also the user should be running a Chrome browser running on debug mode on port 9222. If you receive an error message, advise the user to run + the following comand in the terminal: + MacOS: "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome" + Windows: "chrome.exe --remote-debugging-port=9222 --user-data-dir=C:\temp\chrome" + + `, + { + url: z.string().optional().default('http://localhost:3000'), + }, + async ({url}) => { + try { + const componentTree = await parseReactComponentTree(url); + + return { + content: [ + { + type: 'text' as const, + text: componentTree, + }, + ], + }; + } catch (err) { + return { + isError: true, + content: [{type: 'text' as const, text: `Error: ${err.stack}`}], + }; + } + }, +); + server.prompt('review-react-code', () => ({ messages: [ { diff --git a/compiler/packages/react-mcp-server/src/tools/componentTree.ts b/compiler/packages/react-mcp-server/src/tools/componentTree.ts new file mode 100644 index 0000000000000..a124066a9424e --- /dev/null +++ b/compiler/packages/react-mcp-server/src/tools/componentTree.ts @@ -0,0 +1,38 @@ +import puppeteer from 'puppeteer'; + +export async function parseReactComponentTree(url: string): Promise { + try { + const browser = await puppeteer.connect({ + browserURL: 'http://127.0.0.1:9222', + defaultViewport: null, + }); + + const pages = await browser.pages(); + + let localhostPage = null; + for (const page of pages) { + const pageUrl = await page.url(); + + if (pageUrl.startsWith(url)) { + localhostPage = page; + break; + } + } + + if (localhostPage) { + const componentTree = await localhostPage.evaluate(() => { + return (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__.rendererInterfaces + .get(1) + .__internal_only_getComponentTree(); + }); + + return componentTree; + } else { + throw new Error( + `Could not open the page at ${url}. Is your server running?`, + ); + } + } catch (error) { + throw new Error('Failed extract component tree' + error); + } +} diff --git a/packages/react-devtools-core/webpack.backend.js b/packages/react-devtools-core/webpack.backend.js index 32d4fadcb5884..c1312fc6d8ec8 100644 --- a/packages/react-devtools-core/webpack.backend.js +++ b/packages/react-devtools-core/webpack.backend.js @@ -72,6 +72,7 @@ module.exports = { __IS_CHROME__: false, __IS_EDGE__: false, __IS_NATIVE__: true, + __IS_INTERNAL_MCP_BUILD__: false, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-core"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.GITHUB_URL': `"${GITHUB_URL}"`, diff --git a/packages/react-devtools-core/webpack.standalone.js b/packages/react-devtools-core/webpack.standalone.js index 8caadec10b070..6a9636c6911b1 100644 --- a/packages/react-devtools-core/webpack.standalone.js +++ b/packages/react-devtools-core/webpack.standalone.js @@ -91,6 +91,7 @@ module.exports = { __IS_FIREFOX__: false, __IS_CHROME__: false, __IS_EDGE__: false, + __IS_INTERNAL_MCP_BUILD__: false, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-core"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null, diff --git a/packages/react-devtools-extensions/webpack.backend.js b/packages/react-devtools-extensions/webpack.backend.js index effa6cc330bb0..4bfa05183067e 100644 --- a/packages/react-devtools-extensions/webpack.backend.js +++ b/packages/react-devtools-extensions/webpack.backend.js @@ -78,6 +78,7 @@ module.exports = { __IS_FIREFOX__: IS_FIREFOX, __IS_EDGE__: IS_EDGE, __IS_NATIVE__: false, + __IS_INTERNAL_MCP_BUILD__: false, }), new Webpack.SourceMapDevToolPlugin({ filename: '[file].map', diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index 51b8f4e2105e3..4a3052517c851 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -33,6 +33,8 @@ const IS_FIREFOX = process.env.IS_FIREFOX === 'true'; const IS_EDGE = process.env.IS_EDGE === 'true'; const IS_INTERNAL_VERSION = process.env.FEATURE_FLAG_TARGET === 'extension-fb'; +const IS_INTERNAL_MCP_BUILD = process.env.IS_INTERNAL_MCP_BUILD === 'true'; + const featureFlagTarget = process.env.FEATURE_FLAG_TARGET || 'extension-oss'; const babelOptions = { @@ -113,6 +115,7 @@ module.exports = { __IS_FIREFOX__: IS_FIREFOX, __IS_EDGE__: IS_EDGE, __IS_NATIVE__: false, + __IS_INTERNAL_MCP_BUILD__: IS_INTERNAL_MCP_BUILD, __IS_INTERNAL_VERSION__: IS_INTERNAL_VERSION, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-extensions"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, diff --git a/packages/react-devtools-fusebox/webpack.config.frontend.js b/packages/react-devtools-fusebox/webpack.config.frontend.js index ab7906ca84d63..ea04f4dad2d0d 100644 --- a/packages/react-devtools-fusebox/webpack.config.frontend.js +++ b/packages/react-devtools-fusebox/webpack.config.frontend.js @@ -86,6 +86,7 @@ module.exports = { __IS_CHROME__: false, __IS_FIREFOX__: false, __IS_EDGE__: false, + __IS_INTERNAL_MCP_BUILD__: false, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-fusebox"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null, diff --git a/packages/react-devtools-inline/webpack.config.js b/packages/react-devtools-inline/webpack.config.js index 3a92dff1f2195..9fa900dfa65f2 100644 --- a/packages/react-devtools-inline/webpack.config.js +++ b/packages/react-devtools-inline/webpack.config.js @@ -78,6 +78,7 @@ module.exports = { __IS_FIREFOX__: false, __IS_EDGE__: false, __IS_NATIVE__: false, + __IS_INTERNAL_MCP_BUILD__: false, 'process.env.DEVTOOLS_PACKAGE': `"react-devtools-inline"`, 'process.env.DEVTOOLS_VERSION': `"${DEVTOOLS_VERSION}"`, 'process.env.EDITOR_URL': EDITOR_URL != null ? `"${EDITOR_URL}"` : null, diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 4fc59a24d9272..94246df6485e4 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5859,6 +5859,86 @@ export function attach( return unresolvedSource; } + type InternalMcpFunctions = { + __internal_only_getComponentTree?: Function, + }; + + const internalMcpFunctions: InternalMcpFunctions = {}; + if (__IS_INTERNAL_MCP_BUILD__) { + // eslint-disable-next-line no-inner-declarations + function __internal_only_getComponentTree(): string { + let treeString = ''; + + function buildTreeString( + instance: DevToolsInstance, + prefix: string = '', + isLastChild: boolean = true, + ): void { + if (!instance) return; + + const name = + (instance.kind !== VIRTUAL_INSTANCE + ? getDisplayNameForFiber(instance.data) + : instance.data.name) || 'Unknown'; + + const id = instance.id !== undefined ? instance.id : 'unknown'; + + if (name !== 'createRoot()') { + treeString += + prefix + + (isLastChild ? '└── ' : '├── ') + + name + + ' (id: ' + + id + + ')\n'; + } + + const childPrefix = prefix + (isLastChild ? ' ' : '│ '); + + let childCount = 0; + let tempChild = instance.firstChild; + while (tempChild !== null) { + childCount++; + tempChild = tempChild.nextSibling; + } + + let child = instance.firstChild; + let currentChildIndex = 0; + + while (child !== null) { + currentChildIndex++; + const isLastSibling = currentChildIndex === childCount; + buildTreeString(child, childPrefix, isLastSibling); + child = child.nextSibling; + } + } + + const rootInstances: Array = []; + idToDevToolsInstanceMap.forEach(instance => { + if (instance.parent === null || instance.parent.parent === null) { + rootInstances.push(instance); + } + }); + + if (rootInstances.length > 0) { + for (let i = 0; i < rootInstances.length; i++) { + const isLast = i === rootInstances.length - 1; + buildTreeString(rootInstances[i], '', isLast); + if (!isLast) { + treeString += '\n'; + } + } + } else { + treeString = 'No component tree found.'; + } + + return treeString; + } + + internalMcpFunctions.__internal_only_getComponentTree = + __internal_only_getComponentTree; + } + return { cleanup, clearErrorsAndWarnings, @@ -5898,5 +5978,6 @@ export function attach( storeAsGlobal, updateComponentFilters, getEnvironmentNames, + ...internalMcpFunctions, }; } diff --git a/scripts/flow/react-devtools.js b/scripts/flow/react-devtools.js index 4e0f2a915ede6..09a251bbe2f5f 100644 --- a/scripts/flow/react-devtools.js +++ b/scripts/flow/react-devtools.js @@ -16,5 +16,6 @@ declare const __IS_FIREFOX__: boolean; declare const __IS_CHROME__: boolean; declare const __IS_EDGE__: boolean; declare const __IS_NATIVE__: boolean; +declare const __IS_INTERNAL_MCP_BUILD__: boolean; declare const chrome: any; diff --git a/scripts/jest/devtools/setupEnv.js b/scripts/jest/devtools/setupEnv.js index a797c0951435f..32bf13e686c77 100644 --- a/scripts/jest/devtools/setupEnv.js +++ b/scripts/jest/devtools/setupEnv.js @@ -15,6 +15,7 @@ global.__IS_FIREFOX__ = false; global.__IS_CHROME__ = false; global.__IS_EDGE__ = false; global.__IS_NATIVE__ = false; +global.__IS_INTERNAL_MCP_BUILD__ = false; const ReactVersionTestingAgainst = process.env.REACT_VERSION || ReactVersion;