cocos-web-inspector-mcp 2.3.0 → 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.
@@ -82,8 +82,10 @@ export class BrowserConnection {
82
82
  if (pageUrl) {
83
83
  const wanted = validateLocalUrl(pageUrl, true).href;
84
84
  const selected = pages.filter(candidate => new URL(candidate.url()).href === wanted);
85
- if (selected.length === 0)
86
- throw new InspectorError('PAGE_NOT_FOUND', `Local page not found: ${sanitizeUrl(pageUrl)}`);
85
+ if (selected.length === 0) {
86
+ const open = pages.slice(0, 10).map(candidate => sanitizeUrl(candidate.url())).join(', ');
87
+ throw new InspectorError('PAGE_NOT_FOUND', `Local page not found: ${sanitizeUrl(pageUrl)} (pageUrl must equal the full open page URL including query and fragment; open pages, shown without them: ${open || 'none'})`);
88
+ }
87
89
  if (selected.length > 1)
88
90
  throw new InspectorError('MULTIPLE_PAGES', `Multiple localhost pages match pageUrl: ${sanitizeUrl(pageUrl)}; close duplicate tabs or use a separate Chromium per project`);
89
91
  return selected[0];
@@ -139,7 +141,8 @@ export class BrowserConnection {
139
141
  const hint = /\b404\b/.test(message)
140
142
  ? '; port is likely held by Chrome built-in remote debugging (chrome://inspect/#remote-debugging), which has no /json/version: turn it off or use another port, check with lsof -nP -iTCP:<port> -sTCP:LISTEN'
141
143
  : '';
142
- throw new InspectorError('CDP_UNAVAILABLE', `Unable to connect to Chromium CDP: ${message}${hint}`);
144
+ const launch = /ECONNREFUSED/.test(message) ? '; no Chromium is listening there: start one with --remote-debugging-port=<port> --user-data-dir=<dir>, or point the server at the right port with --cdp-endpoint / COCOS_CDP_ENDPOINT' : '';
145
+ throw new InspectorError('CDP_UNAVAILABLE', `Unable to connect to Chromium CDP at ${sanitizeUrl(this.endpoint)}: ${message}${hint}${launch}`);
143
146
  });
144
147
  this.#connecting = connecting;
145
148
  try {
@@ -13,9 +13,9 @@ export declare class NativeConnection {
13
13
  }
14
14
  export declare function nativeClick(page: Page, uuid: string): Promise<unknown>;
15
15
  export declare function nativeDrag(page: Page, uuid: string, dx: number, dy: number, durationMs?: number): Promise<unknown>;
16
- export declare function nativeCaptureNode(page: Page, uuid: string): Promise<unknown>;
16
+ export declare function nativeCaptureNode(page: Page, uuid: string, maxBytes?: number): Promise<unknown>;
17
17
  /** Crops a raw screencap to the clip and encodes an RGB PNG, sampling every n-th pixel until it fits the response. */
18
- export declare function cropPng(raw: Buffer, clip: Clip): unknown;
18
+ export declare function cropPng(raw: Buffer, clip: Clip, maxBytes?: number): unknown;
19
19
  /**
20
20
  * The inspector port from the engine's own "Debugger listening" logcat lines: the latest one, or `expected` only when
21
21
  * some line names it. Any app can log under the Cocos tag, so reconnects pass the port they already used.
@@ -34,6 +34,7 @@ export declare function nativeConsoleMessages(page: Page, options: {
34
34
  textContains?: string | undefined;
35
35
  limit?: number | undefined;
36
36
  }): Promise<unknown>;
37
+ export declare function nativePressKey(_page: Page, key: string): Promise<unknown>;
37
38
  export declare function nativeTypeText(page: Page, uuid: string, text: string, submit: boolean): Promise<unknown>;
38
39
  export declare function nativeNetworkRequests(page: Page, options: Parameters<typeof selectRequests>[1]): Promise<unknown>;
39
40
  export declare function nativeNetworkRequest(page: Page, id: number, includeBody: boolean): Promise<unknown>;
@@ -122,15 +122,15 @@ export async function nativeDrag(page, uuid, dx, dy, durationMs = 300) {
122
122
  // Raw `screencap` output: width, height, and pixel format as uint32 LE, a colorspace word on Android 12+, then RGBA rows
123
123
  // in the current display rotation. The device gzips it (18 MB raw takes ~20 s over Wi-Fi adb, ~1.5 s gzipped), and cropping
124
124
  // raw pixels needs only zlib here, where decoding `screencap -p` would need a PNG decoder.
125
- export async function nativeCaptureNode(page, uuid) {
125
+ export async function nativeCaptureNode(page, uuid, maxBytes) {
126
126
  const clip = await visibleClip(page, uuid);
127
127
  if ('reason' in clip)
128
128
  return { captured: false, reason: clip.reason };
129
129
  const raw = gunzipSync(await adbBuffer(['exec-out', 'screencap | gzip -1']), { maxOutputLength: 256 * 1024 * 1024 });
130
- return cropPng(raw, clip);
130
+ return cropPng(raw, clip, maxBytes);
131
131
  }
132
132
  /** Crops a raw screencap to the clip and encodes an RGB PNG, sampling every n-th pixel until it fits the response. */
133
- export function cropPng(raw, clip) {
133
+ export function cropPng(raw, clip, maxBytes) {
134
134
  if (raw.length < 12)
135
135
  return { captured: false, reason: 'SCREENCAP_FAILED' };
136
136
  const screenWidth = raw.readUInt32LE(0);
@@ -158,7 +158,7 @@ export function cropPng(raw, clip) {
158
158
  raw.copy(rows, out + x * 3, source + x * step * 4, source + x * step * 4 + 3);
159
159
  }
160
160
  const data = png(width, height, rows).toString('base64');
161
- if (fitsImage(data))
161
+ if (fitsImage(data, maxBytes))
162
162
  return { captured: true, mimeType: 'image/png', data, width: right - left, height: bottom - top, ...(step > 1 ? { scale: 1 / step } : {}) };
163
163
  }
164
164
  return { captured: false, reason: 'RESPONSE_LIMIT' };
@@ -229,6 +229,17 @@ export async function nativeConsoleMessages(page, options) {
229
229
  note: 'Cocos-tagged logcat lines of the app process since it started, bounded by the device log buffer. Engine lines start with D/ or E/; an uncaught JS error spans several error lines (message, then stack).',
230
230
  };
231
231
  }
232
+ const androidKeys = { Enter: 'KEYCODE_ENTER', Escape: 'KEYCODE_ESCAPE', Tab: 'KEYCODE_TAB', Delete: 'KEYCODE_DEL', Space: 'KEYCODE_SPACE', ArrowUp: 'KEYCODE_DPAD_UP', ArrowDown: 'KEYCODE_DPAD_DOWN', ArrowLeft: 'KEYCODE_DPAD_LEFT', ArrowRight: 'KEYCODE_DPAD_RIGHT', Home: 'KEYCODE_MOVE_HOME', End: 'KEYCODE_MOVE_END', PageUp: 'KEYCODE_PAGE_UP', PageDown: 'KEYCODE_PAGE_DOWN' };
233
+ // The native engine reports AKEYCODE_DEL (the physical Backspace key) as Delete, has no entry for FORWARD_DEL, and reports Backspace only for
234
+ // AKEYCODE_BACK, which leaves the app. So Delete maps to DEL and Backspace is refused rather than sent as a key the game would misread.
235
+ export async function nativePressKey(_page, key) {
236
+ if (key === 'Backspace')
237
+ throw new Error('Invalid mutation: native builds deliver Backspace only for the system Back key, which leaves the app; use Delete, which the engine reports for the device Backspace key');
238
+ // Only validated key names reach adb, never free text.
239
+ await adb(['shell', 'input', 'keyevent', androidKeys[key] ?? `KEYCODE_${key.toUpperCase()}`]);
240
+ await settle();
241
+ return { pressed: true, key, input: 'adb', runtimeOnly: true };
242
+ }
232
243
  // Tapping an EditBox on Android opens CocosEditBoxActivity, a native EditText whose every change fires text-changed in the game.
233
244
  export async function nativeTypeText(page, uuid, text, submit) {
234
245
  // input text only types printable ASCII and turns "%s" into a space, so refuse what it would silently mangle.
@@ -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 { nativeCaptureNode, nativeClick, nativeConsoleMessages, nativeDrag, nativeNetworkRequest, nativeNetworkRequests, 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,10 +55,10 @@ 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 };
@@ -65,6 +69,73 @@ export function createServer(browser, options = {}) {
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,95 +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
215
  server.registerTool('cocos_capture_node', {
140
- description: 'Capture a bounded viewport-clipped PNG for one visible Cocos UI node UUID.',
141
- inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
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),
142
218
  annotations: readOnly,
143
- }, input => withNode(input, web ? captureNode : nativeCaptureNode));
219
+ }, input => withNode(input, (page, uuid) => (web ? captureNode : nativeCaptureNode)(page, uuid, input.maxBytes)));
144
220
  server.registerTool('cocos_get_components', {
145
221
  description: 'List bounded component summaries for one Cocos node UUID.',
146
- inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
222
+ inputSchema: obj({ pageUrl, ...target }).refine(oneTarget, oneTargetMessage),
147
223
  annotations: readOnly,
148
224
  }, input => executeNode(input, uuid => ({ action: 'getComponents', uuid })));
149
225
  server.registerTool('cocos_get_properties', {
150
226
  description: 'Read bounded, cycle-safe public properties from a Cocos node or one component.',
151
- 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)
152
228
  .refine(value => !(value.componentType && value.componentUuid), 'Provide componentType or componentUuid, not both'),
153
229
  annotations: readOnly,
154
230
  }, input => executeNode(input, uuid => ({ action: 'getProperties', uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: input.maxDepth })));
