cocos-web-inspector-mcp 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,9 @@ import { readFileSync } from 'node:fs';
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { z } from 'zod';
4
4
  import { InspectorError, sanitizeUrl } from './browser.js';
5
- import { captureNode, clickNode, devicePresetNames, emulateDevice, inspectCocosPage, networkProfileNames, runBridge } from './bridge.js';
5
+ import { nativeClick, nativeConsoleMessages, nativeDrag, nativeTypeText } from './native.js';
6
+ import { consoleMessages, networkRequest, networkRequests, storage } from './browser-data.js';
7
+ import { captureNode, clickNode, devicePresetNames, dragNode, emulateDevice, inspectCocosPage, networkProfileNames, runBridge, typeText } from './bridge.js';
6
8
  const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
7
9
  const pageUrl = z.url().optional();
8
10
  const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
@@ -21,6 +23,21 @@ const propertyValue = z.union([
21
23
  z.object({ width: finiteNumber, height: finiteNumber }).strict(),
22
24
  z.object({ r: finiteNumber, g: finiteNumber, b: finiteNumber, a: finiteNumber }).strict(),
23
25
  ]);
26
+ // Every node tool takes uuid or path. Paths survive reloads but are not unique, so a path must match exactly one node.
27
+ const target = { uuid: z.string().min(1).max(200).optional(), path: z.string().min(1).max(10_000).optional() };
28
+ const oneTarget = (value) => (value.uuid === undefined) !== (value.path === undefined);
29
+ const oneTargetMessage = 'Provide uuid or path, not both';
30
+ async function resolveUuid(page, input) {
31
+ if (input.uuid)
32
+ return input.uuid;
33
+ const found = await runBridge(page, { action: 'findNode', path: input.path, limit: 10 });
34
+ const matches = found.matches ?? [];
35
+ if (matches.length === 0)
36
+ throw new Error(found.truncated ? 'Node not found within traversal limit' : 'Node not found');
37
+ if (matches.length > 1)
38
+ throw new InspectorError('AMBIGUOUS_NODE', `Path matches ${found.truncated ? 'more than 10' : matches.length} nodes; pass one uuid: ${matches.map(match => match.uuid).join(', ')}`);
39
+ return matches[0].uuid;
40
+ }
24
41
  function response(data) {
25
42
  return {
26
43
  content: [{ type: 'text', text: JSON.stringify(data) }],
@@ -44,6 +61,10 @@ function failure(error) {
44
61
  }
45
62
  export function createServer(browser, options = {}) {
46
63
  const server = new McpServer({ name: 'cocos-web-inspector-mcp', version });
64
+ // A native (JSB) runtime has no page: screenshots, device emulation, and network/cookie readers need a browser,
65
+ // the DOM overlays draw nothing, the 2D batcher runs in C++ where analyze_batches cannot observe it,
66
+ // and game.step renders outside the native frame loop, which crashed a 3.8.8 Android build in the GFX pipeline.
67
+ const web = !options.native;
47
68
  const execute = async (request, selectedPage) => {
48
69
  try {
49
70
  return response(await runBridge(await browser.page(selectedPage), request));
@@ -52,6 +73,16 @@ export function createServer(browser, options = {}) {
52
73
  return failure(error);
53
74
  }
54
75
  };
76
+ const withNode = async (input, work) => {
77
+ try {
78
+ const page = await browser.page(input.pageUrl);
79
+ return response(await work(page, await resolveUuid(page, input)));
80
+ }
81
+ catch (error) {
82
+ return failure(error);
83
+ }
84
+ };
85
+ const executeNode = (input, build) => withNode(input, (page, uuid) => runBridge(page, build(uuid)));
55
86
  server.registerTool('cocos_list_pages', {
56
87
  description: 'List bounded summaries of eligible localhost Chromium pages.',
57
88
  inputSchema: z.object({}).strict(),
@@ -92,97 +123,97 @@ export function createServer(browser, options = {}) {
92
123
  }, input => execute({ action: 'findNode', uuid: input.uuid, name: input.name, path: input.path, nameContains: input.nameContains, componentType: input.componentType, active: input.active, pathPrefix: input.pathPrefix, limit: input.limit }, input.pageUrl));
93
124
  server.registerTool('cocos_get_node', {
94
125
  description: 'Return bounded context for one exact Cocos node UUID.',
95
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1) }).strict(),
126
+ inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
96
127
  annotations: readOnly,
97
- }, input => execute({ action: 'getNode', uuid: input.uuid }, input.pageUrl));
128
+ }, input => executeNode(input, uuid => ({ action: 'getNode', uuid })));
98
129
  server.registerTool('cocos_snapshot_subtree', {
99
130
  description: 'Return a bounded stateless subtree snapshot for one exact Cocos node UUID.',
100
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1), maxDepth: z.number().int().min(0).max(20).optional(), maxNodes: z.number().int().min(1).max(5_000).optional() }).strict(),
131
+ inputSchema: z.object({ pageUrl, ...target, maxDepth: z.number().int().min(0).max(20).optional(), maxNodes: z.number().int().min(1).max(5_000).optional() }).strict().refine(oneTarget, oneTargetMessage),
101
132
  annotations: readOnly,
