main
js 395 lines 12.8 KB
Raw
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 }