155
231
  server.registerTool('cocos_wait_for_property', {
156
232
  description: 'Poll one top-level property of a Cocos node or component until it equals a value or the timeout passes.',
157
- 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)
158
234
  .refine(value => !(value.componentType && value.componentUuid), 'Provide componentType or componentUuid, not both'),
159
235
  annotations: readOnly,
160
- }, input => withNode(input, async (page, uuid) => {
161
- const request = { action: 'getProperties', uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: 0 };
162
- const deadline = Date.now() + (input.timeoutMs ?? 5_000);
163
- let polls = 0;
164
- let value;
165
- for (;;) {
166
- polls++;
167
- value = (await runBridge(page, request)).properties?.[input.key];
168
- if (value === input.equals || Date.now() >= deadline)
169
- break;
170
- 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);
171
268
  }
172
- return { matched: value === input.equals, key: input.key, value: value ?? null, polls };
173
- }));
269
+ });
174
270
  if (options.allowRuntimeMutation) {
175
271
  server.registerTool('cocos_set_node_active', {
176
272
  description: 'Set the active state of one Cocos node selected by exact UUID.',
177
- inputSchema: z.object({ pageUrl, ...target, active: z.boolean() }).strict().refine(oneTarget, oneTargetMessage),
273
+ inputSchema: obj({ pageUrl, ...target, active: z.boolean() }).refine(oneTarget, oneTargetMessage),
178
274
  annotations: runtimeMutation,
179
275
  }, input => executeNode(input, uuid => ({ action: 'setNodeActive', uuid, active: input.active })));
180
276
  server.registerTool('cocos_set_transform', {
181
277
  description: 'Update selected Cocos node position, rotation, or scale by exact UUID.',
182
- 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)
183
279
  .refine(value => !!value.position || !!value.rotation || !!value.scale, 'Provide position, rotation, or scale'),
184
280
  annotations: runtimeMutation,
185
281
  }, input => executeNode(input, uuid => ({ action: 'setTransform', uuid, position: input.position, rotation: input.rotation, scale: input.scale })));