102
- }, input => execute({ action: 'snapshotSubtree', uuid: input.uuid, maxDepth: input.maxDepth, maxNodes: input.maxNodes }, input.pageUrl));
133
+ }, input => executeNode(input, uuid => ({ action: 'snapshotSubtree', uuid, maxDepth: input.maxDepth, maxNodes: input.maxNodes })));
103
134
  server.registerTool('cocos_get_node_bounds', {
104
135
  description: 'Return bounded canvas and viewport bounds for one Cocos UI node UUID.',
105
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1) }).strict(),
106
- annotations: readOnly,
107
- }, input => execute({ action: 'getNodeBounds', uuid: input.uuid }, input.pageUrl));
108
- server.registerTool('cocos_capture_node', {
109
- description: 'Capture a bounded viewport-clipped PNG for one visible Cocos UI node UUID.',
110
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1) }).strict(),
136
+ inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
111
137
  annotations: readOnly,
112
- }, async (input) => {
113
- try {
114
- return response(await captureNode(await browser.page(input.pageUrl), input.uuid));
115
- }
116
- catch (error) {
117
- return failure(error);
118
- }
119
- });
138
+ }, input => executeNode(input, uuid => ({ action: 'getNodeBounds', uuid })));
139
+ if (web)
140
+ server.registerTool('cocos_capture_node', {
141
+ description: 'Capture a bounded viewport-clipped PNG for one visible Cocos UI node UUID.',
142
+ inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
143
+ annotations: readOnly,
144
+ }, input => withNode(input, captureNode));
120
145
  server.registerTool('cocos_get_components', {
121
146
  description: 'List bounded component summaries for one Cocos node UUID.',
122
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1) }).strict(),
147
+ inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
123
148
  annotations: readOnly,
124
- }, input => execute({ action: 'getComponents', uuid: input.uuid }, input.pageUrl));
149
+ }, input => executeNode(input, uuid => ({ action: 'getComponents', uuid })));
125
150
  server.registerTool('cocos_get_properties', {
126
151
  description: 'Read bounded, cycle-safe public properties from a Cocos node or one component.',
127
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1), componentType: z.string().min(1).optional(), componentUuid: z.string().min(1).optional(), maxDepth: z.number().int().min(0).max(6).optional() }).strict()
152
+ inputSchema: z.object({ pageUrl, ...target, componentType: z.string().min(1).optional(), componentUuid: z.string().min(1).optional(), maxDepth: z.number().int().min(0).max(6).optional() }).strict().refine(oneTarget, oneTargetMessage)
128
153
  .refine(value => !(value.componentType && value.componentUuid), 'Provide componentType or componentUuid, not both'),
129
154
  annotations: readOnly,
