cocos-web-inspector-mcp 0.1.6 → 0.1.8
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 +9 -5
- package/dist/src/bridge.d.ts +3 -0
- package/dist/src/bridge.js +82 -11
- package/dist/src/server.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,19 +98,20 @@ The endpoint may instead be provided through the MCP process environment:
|
|
|
98
98
|
|
|
99
99
|
## Tools
|
|
100
100
|
|
|
101
|
-
All tool input objects are strict. Unknown fields are rejected. Inspection tools are annotated as read-only, idempotent, non-destructive, and closed-world. The highlight tool is annotated as a non-destructive, non-idempotent, closed-world temporary mutation because repeated calls reset its removal timer.
|
|
101
|
+
All tool input objects are strict. Unknown fields are rejected. Inspection tools are annotated as read-only, idempotent, non-destructive, and closed-world. The highlight tool is annotated as a non-destructive, non-idempotent, closed-world temporary mutation because repeated calls reset its removal timer. Runtime debugger tools are non-read-only and closed-world; `cocos_click_node` is additionally destructive and open-world because game click handlers can reach real servers or make irreversible changes, and it and `cocos_step_frame` are non-idempotent.
|
|
102
102
|
|
|
103
103
|
| Tool | Purpose | Inputs |
|
|
104
104
|
| --- | --- | --- |
|
|
105
105
|
| `cocos_list_pages` | List bounded summaries of eligible localhost pages, including sanitized URL, title, Cocos detection, version, and scene name. | None |
|
|
106
106
|
| `cocos_runtime_info` | Return bounded engine, scene, canvas, view, director, and node-count information. | `pageUrl?` |
|
|
107
|
-
| `cocos_runtime_diagnostics` | Return passive bounded hierarchy counts, depth,
|
|
107
|
+
| `cocos_runtime_diagnostics` | Return passive bounded hierarchy counts, depth, duplicate names, and render metrics (FPS, frame time, draw calls, triangles, instances). | `pageUrl?` |
|
|
108
108
|
| `cocos_set_node_active` | Set one node's active state. Registered only with `--allow-runtime-mutation`; returns before/after state. | `pageUrl?`; `uuid`; `active` boolean |
|
|
109
109
|
| `cocos_set_transform` | Update supplied position, rotation, and/or scale fields for one node. | `pageUrl?`; `uuid`; `position?`, `rotation?`, `scale?` finite vectors |
|
|
110
110
|
| `cocos_set_property` | Update one bounded public component data property. | `pageUrl?`; node `uuid`; `componentUuid`; `key`; primitive/vector/size/color `value` matching current shape |
|
|
111
111
|
| `cocos_click_node` | Dispatch a real mouse click at the visible center of one UI node so Button/touch handlers run. Registered only with `--allow-runtime-mutation`. | `pageUrl?`; `uuid` |
|
|
112
112
|
| `cocos_pause` | Pause the Cocos director when its public API supports it. | `pageUrl?` |
|
|
113
113
|
| `cocos_resume` | Resume the Cocos director when its public API supports it. | `pageUrl?` |
|
|
114
|
+
| `cocos_step_frame` | Advance a paused game by 1–60 fixed-delta frames through `cc.game.step`, then stay paused. Fails unless paused first. | `pageUrl?`, `frames?` |
|
|
114
115
|
| `cocos_scene_tree` | Return a bounded scene tree with node and component summaries. | `pageUrl?`; `maxDepth?` integer `0..20`, default `6`; `maxNodes?` integer `1..5000`, default `500` |
|
|
115
116
|
| `cocos_find_node` | Find nodes with exact or combined bounded filters. | `pageUrl?`; at least one of `uuid`, `name`, `path`, `nameContains`, `componentType`, `active`, `pathPrefix`; `limit?` integer `1..100`, default `20` |
|
|
116
117
|
| `cocos_get_components` | Return bounded component summaries for a node. | `pageUrl?`; `uuid` |
|
|
@@ -122,7 +123,7 @@ All tool input objects are strict. Unknown fields are rejected. Inspection tools
|
|
|
122
123
|
| `cocos_wait_for_property` | Poll one top-level property until it strictly equals a primitive value or the timeout passes. | `pageUrl?`; `uuid`; `componentType?` or `componentUuid?`; `key`; `equals`; `timeoutMs?` `100..30000`, default `5000`; `intervalMs?` `50..5000`, default `200` |
|
|
123
124
|
| `cocos_highlight_node` | Draw a temporary pointer-transparent overlay around a UI node. | `pageUrl?`; `uuid`; `durationMs?` integer `100..10000`, default `2000` |
|
|
124
125
|
|
|
125
|
-
`cocos_runtime_diagnostics` does not enable profiler/statistics systems.
|
|
126
|
+
`cocos_runtime_diagnostics` does not enable profiler/statistics systems. Render metrics are read from the values `Root` and the GFX device already update every frame (the same sources as `root.fps` and `device.numDrawCalls`), whether or not the profiler is shown; draw calls include the profiler overlay when it is visible. Generic invalid-reference checks still return `UNSUPPORTED_PUBLIC_API`. `cocos_highlight_node` temporarily mutates the page DOM only. It does not mutate the Cocos node/component graph or game state. `cocos_capture_node` clips only to the visible browser viewport, never falls back to full-page capture, bounds captures by the visible viewport and encoded response size rather than a fixed pixel cap, returns PNG (or JPEG fallback) base64 in-memory, downscales down to 0.25× (reported as `scale`) when JPEG quality steps are not enough, and rejects responses still oversized after that. Mutation results provide `before` values for manual inverse calls, but restoration cannot undo lifecycle callbacks or other runtime side effects. `cocos_step_frame` uses the public `cc.game.step` (fixed `game.frameTime` delta); because `director.tick` skips logic while the director is paused, it resumes the director only for the synchronous step call and pauses it again.
|
|
126
127
|
|
|
127
128
|
### Page selection
|
|
128
129
|
|
|
@@ -198,7 +199,7 @@ npm run check
|
|
|
198
199
|
|
|
199
200
|
`npm run check` type-checks, builds, runs the self-contained Node.js tests, exercises a vendored Cocos Creator 3.8.8 web fixture through live Chromium and CDP, packs the npm tarball, installs it into a temporary project, and smoke-tests the installed binary. Use it before pushing. `npm test` remains available for build plus self-tests only; `npm run test:integration` runs the live browser test separately.
|
|
200
201
|
|
|
201
|
-
The test suite covers URL policy, CDP connection reuse and recovery, scene traversal, property redaction and cycle handling, output bounds, Cocos version rejection, strict tool schemas, exact tool annotations, real page selection, inspector and debugger tools, visual bounds/capture, snapshots, diagnostics, and browser reconnection. CI installs the matching Playwright Chromium revision, runs the full check on Ubuntu and Windows with Node.js 20 and
|
|
202
|
+
The test suite covers URL policy, CDP connection reuse and recovery, scene traversal, property redaction and cycle handling, output bounds, Cocos version rejection, strict tool schemas, exact tool annotations, real page selection, inspector and debugger tools, visual bounds/capture, snapshots, diagnostics, and browser reconnection. CI installs the matching Playwright Chromium revision, runs the full check on Ubuntu and Windows with Node.js 20, 22, and 24, and rejects high-severity production dependency advisories.
|
|
202
203
|
|
|
203
204
|
## Troubleshooting
|
|
204
205
|
|
|
@@ -206,7 +207,10 @@ The test suite covers URL policy, CDP connection reuse and recovery, scene trave
|
|
|
206
207
|
- `MULTIPLE_PAGES`: call `cocos_list_pages`, then pass the exact reported `pageUrl`. If tabs share one URL, close duplicates or use a separate Chromium per project (see Page selection).
|
|
207
208
|
- `COCOS_NOT_FOUND` or `SCENE_NOT_READY`: wait for the web build to finish loading; use `cocos_runtime_info` after the active scene exists.
|
|
208
209
|
- Runtime mutation tools missing: restart the server with `--allow-runtime-mutation`; a tool call cannot enable this mode.
|
|
209
|
-
- Bounds/capture unavailable: select a visible UI node with `UITransform`. Capture is viewport-only, bounded, and never falls back to a full-page screenshot.
|
|
210
|
+
- Bounds/capture unavailable: select a visible UI node with `UITransform`. `INACTIVE` means the node or an ancestor is inactive. Capture is viewport-only, bounded, and never falls back to a full-page screenshot; `RESPONSE_LIMIT` means even a 0.25× JPEG exceeded the response budget, so capture a smaller node.
|
|
211
|
+
- `cocos_snapshot_subtree` returns `RESPONSE_LIMIT` with a partial tree: snapshot a deeper node, or lower `maxDepth`.
|
|
212
|
+
- `cocos_step_frame` returns `INVALID_MUTATION`: call `cocos_pause` first.
|
|
213
|
+
- Render metrics `fps` reads 0: the engine publishes FPS once per elapsed second, so read again after the scene has run for a second.
|
|
210
214
|
|
|
211
215
|
## Release and compatibility
|
|
212
216
|
|
package/dist/src/bridge.d.ts
CHANGED
package/dist/src/bridge.js
CHANGED
|
@@ -13,16 +13,30 @@ export async function captureNode(page, uuid) {
|
|
|
13
13
|
if (!result.available || !result.visible)
|
|
14
14
|
return { captured: false, reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
|
|
15
15
|
const clip = result.clippedViewport;
|
|
16
|
-
if (!clip || !Number.isFinite(clip.x) || !Number.isFinite(clip.y) || !Number.isFinite(clip.width) || !Number.isFinite(clip.height) || clip.width <= 0 || clip.height <= 0
|
|
17
|
-
return { captured: false, reason: '
|
|
18
|
-
|
|
16
|
+
if (!clip || !Number.isFinite(clip.x) || !Number.isFinite(clip.y) || !Number.isFinite(clip.width) || !Number.isFinite(clip.height) || clip.width <= 0 || clip.height <= 0)
|
|
17
|
+
return { captured: false, reason: 'INVALID_GEOMETRY' };
|
|
18
|
+
const fits = (data) => Buffer.byteLength(JSON.stringify({ data }), 'utf8') <= MAX_BYTES - 4_096;
|
|
19
|
+
const size = { width: Math.round(clip.width), height: Math.round(clip.height) };
|
|
20
|
+
// Device-pixel PNG, then CSS-pixel JPEG quality steps.
|
|
19
21
|
const attempts = [{ type: 'png', scale: 'device' }, ...[80, 60, 40].map(quality => ({ type: 'jpeg', quality, scale: 'css' }))];
|
|
20
22
|
for (const options of attempts) {
|
|
21
23
|
const data = (await page.screenshot({ ...options, clip })).toString('base64');
|
|
22
|
-
if (
|
|
23
|
-
return { captured: true, mimeType: `image/${options.type}`, data,
|
|
24
|
+
if (fits(data))
|
|
25
|
+
return { captured: true, mimeType: `image/${options.type}`, data, ...size };
|
|
26
|
+
}
|
|
27
|
+
// Playwright has no output scale, so downscale through CDP; its clip is document-relative.
|
|
28
|
+
const session = await page.context().newCDPSession(page);
|
|
29
|
+
try {
|
|
30
|
+
const [scrollX, scrollY] = await page.evaluate(() => [window.scrollX, window.scrollY]);
|
|
31
|
+
for (const scale of [0.75, 0.5, 0.35, 0.25]) {
|
|
32
|
+
const { data } = await session.send('Page.captureScreenshot', { format: 'jpeg', quality: 60, clip: { x: clip.x + scrollX, y: clip.y + scrollY, width: clip.width, height: clip.height, scale } });
|
|
33
|
+
if (fits(data))
|
|
34
|
+
return { captured: true, mimeType: 'image/jpeg', data, ...size, scale };
|
|
24
35
|
}
|
|
25
36
|
}
|
|
37
|
+
finally {
|
|
38
|
+
await session.detach().catch(() => { });
|
|
39
|
+
}
|
|
26
40
|
return { captured: false, reason: 'RESPONSE_LIMIT' };
|
|
27
41
|
}
|
|
28
42
|
export async function clickNode(page, uuid) {
|
|
@@ -192,7 +206,8 @@ export function inspectCocos(request) {
|
|
|
192
206
|
if (!Object.values(viewport).every(Number.isFinite) || viewport.width <= 0 || viewport.height <= 0)
|
|
193
207
|
return { available: false, reason: 'INVALID_GEOMETRY' };
|
|
194
208
|
const clipped = { x: Math.max(0, viewport.x), y: Math.max(0, viewport.y), width: Math.max(0, Math.min(innerWidth, viewport.x + viewport.width) - Math.max(0, viewport.x)), height: Math.max(0, Math.min(innerHeight, viewport.y + viewport.height) - Math.max(0, viewport.y)) };
|
|
195
|
-
|
|
209
|
+
const inactive = node.activeInHierarchy === false;
|
|
210
|
+
return { available: true, canvas: { x: worldX, y: worldY, width: worldWidth, height: worldHeight }, viewport, clippedViewport: clipped, anchor: { x: Number(transform.anchorPoint.x), y: Number(transform.anchorPoint.y) }, worldPosition: { x: Number(position.x), y: Number(position.y), z: Number(position.z ?? 0) }, visible: !inactive && clipped.width > 0 && clipped.height > 0, outsideViewport: clipped.width === 0 || clipped.height === 0, ...(inactive ? { reason: 'INACTIVE' } : {}) };
|
|
196
211
|
};
|
|
197
212
|
if (request.action === 'runtimeInfo') {
|
|
198
213
|
const canvas = cc.game?.canvas ?? root.document?.querySelector('#GameCanvas');
|
|
@@ -231,13 +246,28 @@ export function inspectCocos(request) {
|
|
|
231
246
|
}
|
|
232
247
|
});
|
|
233
248
|
const duplicateNames = [...names.entries()].filter(([, uuids]) => uuids.length > 1).slice(0, 100).map(([name, uuids]) => ({ name, uuids, count: uuids.length }));
|
|
249
|
+
// Root and the GFX device update these every frame whether or not the profiler is shown; read backing fields, never getters.
|
|
250
|
+
const renderRoot = cc.director && typeof cc.director === 'object' ? dataProperty(cc.director, '_root') : undefined;
|
|
251
|
+
const device = renderRoot && typeof renderRoot === 'object' ? dataProperty(renderRoot, '_device') : undefined;
|
|
252
|
+
const metric = (owner, key) => {
|
|
253
|
+
const value = owner && typeof owner === 'object' ? dataProperty(owner, key) : undefined;
|
|
254
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
255
|
+
};
|
|
256
|
+
const render = { fps: metric(renderRoot, '_fps'), frameTimeMs: metric(renderRoot, '_frameTime'), drawCalls: metric(device, '_numDrawCalls'), triangles: metric(device, '_numTris'), instances: metric(device, '_numInstances') };
|
|
257
|
+
if (render.frameTimeMs !== undefined)
|
|
258
|
+
render.frameTimeMs *= 1_000;
|
|
259
|
+
const unavailableMetrics = { invalidComponentReferences: 'UNSUPPORTED_PUBLIC_API' };
|
|
260
|
+
for (const [key, value] of Object.entries(render))
|
|
261
|
+
if (value === undefined)
|
|
262
|
+
unavailableMetrics[key] = 'UNSUPPORTED_PUBLIC_API';
|
|
234
263
|
return {
|
|
235
264
|
version,
|
|
236
265
|
nodeCount,
|
|
237
266
|
componentCount,
|
|
238
267
|
maxHierarchyDepth: maxDepth,
|
|
239
268
|
duplicateNames,
|
|
240
|
-
|
|
269
|
+
render: Object.fromEntries(Object.entries(render).filter(([, value]) => value !== undefined)),
|
|
270
|
+
unavailableMetrics,
|
|
241
271
|
truncated: traversal.truncated || duplicateNames.length >= 100,
|
|
242
272
|
truncationReasons: traversal.truncated || duplicateNames.length >= 100 ? ['NODE_LIMIT'] : [],
|
|
243
273
|
};
|
|
@@ -329,17 +359,24 @@ export function inspectCocos(request) {
|
|
|
329
359
|
const maxDepth = Math.min(Math.max(request.maxDepth ?? 6, 0), 20);
|
|
330
360
|
const maxNodes = Math.min(Math.max(request.maxNodes ?? 500, 1), 5_000);
|
|
331
361
|
let count = 0;
|
|
362
|
+
// ponytail: the MCP response carries text and structuredContent, so ~70 KB of JSON (escaped text + structured copy) fits runBridge's ceiling; stop early instead of dropping everything.
|
|
363
|
+
let bytes = 0;
|
|
332
364
|
const reasons = new Set();
|
|
333
365
|
const build = (node, depth) => {
|
|
334
366
|
if (count >= maxNodes) {
|
|
335
367
|
reasons.add('NODE_LIMIT');
|
|
336
368
|
return undefined;
|
|
337
369
|
}
|
|
338
|
-
count++;
|
|
339
370
|
const item = {
|
|
340
371
|
...summary(node),
|
|
341
372
|
components: components(node).slice(0, 200).map(component => ({ type: componentName(component), uuid: String(component.uuid ?? ''), enabled: component.enabled !== false })),
|
|
342
373
|
};
|
|
374
|
+
bytes += JSON.stringify(item).length;
|
|
375
|
+
if (bytes > 70_000) {
|
|
376
|
+
reasons.add('RESPONSE_LIMIT');
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
count++;
|
|
343
380
|
if (components(node).length > 200)
|
|
344
381
|
reasons.add('NODE_LIMIT');
|
|
345
382
|
if (depth >= maxDepth) {
|
|
@@ -395,12 +432,16 @@ export function inspectCocos(request) {
|
|
|
395
432
|
}
|
|
396
433
|
if (typeof value !== 'object')
|
|
397
434
|
return undefined;
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
435
|
+
// Cocos 3.x exposes uuid/children/name as accessors; read their backing fields instead.
|
|
436
|
+
const uuid = dataProperty(value, 'uuid') ?? dataProperty(value, '_id');
|
|
437
|
+
if (reference && uuid && Array.isArray(dataProperty(value, 'children') ?? dataProperty(value, '_children'))) {
|
|
438
|
+
return { $type: 'Node', uuid: String(uuid), name: String(dataProperty(value, 'name') ?? dataProperty(value, '_name') ?? '').slice(0, 500) };
|
|
401
439
|
}
|
|
402
440
|
if (reference && uuid && dataProperty(value, 'node'))
|
|
403
441
|
return { $type: 'Component', uuid: String(uuid), type: componentName(value) };
|
|
442
|
+
if (reference && typeof cc.Asset === 'function' && value instanceof cc.Asset) {
|
|
443
|
+
return { $type: componentName(value), name: String(dataProperty(value, '_name') ?? '').slice(0, 500), uuid: String(dataProperty(value, '_uuid') ?? '') };
|
|
444
|
+
}
|
|
404
445
|
if (depth > 0 && depth >= maxDepth) {
|
|
405
446
|
truncate('MAX_DEPTH');
|
|
406
447
|
return '[MaxDepth]';
|
|
@@ -472,6 +513,13 @@ export function inspectCocos(request) {
|
|
|
472
513
|
readDisplay('RichText', 'string', '_string');
|
|
473
514
|
readDisplay('Button', 'interactable', '_interactable');
|
|
474
515
|
readDisplay('Toggle', 'isChecked', '_isChecked');
|
|
516
|
+
if (selected === node) {
|
|
517
|
+
for (const key of ['name', 'active', 'activeInHierarchy']) {
|
|
518
|
+
const value = dataProperty(node, key) ?? dataProperty(node, `_${key}`);
|
|
519
|
+
if (typeof value === 'string' || typeof value === 'boolean')
|
|
520
|
+
displayFields[key] = typeof value === 'string' ? value.slice(0, 500) : value;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
475
523
|
if (typeof cc.Sprite === 'function' && selected instanceof cc.Sprite) {
|
|
476
524
|
const frame = dataProperty(selected, '_spriteFrame');
|
|
477
525
|
displayFields.spriteFrame = frame && typeof frame === 'object'
|
|
@@ -526,6 +574,29 @@ export function inspectCocos(request) {
|
|
|
526
574
|
const after = directorState();
|
|
527
575
|
return { changed: before.paused !== after.paused, target: {}, before, after, runtimeOnly: true };
|
|
528
576
|
}
|
|
577
|
+
if (request.action === 'stepFrame') {
|
|
578
|
+
const game = cc.game;
|
|
579
|
+
if (typeof game?.step !== 'function' || typeof game.isPaused !== 'function' || typeof cc.director?.getTotalFrames !== 'function')
|
|
580
|
+
invalidMutation('game.step is unavailable');
|
|
581
|
+
const directorPaused = directorState().paused;
|
|
582
|
+
if (!directorPaused && !game.isPaused())
|
|
583
|
+
invalidMutation('pause the game before stepping');
|
|
584
|
+
const before = cc.director.getTotalFrames();
|
|
585
|
+
// game.step ticks the director, which skips logic while director-paused; unpause only inside this synchronous call.
|
|
586
|
+
for (let frame = 0; frame < (request.frames ?? 1); frame++) {
|
|
587
|
+
if (directorPaused)
|
|
588
|
+
cc.director.resume();
|
|
589
|
+
try {
|
|
590
|
+
game.step();
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
if (directorPaused)
|
|
594
|
+
cc.director.pause();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const after = cc.director.getTotalFrames();
|
|
598
|
+
return { changed: after !== before, target: {}, before: { totalFrames: before }, after: { totalFrames: after }, runtimeOnly: true };
|
|
599
|
+
}
|
|
529
600
|
if (request.action === 'setNodeActive') {
|
|
530
601
|
const node = findByUuid(request.uuid);
|
|
531
602
|
const before = node.active !== false;
|
package/dist/src/server.js
CHANGED
|
@@ -171,9 +171,10 @@ export function createServer(browser, options = {}) {
|
|
|
171
171
|
annotations: runtimeMutation,
|
|
172
172
|
}, input => execute({ action: 'setProperty', uuid: input.uuid, componentUuid: input.componentUuid, key: input.key, value: input.value }, input.pageUrl));
|
|
173
173
|
server.registerTool('cocos_click_node', {
|
|
174
|
-
description: 'Dispatch a real mouse click at the visible center of one Cocos UI node, so Button and touch handlers run.',
|
|
174
|
+
description: 'Dispatch a real mouse click at the visible center of one Cocos UI node, so Button and touch handlers run. Handlers may call game servers or make irreversible changes.',
|
|
175
175
|
inputSchema: z.object({ pageUrl, uuid: z.string().min(1) }).strict(),
|
|
176
|
-
|
|
176
|
+
// Game click handlers run arbitrary code: a login button reaches real servers, a buy button spends currency.
|
|
177
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
177
178
|
}, async (input) => {
|
|
178
179
|
try {
|
|
179
180
|
return response(await clickNode(await browser.page(input.pageUrl), input.uuid));
|
|
@@ -189,6 +190,11 @@ export function createServer(browser, options = {}) {
|
|
|
189
190
|
annotations: runtimeMutation,
|
|
190
191
|
}, input => execute({ action }, input.pageUrl));
|
|
191
192
|
}
|
|
193
|
+
server.registerTool('cocos_step_frame', {
|
|
194
|
+
description: 'Advance a paused Cocos game by fixed-delta frames through cc.game.step; requires cocos_pause first.',
|
|
195
|
+
inputSchema: z.object({ pageUrl, frames: z.number().int().min(1).max(60).optional() }).strict(),
|
|
196
|
+
annotations: { ...runtimeMutation, idempotentHint: false },
|
|
197
|
+
}, input => execute({ action: 'stepFrame', frames: input.frames }, input.pageUrl));
|
|
192
198
|
}
|
|
193
199
|
server.registerTool('cocos_highlight_node', {
|
|
194
200
|
description: 'Temporarily draw a pointer-transparent DOM overlay around one Cocos UI node.',
|