186
282
  server.registerTool('cocos_set_property', {
187
283
  description: 'Update one bounded public data property selected by node and component UUID.',
188
- 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),
189
285
  annotations: runtimeMutation,
190
286
  }, input => executeNode(input, uuid => ({ action: 'setProperty', uuid, componentUuid: input.componentUuid, key: input.key, value: input.value })));
191
287
  server.registerTool('cocos_click_node', {
192
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.',
193
- inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
289
+ inputSchema: obj({ pageUrl, ...target }).refine(oneTarget, oneTargetMessage),
194
290
  // Game click handlers run arbitrary code: a login button reaches real servers, a buy button spends currency.
195
291
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
196
292
  }, input => withNode(input, web ? clickNode : nativeClick));
@@ -198,48 +294,65 @@ export function createServer(browser, options = {}) {
198
294
  const realInput = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
199
295
  server.registerTool('cocos_drag_node', {
200
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.',
201
- 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)
202
298
  .refine(value => value.dx !== 0 || value.dy !== 0, 'Provide a non-zero dx or dy'),
203
299
  annotations: realInput,
204
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)));
205
301
  server.registerTool('cocos_type_text', {
206
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.',
207
- 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),
208
304
  annotations: realInput,
209
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
+ });
210
318
  if (web)
211
319
  server.registerTool('cocos_analyze_batches', {
212
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.',
213
- 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() }),
214
322
  annotations: { ...runtimeMutation, idempotentHint: false },