130
- }, input => execute({ action: 'getProperties', uuid: input.uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: input.maxDepth }, input.pageUrl));
155
+ }, input => executeNode(input, uuid => ({ action: 'getProperties', uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: input.maxDepth })));
131
156
  server.registerTool('cocos_wait_for_property', {
132
157
  description: 'Poll one top-level property of a Cocos node or component until it equals a value or the timeout passes.',
133
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1), componentType: z.string().min(1).optional(), componentUuid: z.string().min(1).optional(), key: z.string().min(1).max(200), equals: z.union([z.boolean(), finiteNumber, z.string().max(2_000), z.null()]), timeoutMs: z.number().int().min(100).max(30_000).optional(), intervalMs: z.number().int().min(50).max(5_000).optional() }).strict()
158
+ inputSchema: z.object({ pageUrl, ...target, componentType: z.string().min(1).optional(), componentUuid: z.string().min(1).optional(), key: z.string().min(1).max(200), equals: z.union([z.boolean(), finiteNumber, z.string().max(2_000), z.null()]), timeoutMs: z.number().int().min(100).max(30_000).optional(), intervalMs: z.number().int().min(50).max(5_000).optional() }).strict().refine(oneTarget, oneTargetMessage)
134
159
  .refine(value => !(value.componentType && value.componentUuid), 'Provide componentType or componentUuid, not both'),
135
160
  annotations: readOnly,
136
- }, async (input) => {
137
- try {
138
- const page = await browser.page(input.pageUrl);
139
- const request = { action: 'getProperties', uuid: input.uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: 0 };
140
- const deadline = Date.now() + (input.timeoutMs ?? 5_000);
141
- let polls = 0;
142
- let value;
143
- for (;;) {
144
- polls++;
145
- value = (await runBridge(page, request)).properties?.[input.key];
146
- if (value === input.equals || Date.now() >= deadline)
147
- break;
148
- await new Promise(resolve => setTimeout(resolve, input.intervalMs ?? 200));
149
- }
150
- return response({ matched: value === input.equals, key: input.key, value: value ?? null, polls });
161
+ }, input => withNode(input, async (page, uuid) => {
162
+ const request = { action: 'getProperties', uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: 0 };
163
+ const deadline = Date.now() + (input.timeoutMs ?? 5_000);
164
+ let polls = 0;
165
+ let value;
166
+ for (;;) {
167
+ polls++;
168
+ value = (await runBridge(page, request)).properties?.[input.key];
169
+ if (value === input.equals || Date.now() >= deadline)
170
+ break;
171
+ await new Promise(resolve => setTimeout(resolve, input.intervalMs ?? 200));
151
172
  }
152
- catch (error) {
153
- return failure(error);
154
- }
155
- });
173
+ return { matched: value === input.equals, key: input.key, value: value ?? null, polls };
174
+ }));
156
175
  if (options.allowRuntimeMutation) {
157
176
  server.registerTool('cocos_set_node_active', {
158
177
  description: 'Set the active state of one Cocos node selected by exact UUID.',
159
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1), active: z.boolean() }).strict(),
178
+ inputSchema: z.object({ pageUrl, ...target, active: z.boolean() }).strict().refine(oneTarget, oneTargetMessage),
160
179
  annotations: runtimeMutation,
161
- }, input => execute({ action: 'setNodeActive', uuid: input.uuid, active: input.active }, input.pageUrl));
180
+ }, input => executeNode(input, uuid => ({ action: 'setNodeActive', uuid, active: input.active })));
162
181
  server.registerTool('cocos_set_transform', {
163
182
  description: 'Update selected Cocos node position, rotation, or scale by exact UUID.',
164
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1), position: vector3.optional(), rotation: quaternion.optional(), scale: vector3.optional() }).strict()
183
+ inputSchema: z.object({ pageUrl, ...target, position: vector3.optional(), rotation: quaternion.optional(), scale: vector3.optional() }).strict().refine(oneTarget, oneTargetMessage)
165
184
  .refine(value => !!value.position || !!value.rotation || !!value.scale, 'Provide position, rotation, or scale'),
166
185
  annotations: runtimeMutation,
