/** * 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. * * @flow */ import {installFacade, createTools} from 'react-devtools-facade'; import type {Facade, Tools} from 'react-devtools-facade'; // A tool definition: its chrome-devtools-mcp metadata plus how to invoke the // underlying react-devtools-facade tool. `call` maps the params object that // chrome-devtools-mcp passes (parsed + ajv-validated against inputSchema) onto // the facade tool's positional arguments. type ToolDefinition = { name: string, description: string, inputSchema: {[string]: mixed}, call: (tools: Tools, args: {[string]: any}) => mixed, }; function formatError(error: mixed): string { if (error instanceof Error) { const cause = (error as any).cause; if (cause != null) { return error.message + ' Cause: ' + formatError(cause); } return error.message; } return String(error); } function normalizeToolResult(result: mixed): mixed { if (result != null && typeof result === 'object') { const objectResult = result as {+[string]: mixed}; if ('error' in objectResult && typeof objectResult.error !== 'string') { return { ...objectResult, error: formatError(objectResult.error), }; } } return result; } function getComponentForDomElement(tools: Tools, element: mixed): mixed { const result = tools.getComponentByHostInstance(element); if ( result != null && typeof result === 'object' && typeof (result as any).error === 'string' ) { const error = (result as any).error; if (error === 'Host instance is required') { return {error: 'DOM element is required'}; } if (error === 'Host instance is not managed by React') { return {error: 'DOM element is not managed by React'}; } } return result; } const TOOL_DEFINITIONS: Array = [ { name: 'react_get_component_tree', description: 'Snapshot of the React component tree as {nodes}, where nodes is a ' + 'flat array of {uid, type, name, key, firstChild, nextSibling}. ' + 'firstChild/nextSibling link nodes by uid. uid is the stable handle ' + 'the other tools accept. Covers every mounted root unless rootUid ' + 'scopes the walk (depth defaults to 20).', inputSchema: { type: 'object', properties: { depth: { type: 'number', description: 'Maximum tree depth to traverse (default 20).', }, rootUid: { type: 'string', description: 'Start the snapshot from this component uid (e.g. "r5").', }, }, }, call: (tools, args) => { const nodesOrError = tools.getComponentTree(args.depth, args.rootUid); return Array.isArray(nodesOrError) ? {nodes: nodesOrError} : nodesOrError; }, }, { name: 'react_get_component_by_uid', description: 'Detailed info for one component by uid: ' + '{uid, type, name, key?, props?, hooks?}. props excludes children and ' + 'is normalized for serialization; when includeHooks is true, hooks is ' + 'the nested hooks tree ({id, name, value, subHooks}), present only for ' + 'function/forwardRef/memo components.', inputSchema: { type: 'object', properties: { uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, includeHooks: { type: 'boolean', description: 'Whether to inspect hooks for function components (default false).', }, }, required: ['uid'], }, call: (tools, args) => tools.getComponentByUid(args.uid, args.includeHooks === true), }, { name: 'react_get_component_by_dom_element', description: 'Detailed info for one React DOM component by DOM element reference: ' + '{uid, type, name, key?, props?, hooks?}. The element is an opaque ' + 'page-side reference, such as the currently selected Chrome DevTools ' + 'element.', inputSchema: { type: 'object', properties: { element: { type: 'object', 'x-mcp-type': 'HTMLElement', description: 'DOM element reference from the Chrome DevTools MCP page snapshot.', }, }, required: ['element'], }, call: (tools, args) => getComponentForDomElement(tools, args.element), }, { name: 'react_find_components', description: 'Find components whose name contains a substring (case-insensitive). ' + 'Paginated: {page, pageSize, totalCount, totalPages, results}, where ' + 'results are tree nodes. rootUid limits the search to a subtree.', inputSchema: { type: 'object', properties: { name: {type: 'string', description: 'Name substring to match.'}, rootUid: { type: 'string', description: "Limit the search to this component's subtree.", }, page: {type: 'number', description: 'Page number (default 1).'}, pageSize: { type: 'number', description: 'Results per page (default 10).', }, }, required: ['name'], }, call: (tools, args) => tools.findComponents(args.name, args.rootUid, args.page, args.pageSize), }, { name: 'react_get_component_source', description: 'Source location where a component is defined: ' + '{source: {name, fileName, line, column}}, or {source: null} when ' + 'unavailable (e.g. host components or production builds).', inputSchema: { type: 'object', properties: { uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, }, required: ['uid'], }, call: (tools, args) => tools.getComponentSource(args.uid), }, { name: 'react_get_owner_stack_trace', description: 'Owner stack trace for a component — the chain of JSX creation sites up ' + 'to the root, as a raw string ({stack}) for source-map symbolication. ' + 'DEV-only (empty in production).', inputSchema: { type: 'object', properties: { uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, }, required: ['uid'], }, call: (tools, args) => tools.getOwnerStackTrace(args.uid), }, { name: 'react_get_parent_stack', description: 'Rendered parent chain for a component, from immediate parent to root: ' + 'an array of {uid, name, type}. Parents describe where the node is ' + 'mounted in the rendered component tree and may include host DOM ' + 'components and the root. This differs from owners, which describe JSX ' + 'creation/render ownership.', inputSchema: { type: 'object', properties: { uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, }, required: ['uid'], }, call: (tools, args) => tools.getParentStack(args.uid), }, { name: 'react_get_owner_stack', description: 'JSX owner chain for a component, from immediate owner to root owner: ' + 'an array of {uid, name, type} (empty for a root component). Owners ' + 'describe which components created/rendered this element through JSX, ' + 'not where it is mounted in the rendered component tree. This DEV-only metadata ' + 'differs from structural parents.', inputSchema: { type: 'object', properties: { uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, }, required: ['uid'], }, call: (tools, args) => tools.getOwnerStack(args.uid), }, { name: 'react_start_profiling', description: 'Start a profiling session that records render timing on every commit. ' + 'Returns {status: "started", traceName}; errors if a session is already ' + 'active.', inputSchema: { type: 'object', properties: { traceName: { type: 'string', description: 'Trace name (auto-generated if omitted).', }, }, }, call: (tools, args) => tools.startProfiling(args.traceName), }, { name: 'react_stop_profiling', description: 'Stop the active profiling session. Returns ' + '{status: "stopped", traceName, commits} (commits recorded); errors if none ' + 'is active.', inputSchema: {type: 'object', properties: {}}, call: tools => tools.stopProfiling(), }, { name: 'react_get_trace_overview', description: 'Per-commit overview of a trace — one row each: ' + '{commit, committedAt, renderDuration, layoutDuration, ' + 'passiveDuration, componentsChanged}. Durations are in ms (null if the ' + 'build omits profiler timing).', inputSchema: { type: 'object', properties: { traceName: {type: 'string', description: 'The trace to query.'}, }, required: ['traceName'], }, call: (tools, args) => tools.getTraceOverview(args.traceName), }, { name: 'react_get_commit_report', description: 'Detailed report for one commit of a trace: ' + '{committedAt, priority, renderDuration, layoutDuration, ' + 'passiveDuration, components}, where components is ' + '{uid, name, type, actualDuration, selfDuration} sorted by ' + 'actualDuration descending.', inputSchema: { type: 'object', properties: { traceName: {type: 'string', description: 'The trace to query.'}, commitIndex: { type: 'number', description: 'Zero-based commit index within the trace.', }, }, required: ['traceName', 'commitIndex'], }, call: (tools, args) => tools.getCommitReport(args.traceName, args.commitIndex), }, ]; // A chrome-devtools-mcp third-party tool. `execute` runs in the page and // returns the facade tool's result, which chrome-devtools-mcp forwards to the // MCP client. export type CdtMcpTool = { name: string, description: string, inputSchema: {[string]: mixed}, execute: (args: {[string]: any}) => mixed, }; export type CdtMcpToolGroup = { name: string, description: string, tools: Array, }; type Registration = { facade: Facade, unregister: () => void, }; type ToolDiscoveryEvent = { respondWith: (toolGroup: CdtMcpToolGroup) => void, }; const registrations: WeakMap = new WeakMap(); /** * Build the chrome-devtools-mcp tool group from an assembled set of facade * tools. Each tool returns its facade result directly. */ export function buildToolGroup(tools: Tools): CdtMcpToolGroup { return { name: 'react', description: 'Inspect and profile the running React app. Components are addressed by ' + 'stable uids (e.g. "r5") from react_get_component_tree; pass a uid ' + 'to the other tools.', tools: TOOL_DEFINITIONS.map(definition => ({ name: definition.name, description: definition.description, inputSchema: definition.inputSchema, execute: (args: {[string]: any}) => normalizeToolResult(definition.call(tools, args || {})), })), }; } /** * Install the facade and register the React tools with chrome-devtools-mcp. * * The facade is installed EAGERLY (synchronously, when this runs) so the * DevTools hook is in place before React initializes. The tools, however, are * built LAZILY inside the `devtoolstooldiscovery` handler — no tool work * happens until chrome-devtools-mcp actually discovers them. The tool group is * memoized so component uids stay stable across repeated discovery. * * chrome-devtools-mcp dispatches a `devtoolstooldiscovery` event and expects a * synchronous `respondWith(toolGroup)`. * * Best run once per page before React initializes so the first commit is * captured. If a DevTools hook is already installed (e.g. the React DevTools * browser extension), installFacade attaches to it instead of installing a * second one. Returns the Facade and an `unregister` function that removes the * discovery listener. */ export function register(target?: any = globalThis): { facade: Facade, unregister: () => void, } { const existingRegistration: Registration | void = registrations.get(target); if (existingRegistration !== undefined) { return existingRegistration; } const facade = installFacade(target); let toolGroup: CdtMcpToolGroup | null = null; const listener = (event: ToolDiscoveryEvent) => { if (toolGroup === null) { toolGroup = buildToolGroup(createTools(facade)); } event.respondWith(toolGroup); }; target.addEventListener('devtoolstooldiscovery', listener); let isRegistered = true; const registration: Registration = { facade, unregister: () => { if (!isRegistered) { return; } isRegistered = false; target.removeEventListener('devtoolstooldiscovery', listener); registrations.delete(target); }, }; registrations.set(target, registration); return registration; }