215
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));
216
329
  for (const action of ['pause', 'resume']) {
217
330
  server.registerTool(`cocos_${action}`, {
218
331
  description: `${action === 'pause' ? 'Pause' : 'Resume'} the Cocos director through its public API.`,
219
- inputSchema: z.object({ pageUrl }).strict(),
332
+ inputSchema: obj({ pageUrl }),
220
333
  annotations: runtimeMutation,
221
334
  }, input => execute({ action }, input.pageUrl));
222
335
  }
223
336
  if (web)
224
337
  server.registerTool('cocos_step_frame', {
225
338
  description: 'Advance a paused Cocos game by fixed-delta frames through cc.game.step; requires cocos_pause first.',
226
- 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() }),
227
340
  annotations: { ...runtimeMutation, idempotentHint: false },
228
341
  }, input => execute({ action: 'stepFrame', frames: input.frames }, input.pageUrl));
229
342
  server.registerTool('cocos_set_time_scale', {
230
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.',
231
- 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() }),
232
345
  annotations: runtimeMutation,
233
346
  }, input => execute({ action: 'timeScale', scale: input.scale }, input.pageUrl));
234
347
  server.registerTool('cocos_show_stats', {
235
348
  description: 'Show or hide the Cocos profiler overlay (FPS, draw calls, triangles) through the public profiler API.',
236
- inputSchema: z.object({ pageUrl, visible: z.boolean() }).strict(),
349
+ inputSchema: obj({ pageUrl, visible: z.boolean() }),
237
350
  annotations: runtimeMutation,
238
351
  }, input => execute({ action: 'showStats', visible: input.visible }, input.pageUrl));
239
352
  if (web)
