cocos-web-inspector-mcp 2.2.1 → 2.4.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,11 +2,15 @@ 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 { nativeClick, nativeConsoleMessages, nativeDrag, nativeTypeText } from './native.js';
5
+ import { nativeCaptureNode, nativeClick, nativeConsoleMessages, nativeDrag, nativeNetworkRequest, nativeNetworkRequests, nativePressKey, nativeTypeText } from './native.js';
6
6
  import { consoleMessages, networkRequest, networkRequests, storage } from './browser-data.js';
7
- import { captureNode, clickNode, devicePresetNames, dragNode, emulateDevice, inspectCocosPage, networkProfileNames, runBridge, typeText } from './bridge.js';
7
+ import { captureNode, clickNode, devicePresetNames, dragNode, emulateDevice, fitsResponse, inspectCocosPage, networkProfileNames, keyPattern, pressKey, runBridge, typeText } from './bridge.js';
8
8
  const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
9
- const pageUrl = z.url().optional();
9
+ // Agents send "" for unused optional fields; treat it as omitted.
10
+ const blank = (schema) => z.preprocess(value => value === '' ? undefined : value, schema.optional());
11
+ // Strict object whose unknown-key error also lists the accepted keys.
12
+ const obj = (shape) => z.object(shape, { error: issue => issue.code === 'unrecognized_keys' ? `Unrecognized key(s): ${issue.keys.join(', ')}; accepted: ${Object.keys(shape).join(', ')}` : undefined }).strict();
13
+ const pageUrl = blank(z.url());
10
14
  const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
11
15
  const temporaryMutation = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
12
16
  const runtimeMutation = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
@@ -24,9 +28,9 @@ const propertyValue = z.union([
24
28
  z.object({ r: finiteNumber, g: finiteNumber, b: finiteNumber, a: finiteNumber }).strict(),
25
29
  ]);
26
30
  // 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() };
31
+ const target = { uuid: blank(z.string().min(1).max(200)), path: blank(z.string().min(1).max(10_000)) };
28
32
  const oneTarget = (value) => (value.uuid === undefined) !== (value.path === undefined);
