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.
- package/README.md +31 -8
- package/dist/src/bridge.d.ts +40 -1
- package/dist/src/bridge.js +526 -28
- package/dist/src/browser-data.d.ts +17 -2
- package/dist/src/browser-data.js +15 -3
- package/dist/src/browser.js +6 -3
- package/dist/src/cli.js +32 -2
- package/dist/src/native.d.ts +15 -0
- package/dist/src/native.js +271 -7
- package/dist/src/server.js +209 -79
- package/package.json +1 -1
package/dist/src/server.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
58
|
+
: message.startsWith('Active Cocos scene not found') ? 'SCENE_NOT_READY'
|
|
55
59
|
: message.startsWith('Node not found') ? 'NODE_NOT_FOUND'
|
|
56
|
-
: message
|
|
57
|
-
: message
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
212
|
+
inputSchema: obj({ pageUrl, ...target }).refine(oneTarget, oneTargetMessage),
|
|
137
213
|
annotations: readOnly,
|
|
138
214
|
}, input => executeNode(input, uuid => ({ action: 'getNodeBounds', uuid })));
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
},
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
275
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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:
|
|
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:
|
|
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;
|