240
353
  server.registerTool('cocos_emulate_device', {
241
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.',
242
- inputSchema: z.object({
355
+ inputSchema: obj({
243
356
  pageUrl,
244
357
  reset: z.boolean().optional(),
245
358
  preset: z.enum(devicePresetNames).optional(),
@@ -268,34 +381,55 @@ export function createServer(browser, options = {}) {
268
381
  }
269
382
  server.registerTool('cocos_explain_click', {
270
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.',
271
- 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()
272
385
  .refine(value => (value.x === undefined) === (value.y === undefined), 'Provide x and y together')
273
- .refine(value => value.uuid !== undefined || value.path !== undefined || value.x !== undefined, 'Provide uuid, path, or x and y')
274
- .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'),
275
389
  annotations: readOnly,
276
390
  }, input => input.uuid === undefined && input.path === undefined
277
391
  ? execute({ action: 'explainClick', x: input.x, y: input.y }, input.pageUrl)
278
392
  : executeNode(input, uuid => ({ action: 'explainClick', uuid, x: input.x, y: input.y })));
279
393
  server.registerTool('cocos_listener_report', {
280
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.',
281
- 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() }),
282
396
  annotations: readOnly,
283
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));
284
418
  server.registerTool('cocos_dynamic_atlas', {
285
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, ...).',
286
- 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() }),
287
421
  annotations: readOnly,
288
422
  }, input => execute({ action: 'dynamicAtlas', limit: input.limit }, input.pageUrl));
289
423
  server.registerTool('cocos_asset_report', {
290
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.',
291
- 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() }),
292
426
  annotations: readOnly,
293
427
  }, input => execute({ action: 'assetReport', type: input.type, unusedOnly: input.unusedOnly, limit: input.limit }, input.pageUrl));
294
428
  if (options.allowMethodCall) {
295
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)]));
296
430
  server.registerTool('cocos_call_method', {
297
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.',
298
- 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),
299
433
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
300
434
  }, input => executeNode(input, uuid => ({ action: 'callMethod', uuid, componentUuid: input.componentUuid, method: input.method, args: input.args ?? [], awaitMs: input.awaitMs, maxDepth: input.maxDepth })));
301
435
  }
@@ -312,36 +446,36 @@ export function createServer(browser, options = {}) {
312
446
  };
313
447
  server.registerTool('cocos_console_messages', {
314
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.',
315
- 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() }),
316
450
  annotations: browserData,
317
451
  }, input => run(async () => (web ? consoleMessages : nativeConsoleMessages)(await browser.page(input.pageUrl), input)));
318
452
  server.registerTool('cocos_network_requests', {
319
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.',
320
- 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(),
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() }),
321
455
  annotations: browserData,
322
456
  }, input => run(async () => (web ? networkRequests : nativeNetworkRequests)(await browser.page(input.pageUrl), input)));
323
457
  server.registerTool('cocos_network_request', {
324
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.',
325
- inputSchema: z.object({ pageUrl, id: z.number().int().min(1), includeBody: z.boolean().optional() }).strict(),
459
+ inputSchema: obj({ pageUrl, id: z.number().int().min(1), includeBody: z.boolean().optional() }),
326
460
  annotations: browserData,
327
461
  }, input => run(async () => (web ? networkRequest : nativeNetworkRequest)(await browser.page(input.pageUrl), input.id, input.includeBody ?? false)));
328
462
  server.registerTool('cocos_storage', {
329
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.',
330
- 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(),
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() }),
331
465
  annotations: browserData,
332
466
  }, input => run(async () => storage(await browser.page(input.pageUrl), input.area, input.keyContains, input.limit)));
333
467
  }
334
468
  if (web)
335
469
  server.registerTool('cocos_get_selection', {
336
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.',
337
- inputSchema: z.object({ pageUrl, disable: z.boolean().optional() }).strict(),
471
+ inputSchema: obj({ pageUrl, disable: z.boolean().optional() }),
338
472
  // Installs a page-level listener and DOM overlay only; never touches the Cocos graph.
339
473
  annotations: { ...readOnly, readOnlyHint: false },
340
474
  }, input => execute({ action: 'selection', disable: input.disable }, input.pageUrl));
341
475
  if (web)
342
476
  server.registerTool('cocos_highlight_node', {
343
477
  description: 'Temporarily draw a pointer-transparent DOM overlay around one Cocos UI node.',
344
- 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),
345
479
  annotations: temporaryMutation,
346
480
  }, input => executeNode(input, uuid => ({ action: 'highlightNode', uuid, durationMs: input.durationMs })));
347
481
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cocos-web-inspector-mcp",
3
- "version": "2.3.0",
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": {