29
- const oneTargetMessage = 'Provide uuid or path, not both';
33
+ const oneTargetMessage = { error: (issue) => issue.input?.uuid === undefined && issue.input?.path === undefined ? 'Provide uuid or path' : 'Provide uuid or path, not both' };
30
34
  async function resolveUuid(page, input) {
31
35
  if (input.uuid)
32
36
  return input.uuid;
@@ -51,20 +55,87 @@ function failure(error) {
51
55
  // Playwright wraps in-page errors as "page.evaluate: Error: <message>\n<stack>".
52
56
  const message = raw.replace(/^page\.evaluate: (?:Error: )?/, '').split('\n')[0];
53
57
  const code = message === 'Cocos Creator 3.x runtime not found' ? 'COCOS_NOT_FOUND'
54
- : message === 'Active Cocos scene not found' ? 'SCENE_NOT_READY'
58
+ : message.startsWith('Active Cocos scene not found') ? 'SCENE_NOT_READY'
55
59
  : message.startsWith('Node not found') ? 'NODE_NOT_FOUND'
56
- : message === 'Component not found' ? 'COMPONENT_NOT_FOUND'
57
- : message === 'Ambiguous component type' ? 'AMBIGUOUS_COMPONENT'
60
+ : message.startsWith('Component not found') ? 'COMPONENT_NOT_FOUND'
61
+ : message.startsWith('Ambiguous component type') ? 'AMBIGUOUS_COMPONENT'
58
62
  : message.startsWith('Invalid mutation') ? 'INVALID_MUTATION'
59
63
  : 'CDP_UNAVAILABLE';
60
64
  return { ...response({ code, message }), isError: true };
61
65
  }
62
66
  export function createServer(browser, options = {}) {
63
67
  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,
68
+ // A native (JSB) runtime has no page: screenshots and device emulation need a browser,
65
69
  // the DOM overlays draw nothing, the 2D batcher runs in C++ where analyze_batches cannot observe it,
66
70
  // and game.step renders outside the native frame loop, which crashed a 3.8.8 Android build in the GFX pipeline.
67
71
  const web = !options.native;
72
+ const pageIds = new WeakMap();
73
+ const baselines = new Map();
74
+ let nextPageId = 1;
75
+ let revision = 0;
76
+ const pageId = (page) => {
77
+ let id = pageIds.get(page);
78
+ if (id === undefined) {
79
+ id = nextPageId++;
80
+ pageIds.set(page, id);
81
+ }
82
+ return id;
83
+ };
84
+ const observeChanges = async (input) => {
85
+ try {
86
+ const page = await browser.page(input.pageUrl);
87
+ const uuid = input.uuid ?? (input.path ? await resolveUuid(page, input) : undefined);
88
+ const snapshot = JSON.parse(JSON.stringify(await runBridge(page, { action: 'semanticSnapshot', uuid, maxDepth: input.maxDepth, maxNodes: input.maxNodes })));
89
+ const selector = input.uuid ? `uuid:${input.uuid}` : input.path ? `path:${input.path}` : 'scene';
90
+ const key = JSON.stringify([pageId(page), page.url(), snapshot.documentId ?? null, selector, input.maxDepth ?? 6, input.maxNodes ?? 500]);
91
+ const prior = baselines.get(key);
92
+ if (input.reset)
93
+ baselines.delete(key);
94
+ const baseline = input.reset ? undefined : prior;
95
+ const full = (reason) => {
96
+ const next = { snapshot, revision: ++revision };
97
+ baselines.delete(key);
98
+ baselines.set(key, next);
99
+ while (baselines.size > 32)
100
+ baselines.delete(baselines.keys().next().value);
101
+ return { mode: 'full', reason, revision: next.revision, snapshot };
102
+ };
103
+ if (!baseline)
104
+ return response(full(input.reset ? 'RESET' : 'BASELINE_MISSING'));
105
+ baselines.delete(key);
106
+ baselines.set(key, baseline);
107
+ const before = new Map((baseline.snapshot.nodes ?? []).map((node) => [node.uuid, node]));
108
+ const after = new Map((snapshot.nodes ?? []).map((node) => [node.uuid, node]));
109
+ const nodes = [...after.values()];
110
+ const added = nodes.filter(node => !before.has(node.uuid));
111
+ const removed = [...before.keys()].filter(uuid => !after.has(uuid));
112
+ const changed = nodes.flatMap(node => {
113
+ const old = before.get(node.uuid);
114
+ if (!old)
115
+ return [];
116
+ const changes = Object.fromEntries([...new Set([...Object.keys(old), ...Object.keys(node)])]
117
+ .filter(field => field !== 'uuid' && JSON.stringify(node[field]) !== JSON.stringify(old[field]))
118
+ .map(field => [field, field in node ? node[field] : null]));
119
+ return Object.keys(changes).length ? [{ uuid: node.uuid, changes }] : [];
120
+ });
121
+ const metadataChanged = JSON.stringify({ truncated: snapshot.truncated, truncationReasons: snapshot.truncationReasons }) !== JSON.stringify({ truncated: baseline.snapshot.truncated, truncationReasons: baseline.snapshot.truncationReasons });
122
+ if (!added.length && !removed.length && !changed.length && !metadataChanged)
123
+ return response({ mode: 'unchanged', revision: baseline.revision });
124
+ if (metadataChanged)
125
+ return response(full('TRUNCATION_CHANGED'));
126
+ const delta = { added, removed, changed };
127
+ const deltaResult = { mode: 'delta', revision: revision + 1, delta };
128
+ if (JSON.stringify(delta).length >= JSON.stringify(snapshot).length || !fitsResponse(deltaResult))
129
+ return response(full('DELTA_NOT_SMALLER_THAN_FULL'));
130
+ const next = { snapshot, revision: ++revision };
131
+ baselines.delete(key);
132
+ baselines.set(key, next);
133
+ return response({ ...deltaResult, revision: next.revision });
134
+ }
135
+ catch (error) {
136
+ return failure(error);
137
+ }
138
+ };
68
139
  const execute = async (request, selectedPage) => {
69
140
  try {
70
141
  return response(await runBridge(await browser.page(selectedPage), request));
@@ -85,7 +156,7 @@ export function createServer(browser, options = {}) {
85
156
  const executeNode = (input, build) => withNode(input, (page, uuid) => runBridge(page, build(uuid)));
86
157
  server.registerTool('cocos_list_pages', {
87
158
  description: 'List bounded summaries of eligible localhost Chromium pages.',
88
- inputSchema: z.object({}).strict(),
159
+ inputSchema: obj({}),
89
160
  annotations: readOnly,
90
161
  }, async () => {
91
162
  try {
@@ -102,96 +173,120 @@ export function createServer(browser, options = {}) {
102
173
  });
103
174
  server.registerTool('cocos_runtime_info', {
104
175
  description: 'Return bounded runtime information for one localhost Cocos Creator 3.x page.',
105
- inputSchema: z.object({ pageUrl }).strict(),
176
+ inputSchema: obj({ pageUrl }),
106
177
  annotations: readOnly,
107
178
  }, input => execute({ action: 'runtimeInfo' }, input.pageUrl));
108
179
  server.registerTool('cocos_runtime_diagnostics', {
109
180
  description: 'Return bounded passive Cocos scene hierarchy diagnostics.',
110
- inputSchema: z.object({ pageUrl }).strict(),
181
+ inputSchema: obj({ pageUrl }),
111
182
  annotations: readOnly,
112
183
  }, input => execute({ action: 'runtimeDiagnostics' }, input.pageUrl));
113
184
  server.registerTool('cocos_scene_tree', {
114
185
  description: 'Return a bounded Cocos Creator 3.x scene tree from a localhost Chromium page.',
115
- inputSchema: z.object({ pageUrl, maxDepth: z.number().int().min(0).max(20).optional(), maxNodes: z.number().int().min(1).max(5_000).optional() }).strict(),
186
+ inputSchema: obj({ pageUrl, maxDepth: z.number().int().min(0).max(20).optional(), maxNodes: z.number().int().min(1).max(5_000).optional() }),
116
187
  annotations: readOnly,
117
188
  }, input => execute({ action: 'sceneTree', maxDepth: input.maxDepth, maxNodes: input.maxNodes }, input.pageUrl));
118
189
  server.registerTool('cocos_find_node', {
119
190
  description: 'Find Cocos nodes with bounded exact and filter criteria.',
120
- inputSchema: z.object({ pageUrl, uuid: z.string().min(1).max(200).optional(), name: z.string().min(1).max(500).optional(), path: z.string().min(1).max(10_000).optional(), nameContains: z.string().min(1).max(500).optional(), componentType: z.string().min(1).max(120).optional(), active: z.boolean().optional(), pathPrefix: z.string().min(1).max(10_000).optional(), limit: z.number().int().min(1).max(100).optional() }).strict()
191
+ inputSchema: obj({ pageUrl, uuid: blank(z.string().min(1).max(200)), name: blank(z.string().min(1).max(500)), path: blank(z.string().min(1).max(10_000)), nameContains: blank(z.string().min(1).max(500)), componentType: blank(z.string().min(1).max(120)), active: z.boolean().optional(), pathPrefix: blank(z.string().min(1).max(10_000)), limit: z.number().int().min(1).max(100).optional() }).strict()
121
192
  .refine(value => [value.uuid, value.name, value.path, value.nameContains, value.componentType, value.active, value.pathPrefix].some(item => item !== undefined), 'Provide at least one filter'),
122
193
  annotations: readOnly,
123
194
  }, 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));
124
195
  server.registerTool('cocos_get_node', {
125
196
  description: 'Return bounded context for one exact Cocos node UUID.',
126
- inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
197
+ inputSchema: obj({ pageUrl, ...target }).refine(oneTarget, oneTargetMessage),
127
198
  annotations: readOnly,
128
199
  }, input => executeNode(input, uuid => ({ action: 'getNode', uuid })));
129
200
  server.registerTool('cocos_snapshot_subtree', {
130
201
  description: 'Return a bounded stateless subtree snapshot for one exact Cocos node UUID.',
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),
202
+ inputSchema: obj({ pageUrl, ...target, maxDepth: z.number().int().min(0).max(20).optional(), maxNodes: z.number().int().min(1).max(5_000).optional() }).refine(oneTarget, oneTargetMessage),
132
203
  annotations: readOnly,
133
204
  }, input => executeNode(input, uuid => ({ action: 'snapshotSubtree', uuid, maxDepth: input.maxDepth, maxNodes: input.maxNodes })));
205
+ server.registerTool('cocos_observe_changes', {
206
+ description: 'Observe bounded semantic scene changes since the prior matching call.',
207
+ inputSchema: obj({ pageUrl, ...target, reset: z.boolean().optional(), maxDepth: z.number().int().min(0).max(20).optional(), maxNodes: z.number().int().min(1).max(5_000).optional() }).refine(value => !(value.uuid && value.path), oneTargetMessage),
208
+ annotations: readOnly,
209
+ }, input => observeChanges(input));
134
210
  server.registerTool('cocos_get_node_bounds', {
135
211
  description: 'Return bounded canvas and viewport bounds for one Cocos UI node UUID.',
136
- inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
212
+ inputSchema: obj({ pageUrl, ...target }).refine(oneTarget, oneTargetMessage),
137
213
  annotations: readOnly,
138
214
  }, 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));
215
+ server.registerTool('cocos_capture_node', {
216
+ description: 'Capture a bounded viewport-clipped PNG for one visible Cocos UI node UUID. Pass maxBytes to cap the base64 image size when the client rejects large results.',
217
+ inputSchema: obj({ pageUrl, ...target, maxBytes: z.number().int().min(1_000).max(200_000).optional() }).refine(oneTarget, oneTargetMessage),
218
+ annotations: readOnly,
219
+ }, input => withNode(input, (page, uuid) => (web ? captureNode : nativeCaptureNode)(page, uuid, input.maxBytes)));
145
220
  server.registerTool('cocos_get_components', {
146
221
  description: 'List bounded component summaries for one Cocos node UUID.',
147
- inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
222
+ inputSchema: obj({ pageUrl, ...target }).refine(oneTarget, oneTargetMessage),
148
223
  annotations: readOnly,
149
224
  }, input => executeNode(input, uuid => ({ action: 'getComponents', uuid })));
150
225
  server.registerTool('cocos_get_properties', {
151
226
  description: 'Read bounded, cycle-safe public properties from a Cocos node or one component.',
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)
227
+ inputSchema: obj({ pageUrl, ...target, componentType: blank(z.string().min(1)), componentUuid: blank(z.string().min(1)), maxDepth: z.number().int().min(0).max(6).optional() }).refine(oneTarget, oneTargetMessage)
153
228
  .refine(value => !(value.componentType && value.componentUuid), 'Provide componentType or componentUuid, not both'),
154
229
  annotations: readOnly,
155
230
  }, input => executeNode(input, uuid => ({ action: 'getProperties', uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: input.maxDepth })));
156
231
  server.registerTool('cocos_wait_for_property', {
157
232
  description: 'Poll one top-level property of a Cocos node or component until it equals a value or the timeout passes.',
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)
233
+ inputSchema: obj({ pageUrl, ...target, componentType: blank(z.string().min(1)), componentUuid: blank(z.string().min(1)), 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() }).refine(oneTarget, oneTargetMessage)
159
234
  .refine(value => !(value.componentType && value.componentUuid), 'Provide componentType or componentUuid, not both'),
160
235
  annotations: readOnly,
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));
236
+ }, async (input) => {
237
+ try {
238
+ const page = await browser.page(input.pageUrl);
239
+ const deadline = Date.now() + (input.timeoutMs ?? 5_000);
240
+ let polls = 0;
241
+ let value;
242
+ let gone;
243
+ // A path is resolved on every poll so a scene switch or a node still being created does not end the wait early.
244
+ for (let uuid = input.uuid;;) {
245
+ polls++;
246
+ try {
247
+ uuid ??= await resolveUuid(page, input);
248
+ value = (await runBridge(page, { action: 'getProperties', uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: 0 })).properties?.[input.key];
249
+ gone = undefined;
250
+ }
251
+ catch (error) {
252
+ const message = error instanceof Error ? error.message : '';
253
+ if (!/Node not found|Active Cocos scene not found|Component not found/.test(message))
254
+ throw error;
255
+ gone = message.replace(/^page\.evaluate: (?:Error: )?/, '').split('\n')[0];
256
+ value = undefined;
257
+ if (!input.uuid)
258
+ uuid = undefined;
259
+ }
260
+ if (gone === undefined && value === input.equals || Date.now() >= deadline)
261
+ break;
262
+ await new Promise(resolve => setTimeout(resolve, input.intervalMs ?? 200));
263
+ }
264
+ return response({ matched: gone === undefined && value === input.equals, key: input.key, value: value ?? null, polls, ...(gone ? { gone } : {}) });
265
+ }
266
+ catch (error) {
267
+ return failure(error);
172
268
  }
173
- return { matched: value === input.equals, key: input.key, value: value ?? null, polls };
174
- }));
269
+ });
175
270
  if (options.allowRuntimeMutation) {
176
271
  server.registerTool('cocos_set_node_active', {
177
272
  description: 'Set the active state of one Cocos node selected by exact UUID.',
178
- inputSchema: z.object({ pageUrl, ...target, active: z.boolean() }).strict().refine(oneTarget, oneTargetMessage),
273
+ inputSchema: obj({ pageUrl, ...target, active: z.boolean() }).refine(oneTarget, oneTargetMessage),
179
274
  annotations: runtimeMutation,
180
275
  }, input => executeNode(input, uuid => ({ action: 'setNodeActive', uuid, active: input.active })));
181
276
  server.registerTool('cocos_set_transform', {
182
277
  description: 'Update selected Cocos node position, rotation, or scale by exact UUID.',
183
- inputSchema: z.object({ pageUrl, ...target, position: vector3.optional(), rotation: quaternion.optional(), scale: vector3.optional() }).strict().refine(oneTarget, oneTargetMessage)
278
+ inputSchema: obj({ pageUrl, ...target, position: vector3.optional(), rotation: quaternion.optional(), scale: vector3.optional() }).refine(oneTarget, oneTargetMessage)
184
279
  .refine(value => !!value.position || !!value.rotation || !!value.scale, 'Provide position, rotation, or scale'),
185
280
  annotations: runtimeMutation,
186
281
  }, input => executeNode(input, uuid => ({ action: 'setTransform', uuid, position: input.position, rotation: input.rotation, scale: input.scale })));
187
282
  server.registerTool('cocos_set_property', {
188
283
  description: 'Update one bounded public data property selected by node and component UUID.',
189
- inputSchema: z.object({ pageUrl, ...target, componentUuid: z.string().min(1), key: z.string().min(1).max(200), value: propertyValue }).strict().refine(oneTarget, oneTargetMessage),
284
+ inputSchema: obj({ pageUrl, ...target, componentUuid: z.string().min(1), key: z.string().min(1).max(200), value: propertyValue }).refine(oneTarget, oneTargetMessage),
190
285
  annotations: runtimeMutation,
191
286
  }, input => executeNode(input, uuid => ({ action: 'setProperty', uuid, componentUuid: input.componentUuid, key: input.key, value: input.value })));
192
287
  server.registerTool('cocos_click_node', {
193
288
  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.',
194
- inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
289
+ inputSchema: obj({ pageUrl, ...target }).refine(oneTarget, oneTargetMessage),
195
290
  // Game click handlers run arbitrary code: a login button reaches real servers, a buy button spends currency.
196
291
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
197
292
  }, input => withNode(input, web ? clickNode : nativeClick));
@@ -199,48 +294,65 @@ export function createServer(browser, options = {}) {
199
294
  const realInput = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
200
295
  server.registerTool('cocos_drag_node', {
201
296
  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)
297
+ inputSchema: obj({ 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() }).refine(oneTarget, oneTargetMessage)
203
298
  .refine(value => value.dx !== 0 || value.dy !== 0, 'Provide a non-zero dx or dy'),
204
299
  annotations: realInput,
205
300
  }, 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
301
  server.registerTool('cocos_type_text', {
207
302
  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),
303
+ inputSchema: obj({ pageUrl, ...target, text: z.string().max(2_000), submit: z.boolean().optional() }).refine(oneTarget, oneTargetMessage),
209
304
  annotations: realInput,
210
305
  }, input => withNode(input, (page, uuid) => (web ? typeText : nativeTypeText)(page, uuid, input.text, input.submit ?? false)));
306
+ server.registerTool('cocos_press_key', {
307
+ description: 'Press and release one key with real keyboard input, for game keyboard handlers and focused inputs: Enter, Escape, Tab, Backspace, Delete, Space, arrows, Home, End, PageUp, PageDown, a letter, or a digit. To fill an EditBox use cocos_type_text. Handlers may call game servers.',
308
+ inputSchema: obj({ pageUrl, key: z.string().regex(keyPattern, 'Use Enter, Escape, Tab, Backspace, Delete, Space, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, or one letter or digit') }),
309
+ annotations: realInput,
310
+ }, async (input) => {
311
+ try {
312
+ return response(await (web ? pressKey : nativePressKey)(await browser.page(input.pageUrl), input.key));
313
+ }
314
+ catch (error) {
315
+ return failure(error);
316
+ }
317
+ });
211
318
  if (web)
212
319
  server.registerTool('cocos_analyze_batches', {
213
320
  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(),
321
+ inputSchema: obj({ pageUrl, limit: z.number().int().min(1).max(500).optional(), tintMs: z.number().int().min(100).max(30_000).optional() }),
215
322
  annotations: { ...runtimeMutation, idempotentHint: false },
216
323
  }, input => execute({ action: 'analyzeBatches', limit: input.limit, tintMs: input.tintMs }, input.pageUrl));
324
+ server.registerTool('cocos_event_trace', {
325
+ description: 'Count Node events (emit calls) per event name and node for a bounded window, to find event storms such as a TRANSFORM_CHANGED or custom event fired every frame. Wraps Node.prototype.emit for durationMs and restores it; arguments are never recorded.',
326
+ inputSchema: obj({ pageUrl, durationMs: z.number().int().min(100).max(10_000).optional(), limit: z.number().int().min(1).max(200).optional() }),
327
+ annotations: { ...runtimeMutation, idempotentHint: false },
328
+ }, input => execute({ action: 'eventTrace', durationMs: input.durationMs, limit: input.limit }, input.pageUrl));
217
329
  for (const action of ['pause', 'resume']) {
218
330
  server.registerTool(`cocos_${action}`, {
219
331
  description: `${action === 'pause' ? 'Pause' : 'Resume'} the Cocos director through its public API.`,
220
- inputSchema: z.object({ pageUrl }).strict(),
332
+ inputSchema: obj({ pageUrl }),
221
333
  annotations: runtimeMutation,
222
334
  }, input => execute({ action }, input.pageUrl));
223
335
  }
224
336
  if (web)
225
337
  server.registerTool('cocos_step_frame', {
226
338
  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(),
339
+ inputSchema: obj({ pageUrl, frames: z.number().int().min(1).max(60).optional() }),
228
340
  annotations: { ...runtimeMutation, idempotentHint: false },
229
341
  }, input => execute({ action: 'stepFrame', frames: input.frames }, input.pageUrl));
230
342
  server.registerTool('cocos_set_time_scale', {
231
343
  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(),
344
+ inputSchema: obj({ pageUrl, scale: z.number().gt(0).max(100).optional() }),
233
345
  annotations: runtimeMutation,
234
346
  }, input => execute({ action: 'timeScale', scale: input.scale }, input.pageUrl));
235
347
  server.registerTool('cocos_show_stats', {
236
348
  description: 'Show or hide the Cocos profiler overlay (FPS, draw calls, triangles) through the public profiler API.',
237
- inputSchema: z.object({ pageUrl, visible: z.boolean() }).strict(),
349
+ inputSchema: obj({ pageUrl, visible: z.boolean() }),
238
350
  annotations: runtimeMutation,
239
351
  }, input => execute({ action: 'showStats', visible: input.visible }, input.pageUrl));
240
352
  if (web)
241
353
  server.registerTool('cocos_emulate_device', {
242
354
  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({
355
+ inputSchema: obj({
244
356
  pageUrl,
245
357
  reset: z.boolean().optional(),
246
358
  preset: z.enum(devicePresetNames).optional(),
@@ -269,34 +381,55 @@ export function createServer(browser, options = {}) {
269
381
  }
270
382
  server.registerTool('cocos_explain_click', {
271
383
  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()
384
+ inputSchema: obj({ pageUrl, ...target, x: z.number().min(0).max(20_000).optional(), y: z.number().min(0).max(20_000).optional() }).strict()
273
385
  .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),
386
+ .refine(value => value.x !== undefined
387
+ ? value.uuid === undefined && value.path === undefined
388
+ : oneTarget(value), 'Provide uuid, path, or x and y'),
276
389
  annotations: readOnly,
277
390
  }, input => input.uuid === undefined && input.path === undefined
278
391
  ? execute({ action: 'explainClick', x: input.x, y: input.y }, input.pageUrl)
279
392
  : executeNode(input, uuid => ({ action: 'explainClick', uuid, x: input.x, y: input.y })));
280
393
  server.registerTool('cocos_listener_report', {
281
394
  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(),
395
+ inputSchema: obj({ pageUrl, limit: z.number().int().min(1).max(500).optional() }),
283
396
  annotations: readOnly,
284
397
  }, input => execute({ action: 'listenerReport', limit: input.limit }, input.pageUrl));
398
+ for (const [name, action, description] of [
399
+ ['cocos_explain_visibility', 'explainVisibility', 'Explain why a node is not visible: inactive ancestors, disabled or transparent renderer, missing spriteFrame, empty Label, zero opacity/scale/size, a layer no active camera renders, outside the viewport, clipped by a Mask, or possibly covered by an opaque Sprite. Returns reasons (definite) and warnings (possible) without changing anything.'],
400
+ ['cocos_explain_layout', 'explainLayout', 'Explain why a node has its size and position: its Widget edges and align mode, a parent Layout that places or resizes it, Label/Sprite size modes, ancestor scale, and the resulting viewport rectangle. Pair with cocos_emulate_device to compare resolutions.'],
401
+ ]) {
402
+ server.registerTool(name, {
403
+ description,
404
+ inputSchema: obj({ pageUrl, ...target }).refine(oneTarget, oneTargetMessage),
405
+ annotations: readOnly,
406
+ }, input => executeNode(input, uuid => ({ action, uuid })));
407
+ }
408
+ server.registerTool('cocos_animation_report', {
409
+ description: 'List what is animating now: Animation components with playing states (speed, wrap mode, time), Spine/DragonBones components with their current animation, and tween targets with action counts. Read-only; nothing is played or stopped.',
410
+ inputSchema: obj({ pageUrl, limit: z.number().int().min(1).max(500).optional() }),
411
+ annotations: readOnly,
412
+ }, input => execute({ action: 'animationReport', limit: input.limit }, input.pageUrl));
413
+ server.registerTool('cocos_frame_profile', {
414
+ description: 'Sample real frames for a while and report frame time (avg, p50, p95, max), fps, spike count, the worst frames, and draw call and triangle ranges. Needs the game running (not paused). Subscribes to the director for the sample window only.',
415
+ inputSchema: obj({ pageUrl, frames: z.number().int().min(2).max(600).optional(), timeoutMs: z.number().int().min(100).max(30_000).optional() }),
416
+ annotations: { ...readOnly, idempotentHint: false },
417
+ }, input => execute({ action: 'frameProfile', frames: input.frames, timeoutMs: input.timeoutMs }, input.pageUrl));
285
418
  server.registerTool('cocos_dynamic_atlas', {
286
419
  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(),
420
+ inputSchema: obj({ pageUrl, limit: z.number().int().min(1).max(500).optional() }),
288
421
  annotations: readOnly,
289
422
  }, input => execute({ action: 'dynamicAtlas', limit: input.limit }, input.pageUrl));
290
423
  server.registerTool('cocos_asset_report', {
291
424
  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(),
425
+ inputSchema: obj({ pageUrl, type: blank(z.string().min(1).max(120)), unusedOnly: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() }),
293
426
  annotations: readOnly,
294
427
  }, input => execute({ action: 'assetReport', type: input.type, unusedOnly: input.unusedOnly, limit: input.limit }, input.pageUrl));
295
428
  if (options.allowMethodCall) {
296
429
  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
430
  server.registerTool('cocos_call_method', {
298
431
  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),
432
+ inputSchema: obj({ pageUrl, ...target, componentUuid: blank(z.string().min(1)), 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() }).refine(oneTarget, oneTargetMessage),
300
433
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
301
434
  }, input => executeNode(input, uuid => ({ action: 'callMethod', uuid, componentUuid: input.componentUuid, method: input.method, args: input.args ?? [], awaitMs: input.awaitMs, maxDepth: input.maxDepth })));
302
435
  }
@@ -313,39 +446,36 @@ export function createServer(browser, options = {}) {
313
446
  };
314
447
  server.registerTool('cocos_console_messages', {
315
448
  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(),
449
+ inputSchema: obj({ pageUrl, types: z.array(z.enum(['log', 'debug', 'info', 'error', 'warning', 'assert', 'trace', 'pageerror'])).max(8).optional(), textContains: blank(z.string().min(1).max(200)), limit: z.number().int().min(1).max(200).optional() }),
317
450
  annotations: browserData,
318
451
  }, 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
- }
452
+ server.registerTool('cocos_network_requests', {
453
+ 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.',
454
+ inputSchema: obj({ pageUrl, urlContains: blank(z.string().min(1).max(500)), resourceType: blank(z.string().min(1).max(40)), failedOnly: z.boolean().optional(), limit: z.number().int().min(1).max(200).optional() }),
455
+ annotations: browserData,
456
+ }, input => run(async () => (web ? networkRequests : nativeNetworkRequests)(await browser.page(input.pageUrl), input)));
457
+ server.registerTool('cocos_network_request', {
458
+ 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.',
459
+ inputSchema: obj({ pageUrl, id: z.number().int().min(1), includeBody: z.boolean().optional() }),
460
+ annotations: browserData,
461
+ }, input => run(async () => (web ? networkRequest : nativeNetworkRequest)(await browser.page(input.pageUrl), input.id, input.includeBody ?? false)));
462
+ server.registerTool('cocos_storage', {
463
+ 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.',
464
+ inputSchema: obj({ pageUrl, area: z.enum(['local', 'session', 'cookies']), keyContains: blank(z.string().min(1).max(200)), limit: z.number().int().min(1).max(500).optional() }),
465
+ annotations: browserData,
466
+ }, input => run(async () => storage(await browser.page(input.pageUrl), input.area, input.keyContains, input.limit)));
337
467
  }
338
468
  if (web)
339
469
  server.registerTool('cocos_get_selection', {
340
470
  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(),
471
+ inputSchema: obj({ pageUrl, disable: z.boolean().optional() }),
342
472
  // Installs a page-level listener and DOM overlay only; never touches the Cocos graph.
343
473
  annotations: { ...readOnly, readOnlyHint: false },
344
474
  }, input => execute({ action: 'selection', disable: input.disable }, input.pageUrl));
345
475
  if (web)
346
476
  server.registerTool('cocos_highlight_node', {
347
477
  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),
478
+ inputSchema: obj({ pageUrl, ...target, durationMs: z.number().int().min(100).max(10_000).optional() }).refine(oneTarget, oneTargetMessage),
349
479
  annotations: temporaryMutation,
350
480
  }, input => executeNode(input, uuid => ({ action: 'highlightNode', uuid, durationMs: input.durationMs })));
351
481
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cocos-web-inspector-mcp",
3
- "version": "2.2.1",
3
+ "version": "2.4.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": {