167
- }, input => execute({ action: 'setTransform', uuid: input.uuid, position: input.position, rotation: input.rotation, scale: input.scale }, input.pageUrl));
186
+ }, input => executeNode(input, uuid => ({ action: 'setTransform', uuid, position: input.position, rotation: input.rotation, scale: input.scale })));
168
187
  server.registerTool('cocos_set_property', {
169
188
  description: 'Update one bounded public data property selected by node and component UUID.',
170
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1), componentUuid: z.string().min(1), key: z.string().min(1).max(200), value: propertyValue }).strict(),
189
+ inputSchema: z.object({ pageUrl, ...target, componentUuid: z.string().min(1), key: z.string().min(1).max(200), value: propertyValue }).strict().refine(oneTarget, oneTargetMessage),
171
190
  annotations: runtimeMutation,
172
- }, input => execute({ action: 'setProperty', uuid: input.uuid, componentUuid: input.componentUuid, key: input.key, value: input.value }, input.pageUrl));
191
+ }, input => executeNode(input, uuid => ({ action: 'setProperty', uuid, componentUuid: input.componentUuid, key: input.key, value: input.value })));
173
192
  server.registerTool('cocos_click_node', {
174
193
  description: 'Dispatch a real mouse click at the visible center of one Cocos UI node, so Button and touch handlers run. Handlers may call game servers or make irreversible changes.',
175
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1) }).strict(),
194
+ inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
176
195
  // Game click handlers run arbitrary code: a login button reaches real servers, a buy button spends currency.
