| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | |
| 10 | import {installFacade, createTools} from 'react-devtools-facade'; |
| 11 | |
| 12 | import type {Facade, Tools} from 'react-devtools-facade'; |
| 13 | |
| 14 | // A tool definition: its chrome-devtools-mcp metadata plus how to invoke the |
| 15 | // underlying react-devtools-facade tool. `call` maps the params object that |
| 16 | // chrome-devtools-mcp passes (parsed + ajv-validated against inputSchema) onto |
| 17 | // the facade tool's positional arguments. |
| 18 | type ToolDefinition = { |
| 19 | name: string, |
| 20 | description: string, |
| 21 | inputSchema: {[string]: mixed}, |
| 22 | call: (tools: Tools, args: {[string]: any}) => mixed, |
| 23 | }; |
| 24 | |
| 25 | function formatError(error: mixed): string { |
| 26 | if (error instanceof Error) { |
| 27 | const cause = (error as any).cause; |
| 28 | if (cause != null) { |
| 29 | return error.message + ' Cause: ' + formatError(cause); |
| 30 | } |
| 31 | return error.message; |
| 32 | } |
| 33 | return String(error); |
| 34 | } |
| 35 | |
| 36 | function normalizeToolResult(result: mixed): mixed { |
| 37 | if (result != null && typeof result === 'object') { |
| 38 | const objectResult = result as {+[string]: mixed}; |
| 39 | if ('error' in objectResult && typeof objectResult.error !== 'string') { |
| 40 | return { |
| 41 | ...objectResult, |
| 42 | error: formatError(objectResult.error), |
| 43 | }; |
| 44 | } |
| 45 | } |
| 46 | return result; |
| 47 | } |
| 48 | |
| 49 | function getComponentForDomElement(tools: Tools, element: mixed): mixed { |
| 50 | const result = tools.getComponentByHostInstance(element); |
| 51 | if ( |
| 52 | result != null && |
| 53 | typeof result === 'object' && |
| 54 | typeof (result as any).error === 'string' |
| 55 | ) { |
| 56 | const error = (result as any).error; |
| 57 | if (error === 'Host instance is required') { |
| 58 | return {error: 'DOM element is required'}; |
| 59 | } |
| 60 | if (error === 'Host instance is not managed by React') { |
| 61 | return {error: 'DOM element is not managed by React'}; |
| 62 | } |
| 63 | } |
| 64 | return result; |
| 65 | } |
| 66 | |
| 67 | const TOOL_DEFINITIONS: Array<ToolDefinition> = [ |
| 68 | { |
| 69 | name: 'react_get_component_tree', |
| 70 | description: |
| 71 | 'Snapshot of the React component tree as {nodes}, where nodes is a ' + |
| 72 | 'flat array of {uid, type, name, key, firstChild, nextSibling}. ' + |
| 73 | 'firstChild/nextSibling link nodes by uid. uid is the stable handle ' + |
| 74 | 'the other tools accept. Covers every mounted root unless rootUid ' + |
| 75 | 'scopes the walk (depth defaults to 20).', |
| 76 | inputSchema: { |
| 77 | type: 'object', |
| 78 | properties: { |
| 79 | depth: { |
| 80 | type: 'number', |
| 81 | description: 'Maximum tree depth to traverse (default 20).', |
| 82 | }, |
| 83 | rootUid: { |
| 84 | type: 'string', |
| 85 | description: |
| 86 | 'Start the snapshot from this component uid (e.g. "r5").', |
| 87 | }, |
| 88 | }, |
| 89 | }, |
| 90 | call: (tools, args) => { |
| 91 | const nodesOrError = tools.getComponentTree(args.depth, args.rootUid); |
| 92 | return Array.isArray(nodesOrError) ? {nodes: nodesOrError} : nodesOrError; |
| 93 | }, |
| 94 | }, |
| 95 | { |
| 96 | name: 'react_get_component_by_uid', |
| 97 | description: |
| 98 | 'Detailed info for one component by uid: ' + |
| 99 | '{uid, type, name, key?, props?, hooks?}. props excludes children and ' + |
| 100 | 'is normalized for serialization; when includeHooks is true, hooks is ' + |
| 101 | 'the nested hooks tree ({id, name, value, subHooks}), present only for ' + |
| 102 | 'function/forwardRef/memo components.', |
| 103 | inputSchema: { |
| 104 | type: 'object', |
| 105 | properties: { |
| 106 | uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, |
| 107 | includeHooks: { |
| 108 | type: 'boolean', |
| 109 | description: |
| 110 | 'Whether to inspect hooks for function components (default false).', |
| 111 | }, |
| 112 | }, |
| 113 | required: ['uid'], |
| 114 | }, |
| 115 | call: (tools, args) => |
| 116 | tools.getComponentByUid(args.uid, args.includeHooks === true), |
| 117 | }, |
| 118 | { |
| 119 | name: 'react_get_component_by_dom_element', |
| 120 | description: |
| 121 | 'Detailed info for one React DOM component by DOM element reference: ' + |
| 122 | '{uid, type, name, key?, props?, hooks?}. The element is an opaque ' + |
| 123 | 'page-side reference, such as the currently selected Chrome DevTools ' + |
| 124 | 'element.', |
| 125 | inputSchema: { |
| 126 | type: 'object', |
| 127 | properties: { |
| 128 | element: { |
| 129 | type: 'object', |
| 130 | 'x-mcp-type': 'HTMLElement', |
| 131 | description: |
| 132 | 'DOM element reference from the Chrome DevTools MCP page snapshot.', |
| 133 | }, |
| 134 | }, |
| 135 | required: ['element'], |
| 136 | }, |
| 137 | call: (tools, args) => getComponentForDomElement(tools, args.element), |
| 138 | }, |
| 139 | { |
| 140 | name: 'react_find_components', |
| 141 | description: |
| 142 | 'Find components whose name contains a substring (case-insensitive). ' + |
| 143 | 'Paginated: {page, pageSize, totalCount, totalPages, results}, where ' + |
| 144 | 'results are tree nodes. rootUid limits the search to a subtree.', |
| 145 | inputSchema: { |
| 146 | type: 'object', |
| 147 | properties: { |
| 148 | name: {type: 'string', description: 'Name substring to match.'}, |
| 149 | rootUid: { |
| 150 | type: 'string', |
| 151 | description: "Limit the search to this component's subtree.", |
| 152 | }, |
| 153 | page: {type: 'number', description: 'Page number (default 1).'}, |
| 154 | pageSize: { |
| 155 | type: 'number', |
| 156 | description: 'Results per page (default 10).', |
| 157 | }, |
| 158 | }, |
| 159 | required: ['name'], |
| 160 | }, |
| 161 | call: (tools, args) => |
| 162 | tools.findComponents(args.name, args.rootUid, args.page, args.pageSize), |
| 163 | }, |
| 164 | { |
| 165 | name: 'react_get_component_source', |
| 166 | description: |
| 167 | 'Source location where a component is defined: ' + |
| 168 | '{source: {name, fileName, line, column}}, or {source: null} when ' + |
| 169 | 'unavailable (e.g. host components or production builds).', |
| 170 | inputSchema: { |
| 171 | type: 'object', |
| 172 | properties: { |
| 173 | uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, |
| 174 | }, |
| 175 | required: ['uid'], |
| 176 | }, |
| 177 | call: (tools, args) => tools.getComponentSource(args.uid), |
| 178 | }, |
| 179 | { |
| 180 | name: 'react_get_owner_stack_trace', |
| 181 | description: |
| 182 | 'Owner stack trace for a component — the chain of JSX creation sites up ' + |
| 183 | 'to the root, as a raw string ({stack}) for source-map symbolication. ' + |
| 184 | 'DEV-only (empty in production).', |
| 185 | inputSchema: { |
| 186 | type: 'object', |
| 187 | properties: { |
| 188 | uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, |
| 189 | }, |
| 190 | required: ['uid'], |
| 191 | }, |
| 192 | call: (tools, args) => tools.getOwnerStackTrace(args.uid), |
| 193 | }, |
| 194 | { |
| 195 | name: 'react_get_parent_stack', |
| 196 | description: |
| 197 | 'Rendered parent chain for a component, from immediate parent to root: ' + |
| 198 | 'an array of {uid, name, type}. Parents describe where the node is ' + |
| 199 | 'mounted in the rendered component tree and may include host DOM ' + |
| 200 | 'components and the root. This differs from owners, which describe JSX ' + |
| 201 | 'creation/render ownership.', |
| 202 | inputSchema: { |
| 203 | type: 'object', |
| 204 | properties: { |
| 205 | uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, |
| 206 | }, |
| 207 | required: ['uid'], |
| 208 | }, |
| 209 | call: (tools, args) => tools.getParentStack(args.uid), |
| 210 | }, |
| 211 | { |
| 212 | name: 'react_get_owner_stack', |
| 213 | description: |
| 214 | 'JSX owner chain for a component, from immediate owner to root owner: ' + |
| 215 | 'an array of {uid, name, type} (empty for a root component). Owners ' + |
| 216 | 'describe which components created/rendered this element through JSX, ' + |
| 217 | 'not where it is mounted in the rendered component tree. This DEV-only metadata ' + |
| 218 | 'differs from structural parents.', |
| 219 | inputSchema: { |
| 220 | type: 'object', |
| 221 | properties: { |
| 222 | uid: {type: 'string', description: 'Component uid, e.g. "r5".'}, |
| 223 | }, |
| 224 | required: ['uid'], |
| 225 | }, |
| 226 | call: (tools, args) => tools.getOwnerStack(args.uid), |
| 227 | }, |
| 228 | { |
| 229 | name: 'react_start_profiling', |
| 230 | description: |
| 231 | 'Start a profiling session that records render timing on every commit. ' + |
| 232 | 'Returns {status: "started", traceName}; errors if a session is already ' + |
| 233 | 'active.', |
| 234 | inputSchema: { |
| 235 | type: 'object', |
| 236 | properties: { |
| 237 | traceName: { |
| 238 | type: 'string', |
| 239 | description: 'Trace name (auto-generated if omitted).', |
| 240 | }, |
| 241 | }, |
| 242 | }, |
| 243 | call: (tools, args) => tools.startProfiling(args.traceName), |
| 244 | }, |
| 245 | { |
| 246 | name: 'react_stop_profiling', |
| 247 | description: |
| 248 | 'Stop the active profiling session. Returns ' + |
| 249 | '{status: "stopped", traceName, commits} (commits recorded); errors if none ' + |
| 250 | 'is active.', |
| 251 | inputSchema: {type: 'object', properties: {}}, |
| 252 | call: tools => tools.stopProfiling(), |
| 253 | }, |
| 254 | { |
| 255 | name: 'react_get_trace_overview', |
| 256 | description: |
| 257 | 'Per-commit overview of a trace — one row each: ' + |
| 258 | '{commit, committedAt, renderDuration, layoutDuration, ' + |
| 259 | 'passiveDuration, componentsChanged}. Durations are in ms (null if the ' + |
| 260 | 'build omits profiler timing).', |
| 261 | inputSchema: { |
| 262 | type: 'object', |
| 263 | properties: { |
| 264 | traceName: {type: 'string', description: 'The trace to query.'}, |
| 265 | }, |
| 266 | required: ['traceName'], |
| 267 | }, |
| 268 | call: (tools, args) => tools.getTraceOverview(args.traceName), |
| 269 | }, |
| 270 | { |
| 271 | name: 'react_get_commit_report', |
| 272 | description: |
| 273 | 'Detailed report for one commit of a trace: ' + |
| 274 | '{committedAt, priority, renderDuration, layoutDuration, ' + |
| 275 | 'passiveDuration, components}, where components is ' + |
| 276 | '{uid, name, type, actualDuration, selfDuration} sorted by ' + |
| 277 | 'actualDuration descending.', |
| 278 | inputSchema: { |
| 279 | type: 'object', |
| 280 | properties: { |
| 281 | traceName: {type: 'string', description: 'The trace to query.'}, |
| 282 | commitIndex: { |
| 283 | type: 'number', |
| 284 | description: 'Zero-based commit index within the trace.', |
| 285 | }, |
| 286 | }, |
| 287 | required: ['traceName', 'commitIndex'], |
| 288 | }, |
| 289 | call: (tools, args) => |
| 290 | tools.getCommitReport(args.traceName, args.commitIndex), |
| 291 | }, |
| 292 | ]; |
| 293 | |
| 294 | // A chrome-devtools-mcp third-party tool. `execute` runs in the page and |
| 295 | // returns the facade tool's result, which chrome-devtools-mcp forwards to the |
| 296 | // MCP client. |
| 297 | export type CdtMcpTool = { |
| 298 | name: string, |
| 299 | description: string, |
| 300 | inputSchema: {[string]: mixed}, |
| 301 | execute: (args: {[string]: any}) => mixed, |
| 302 | }; |
| 303 | |
| 304 | export type CdtMcpToolGroup = { |
| 305 | name: string, |
| 306 | description: string, |
| 307 | tools: Array<CdtMcpTool>, |
| 308 | }; |
| 309 | |
| 310 | type Registration = { |
| 311 | facade: Facade, |
| 312 | unregister: () => void, |
| 313 | }; |
| 314 | |
| 315 | type ToolDiscoveryEvent = { |
| 316 | respondWith: (toolGroup: CdtMcpToolGroup) => void, |
| 317 | }; |
| 318 | |
| 319 | const registrations: WeakMap<any, Registration> = new WeakMap(); |
| 320 | |
| 321 | /** |
| 322 | * Build the chrome-devtools-mcp tool group from an assembled set of facade |
| 323 | * tools. Each tool returns its facade result directly. |
| 324 | */ |
| 325 | export function buildToolGroup(tools: Tools): CdtMcpToolGroup { |
| 326 | return { |
| 327 | name: 'react', |
| 328 | description: |
| 329 | 'Inspect and profile the running React app. Components are addressed by ' + |
| 330 | 'stable uids (e.g. "r5") from react_get_component_tree; pass a uid ' + |
| 331 | 'to the other tools.', |
| 332 | tools: TOOL_DEFINITIONS.map(definition => ({ |
| 333 | name: definition.name, |
| 334 | description: definition.description, |
| 335 | inputSchema: definition.inputSchema, |
| 336 | execute: (args: {[string]: any}) => |
| 337 | normalizeToolResult(definition.call(tools, args || {})), |
| 338 | })), |
| 339 | }; |
| 340 | } |
| 341 | |
| 342 | /** |
| 343 | * Install the facade and register the React tools with chrome-devtools-mcp. |
| 344 | * |
| 345 | * The facade is installed EAGERLY (synchronously, when this runs) so the |
| 346 | * DevTools hook is in place before React initializes. The tools, however, are |
| 347 | * built LAZILY inside the `devtoolstooldiscovery` handler — no tool work |
| 348 | * happens until chrome-devtools-mcp actually discovers them. The tool group is |
| 349 | * memoized so component uids stay stable across repeated discovery. |
| 350 | * |
| 351 | * chrome-devtools-mcp dispatches a `devtoolstooldiscovery` event and expects a |
| 352 | * synchronous `respondWith(toolGroup)`. |
| 353 | * |
| 354 | * Best run once per page before React initializes so the first commit is |
| 355 | * captured. If a DevTools hook is already installed (e.g. the React DevTools |
| 356 | * browser extension), installFacade attaches to it instead of installing a |
| 357 | * second one. Returns the Facade and an `unregister` function that removes the |
| 358 | * discovery listener. |
| 359 | */ |
| 360 | export function register(target?: any = globalThis): { |
| 361 | facade: Facade, |
| 362 | unregister: () => void, |
| 363 | } { |
| 364 | const existingRegistration: Registration | void = registrations.get(target); |
| 365 | if (existingRegistration !== undefined) { |
| 366 | return existingRegistration; |
| 367 | } |
| 368 | |
| 369 | const facade = installFacade(target); |
| 370 | |
| 371 | let toolGroup: CdtMcpToolGroup | null = null; |
| 372 | const listener = (event: ToolDiscoveryEvent) => { |
| 373 | if (toolGroup === null) { |
| 374 | toolGroup = buildToolGroup(createTools(facade)); |
| 375 | } |
| 376 | event.respondWith(toolGroup); |
| 377 | }; |
| 378 | target.addEventListener('devtoolstooldiscovery', listener); |
| 379 | |
| 380 | let isRegistered = true; |
| 381 | const registration: Registration = { |
| 382 | facade, |
| 383 | unregister: () => { |
| 384 | if (!isRegistered) { |
| 385 | return; |
| 386 | } |
| 387 | isRegistered = false; |
| 388 | target.removeEventListener('devtoolstooldiscovery', listener); |
| 389 | registrations.delete(target); |
| 390 | }, |
| 391 | }; |
| 392 | |
| 393 | registrations.set(target, registration); |
| 394 | return registration; |
| 395 | } |