177
196
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
178
- }, async (input) => {
179
- try {
180
- return response(await clickNode(await browser.page(input.pageUrl), input.uuid));
181
- }
182
- catch (error) {
183
- return failure(error);
184
- }
185
- });
197
+ }, input => withNode(input, web ? clickNode : nativeClick));
198
+ // Like click: game drag and input handlers run arbitrary code.
199
+ const realInput = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
200
+ server.registerTool('cocos_drag_node', {
201
+ description: 'Drag from the visible center of one Cocos UI node by dx/dy CSS pixels with real pointer input, to scroll a ScrollView, flip a PageView, or move a Slider. Under touch emulation the drag arrives as touch. Handlers may call game servers.',
202
+ inputSchema: z.object({ pageUrl, ...target, dx: z.number().min(-4_000).max(4_000), dy: z.number().min(-4_000).max(4_000), steps: z.number().int().min(1).max(60).optional(), durationMs: z.number().int().min(0).max(5_000).optional() }).strict().refine(oneTarget, oneTargetMessage)
203
+ .refine(value => value.dx !== 0 || value.dy !== 0, 'Provide a non-zero dx or dy'),
204
+ annotations: realInput,
205
+ }, input => withNode(input, (page, uuid) => web ? dragNode(page, uuid, input.dx, input.dy, input.steps, input.durationMs) : nativeDrag(page, uuid, input.dx, input.dy, input.durationMs)));
206
+ server.registerTool('cocos_type_text', {
207
+ description: 'Tap one Cocos EditBox and type text with real keyboard input, replacing its content, so text-changed and editing events fire; submit presses Enter (editing-return on single-line boxes, a newline in multi-line ones). Password text is never echoed back.',
208
+ inputSchema: z.object({ pageUrl, ...target, text: z.string().max(2_000), submit: z.boolean().optional() }).strict().refine(oneTarget, oneTargetMessage),
209
+ annotations: realInput,
210
+ }, input => withNode(input, (page, uuid) => (web ? typeText : nativeTypeText)(page, uuid, input.text, input.submit ?? false)));
211
+ if (web)
212
+ server.registerTool('cocos_analyze_batches', {
213
+ description: 'Capture the 2D draw batches of the next rendered frame: the node that starts each batch and why the previous batch broke (TEXTURE, MATERIAL, MASK, STENCIL, LAYER, BUFFER, MODEL, MIDDLEWARE, ...). Wraps engine batcher methods for that one frame only; the game must be running. tintMs overlays each batch\'s nodes in its own color for that long.',
214
+ inputSchema: z.object({ pageUrl, limit: z.number().int().min(1).max(500).optional(), tintMs: z.number().int().min(100).max(30_000).optional() }).strict(),
215
+ annotations: { ...runtimeMutation, idempotentHint: false },
216
+ }, input => execute({ action: 'analyzeBatches', limit: input.limit, tintMs: input.tintMs }, input.pageUrl));
186
217
  for (const action of ['pause', 'resume']) {
187
218
  server.registerTool(`cocos_${action}`, {
188
219
  description: `${action === 'pause' ? 'Pause' : 'Resume'} the Cocos director through its public API.`,
@@ -190,55 +221,132 @@ export function createServer(browser, options = {}) {
190
221
  annotations: runtimeMutation,
191
222
  }, input => execute({ action }, input.pageUrl));
192
223
  }
193
- server.registerTool('cocos_step_frame', {
194
- description: 'Advance a paused Cocos game by fixed-delta frames through cc.game.step; requires cocos_pause first.',
195
- inputSchema: z.object({ pageUrl, frames: z.number().int().min(1).max(60).optional() }).strict(),
196
- annotations: { ...runtimeMutation, idempotentHint: false },
197
- }, input => execute({ action: 'stepFrame', frames: input.frames }, input.pageUrl));
224
+ if (web)
225
+ server.registerTool('cocos_step_frame', {
226
+ description: 'Advance a paused Cocos game by fixed-delta frames through cc.game.step; requires cocos_pause first.',
227
+ inputSchema: z.object({ pageUrl, frames: z.number().int().min(1).max(60).optional() }).strict(),
228
+ annotations: { ...runtimeMutation, idempotentHint: false },
229
+ }, input => execute({ action: 'stepFrame', frames: input.frames }, input.pageUrl));
230
+ server.registerTool('cocos_set_time_scale', {
231
+ description: 'Speed up or slow down the whole game by scaling the delta time of every frame (components, scheduler, tweens, animation, physics). 1 restores normal speed; omit scale to read it.',
232
+ inputSchema: z.object({ pageUrl, scale: z.number().gt(0).max(100).optional() }).strict(),
233
+ annotations: runtimeMutation,
234
+ }, input => execute({ action: 'timeScale', scale: input.scale }, input.pageUrl));
198
235
  server.registerTool('cocos_show_stats', {
199
236
  description: 'Show or hide the Cocos profiler overlay (FPS, draw calls, triangles) through the public profiler API.',
200
237
  inputSchema: z.object({ pageUrl, visible: z.boolean() }).strict(),
201
238
  annotations: runtimeMutation,
202
239
  }, input => execute({ action: 'showStats', visible: input.visible }, input.pageUrl));
203
- server.registerTool('cocos_emulate_device', {
204
- description: 'Emulate a mobile device (viewport, DPR, touch, user agent, orientation) and optionally slow the CPU or network, like the Chrome device toolbar. Settings merge across calls and last until reset or server disconnect; reload lets the game re-detect touch and user agent.',
205
- inputSchema: z.object({
206
- pageUrl,
207
- reset: z.boolean().optional(),
208
- preset: z.enum(devicePresetNames).optional(),
209
- width: z.number().int().min(200).max(4_000).optional(),
210
- height: z.number().int().min(200).max(4_000).optional(),
211
- deviceScaleFactor: z.number().min(1).max(4).optional(),
212
- mobile: z.boolean().optional(),
213
- orientation: z.enum(['portrait', 'landscape']).optional(),
214
- cpuSlowdown: z.number().min(1).max(20).optional(),
215
- network: z.enum(networkProfileNames).optional(),
216
- reload: z.boolean().optional(),
217
- }).strict()
218
- .refine(value => (value.width === undefined) === (value.height === undefined), 'Provide width and height together')
219
- .refine(value => !(value.preset && value.width !== undefined), 'Provide preset or width and height, not both')
220
- .refine(value => value.width !== undefined || (value.deviceScaleFactor === undefined && value.mobile === undefined), 'deviceScaleFactor and mobile need width and height')
221
- .refine(value => !value.reset || Object.keys(value).every(key => ['pageUrl', 'reset', 'reload'].includes(key)), 'reset accepts only pageUrl and reload'),
222
- annotations: { ...runtimeMutation, idempotentHint: false },
223
- }, async (input) => {
240
+ if (web)
241
+ server.registerTool('cocos_emulate_device', {
242
+ description: 'Emulate a mobile device (viewport, DPR, touch, user agent, orientation) and optionally slow the CPU or network, like the Chrome device toolbar. Settings merge across calls and last until reset or server disconnect; reload lets the game re-detect touch and user agent.',
243
+ inputSchema: z.object({
244
+ pageUrl,
245
+ reset: z.boolean().optional(),
246
+ preset: z.enum(devicePresetNames).optional(),
247
+ width: z.number().int().min(200).max(4_000).optional(),
248
+ height: z.number().int().min(200).max(4_000).optional(),
249
+ deviceScaleFactor: z.number().min(1).max(4).optional(),
250
+ mobile: z.boolean().optional(),
251
+ orientation: z.enum(['portrait', 'landscape']).optional(),
252
+ cpuSlowdown: z.number().min(1).max(20).optional(),
253
+ network: z.enum(networkProfileNames).optional(),
254
+ reload: z.boolean().optional(),
255
+ }).strict()
256
+ .refine(value => (value.width === undefined) === (value.height === undefined), 'Provide width and height together')
257
+ .refine(value => !(value.preset && value.width !== undefined), 'Provide preset or width and height, not both')
258
+ .refine(value => value.width !== undefined || (value.deviceScaleFactor === undefined && value.mobile === undefined), 'deviceScaleFactor and mobile need width and height')
259
+ .refine(value => !value.reset || Object.keys(value).every(key => ['pageUrl', 'reset', 'reload'].includes(key)), 'reset accepts only pageUrl and reload'),
260
+ annotations: { ...runtimeMutation, idempotentHint: false },
261
+ }, async (input) => {
262
+ try {
263
+ return response(await emulateDevice(await browser.page(input.pageUrl), input));
264
+ }
265
+ catch (error) {
266
+ return failure(error);
267
+ }
268
+ });
269
+ }
270
+ server.registerTool('cocos_explain_click', {
271
+ description: 'Explain whether a tap on a node (or at viewport x/y) reaches it, replaying the engine touch dispatch order without dispatching: which node would claim the touch, the hit stack, and reasons such as INACTIVE, BUTTON_NOT_INTERACTABLE, BLOCKED_BY_BLOCK_INPUT_EVENTS, COVERED_BY_OTHER_NODE, NO_TOUCH_LISTENER, or OUTSIDE_VIEWPORT.',
272
+ inputSchema: z.object({ pageUrl, ...target, x: z.number().min(0).max(20_000).optional(), y: z.number().min(0).max(20_000).optional() }).strict()
273
+ .refine(value => (value.x === undefined) === (value.y === undefined), 'Provide x and y together')
274
+ .refine(value => value.uuid !== undefined || value.path !== undefined || value.x !== undefined, 'Provide uuid, path, or x and y')
275
+ .refine(value => !(value.uuid !== undefined && value.path !== undefined), oneTargetMessage),
276
+ annotations: readOnly,
277
+ }, input => input.uuid === undefined && input.path === undefined
278
+ ? execute({ action: 'explainClick', x: input.x, y: input.y }, input.pageUrl)
279
+ : executeNode(input, uuid => ({ action: 'explainClick', uuid, x: input.x, y: input.y })));
280
+ server.registerTool('cocos_listener_report', {
281
+ description: 'Report scheduler timers, update callbacks, tweens, and director/game/view listeners that outlive their owner: those on destroyed or detached nodes and components, and unowned ones (arrow functions, bind(this), plain objects) the engine can never purge, grouped by event and name with counts. Compare two calls around opening and closing a popup; a growing count is a leak.',
282
+ inputSchema: z.object({ pageUrl, limit: z.number().int().min(1).max(500).optional() }).strict(),
283
+ annotations: readOnly,
284
+ }, input => execute({ action: 'listenerReport', limit: input.limit }, input.pageUrl));
285
+ server.registerTool('cocos_dynamic_atlas', {
286
+ description: 'Report the Cocos dynamic atlas: config, each atlas page with its packed textures, positions, fill ratio, and GPU bytes, plus why visible sprites stayed out of it (DISABLED, COMPRESSED, TOO_LARGE, FILTER, NOT_PACKABLE, ATLAS_FULL, ...).',
287
+ inputSchema: z.object({ pageUrl, limit: z.number().int().min(1).max(500).optional() }).strict(),
288
+ annotations: readOnly,
289
+ }, input => execute({ action: 'dynamicAtlas', limit: input.limit }, input.pageUrl));
290
+ server.registerTool('cocos_asset_report', {
291
+ description: 'List assets held in the Cocos asset cache with type, refCount, bundle, texture GPU bytes, and which scene nodes use them; unused marks assets no active renderer references. Compare two calls (before and after a scene or popup closes) to find leaks.',
292
+ inputSchema: z.object({ pageUrl, type: z.string().min(1).max(120).optional(), unusedOnly: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() }).strict(),
293
+ annotations: readOnly,
294
+ }, input => execute({ action: 'assetReport', type: input.type, unusedOnly: input.unusedOnly, limit: input.limit }, input.pageUrl));
295
+ if (options.allowMethodCall) {
296
+ const jsonValue = z.lazy(() => z.union([z.null(), z.boolean(), finiteNumber, z.string().max(10_000), z.array(jsonValue).max(100), z.record(z.string().max(200), jsonValue)]));
297
+ server.registerTool('cocos_call_method', {
298
+ description: 'Call one public method on a Cocos node or component selected by UUID, with JSON arguments; {"$node": uuid}, {"$path": path}, {"$component": uuid}, and {"$asset": uuid} pass live objects. Runs arbitrary game code: it can change state, reach servers, or spend currency. Returns the bounded result or the thrown error; awaitMs waits for a returned promise.',
299
+ inputSchema: z.object({ pageUrl, ...target, componentUuid: z.string().min(1).optional(), method: z.string().min(1).max(200).regex(/^[A-Za-z$][\w$]*$/), args: z.array(jsonValue).max(20).optional(), awaitMs: z.number().int().min(0).max(30_000).optional(), maxDepth: z.number().int().min(0).max(6).optional() }).strict().refine(oneTarget, oneTargetMessage),
300
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
301
+ }, input => executeNode(input, uuid => ({ action: 'callMethod', uuid, componentUuid: input.componentUuid, method: input.method, args: input.args ?? [], awaitMs: input.awaitMs, maxDepth: input.maxDepth })));
302
+ }
303
+ if (options.allowBrowserData) {
304
+ // Page data written by the game or its servers: treat every returned string as untrusted, and redaction as best effort.
305
+ const browserData = { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false };
306
+ const run = async (work) => {
224
307
  try {
225
- return response(await emulateDevice(await browser.page(input.pageUrl), input));
308
+ return response(await work());
226
309
  }
227
310
  catch (error) {
228
311
  return failure(error);
229
312
  }
230
- });
313
+ };
314
+ server.registerTool('cocos_console_messages', {
315
+ description: 'List recent console messages and uncaught page errors since the server attached (newest last). Secret-like values, JWTs, and bearer tokens are masked; text is untrusted page output.',
316
+ inputSchema: z.object({ pageUrl, types: z.array(z.enum(['log', 'debug', 'info', 'error', 'warning', 'assert', 'trace', 'pageerror'])).max(8).optional(), textContains: z.string().min(1).max(200).optional(), limit: z.number().int().min(1).max(200).optional() }).strict(),
317
+ annotations: browserData,
318
+ }, input => run(async () => (web ? consoleMessages : nativeConsoleMessages)(await browser.page(input.pageUrl), input)));
319
+ // ponytail: native network and storage need jsb XHR hooks and a jsb.localStorage reader; add them when a native game needs them.
320
+ if (web) {
321
+ server.registerTool('cocos_network_requests', {
322
+ description: 'List recent network requests since the server attached: id, method, URL with secret query values masked, resource type, status, failure, and duration. Use cocos_network_request for headers and bodies.',
323
+ inputSchema: z.object({ pageUrl, urlContains: z.string().min(1).max(500).optional(), resourceType: z.string().min(1).max(40).optional(), failedOnly: z.boolean().optional(), limit: z.number().int().min(1).max(200).optional() }).strict(),
324
+ annotations: browserData,
325
+ }, input => run(async () => networkRequests(await browser.page(input.pageUrl), input)));
326
+ server.registerTool('cocos_network_request', {
327
+ description: 'Return one request by id from cocos_network_requests: headers with authorization and cookies masked, status, timing, and optionally text bodies up to 20 KB with secret-like JSON and form fields masked.',
328
+ inputSchema: z.object({ pageUrl, id: z.number().int().min(1), includeBody: z.boolean().optional() }).strict(),
329
+ annotations: browserData,
330
+ }, input => run(async () => networkRequest(await browser.page(input.pageUrl), input.id, input.includeBody ?? false)));
331
+ server.registerTool('cocos_storage', {
332
+ description: 'Read localStorage or sessionStorage entries, or cookie names and attributes for the page. Secret-like keys and every cookie value are masked; JSON values are masked by key.',
333
+ inputSchema: z.object({ pageUrl, area: z.enum(['local', 'session', 'cookies']), keyContains: z.string().min(1).max(200).optional(), limit: z.number().int().min(1).max(500).optional() }).strict(),
334
+ annotations: browserData,
335
+ }, input => run(async () => storage(await browser.page(input.pageUrl), input.area, input.keyContains, input.limit)));
336
+ }
231
337
  }
232
- server.registerTool('cocos_get_selection', {
233
- description: 'Return the node the user last Alt+clicked on the game canvas. The first call installs the picker; Alt+clicks are kept from the game, other input is untouched. disable removes the picker, overlay, and selection.',
234
- inputSchema: z.object({ pageUrl, disable: z.boolean().optional() }).strict(),
235
- // Installs a page-level listener and DOM overlay only; never touches the Cocos graph.
236
- annotations: { ...readOnly, readOnlyHint: false },
237
- }, input => execute({ action: 'selection', disable: input.disable }, input.pageUrl));
238
- server.registerTool('cocos_highlight_node', {
239
- description: 'Temporarily draw a pointer-transparent DOM overlay around one Cocos UI node.',
240
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1), durationMs: z.number().int().min(100).max(10_000).optional() }).strict(),
241
- annotations: temporaryMutation,
242
- }, input => execute({ action: 'highlightNode', uuid: input.uuid, durationMs: input.durationMs }, input.pageUrl));
338
+ if (web)
339
+ server.registerTool('cocos_get_selection', {
340
+ description: 'Return the node the user last Alt+clicked on the game canvas. The first call installs the picker; Alt+clicks are kept from the game, other input is untouched. disable removes the picker, overlay, and selection.',
341
+ inputSchema: z.object({ pageUrl, disable: z.boolean().optional() }).strict(),
342
+ // Installs a page-level listener and DOM overlay only; never touches the Cocos graph.
343
+ annotations: { ...readOnly, readOnlyHint: false },
344
+ }, input => execute({ action: 'selection', disable: input.disable }, input.pageUrl));
345
+ if (web)
346
+ server.registerTool('cocos_highlight_node', {
347
+ description: 'Temporarily draw a pointer-transparent DOM overlay around one Cocos UI node.',
348
+ inputSchema: z.object({ pageUrl, ...target, durationMs: z.number().int().min(100).max(10_000).optional() }).strict().refine(oneTarget, oneTargetMessage),
349
+ annotations: temporaryMutation,
350
+ }, input => executeNode(input, uuid => ({ action: 'highlightNode', uuid, durationMs: input.durationMs })));
243
351
  return server;
244
352
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cocos-web-inspector-mcp",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server to inspect and debug Cocos Creator 3.x web builds in local Chromium over CDP",
5
5
  "license": "MIT",
6
6
  "repository": {