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 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, and duplicate names. Render metrics remain explicitly unsupported. | `pageUrl?` |
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. FPS, frame time, draw calls, triangles, and generic invalid-reference checks return `UNSUPPORTED_PUBLIC_API` until stable passive public Cocos APIs are verified. `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, limits captures to 1,024 × 1,024 CSS pixels / 1,048,576 total pixels, returns PNG (or JPEG fallback) base64 in-memory, and rejects responses still oversized after JPEG quality steps. Mutation results provide `before` values for manual inverse calls, but restoration cannot undo lifecycle callbacks or other runtime side effects. Frame stepping is unsupported pending a verified public Cocos API compatibility matrix.
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 22, and rejects high-severity production dependency advisories.
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
 
@@ -61,6 +61,9 @@ export type BridgeRequest = {
61
61
  action: 'pause';
62
62
  } | {
63
63
  action: 'resume';
64
+ } | {
65
+ action: 'stepFrame';
66
+ frames?: number | undefined;
64
67
  };
65
68
  type Vector3 = {
66
69
  x: number;
@@ -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 || clip.width > 1_024 || clip.height > 1_024 || clip.width * clip.height > 1_048_576)
17
- return { captured: false, reason: 'CAPTURE_LIMIT' };
18
- // ponytail: device-pixel PNG, then CSS-pixel JPEG quality steps; add CDP clip.scale downscaling if captures still hit the limit.
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 (Buffer.byteLength(JSON.stringify({ data }), 'utf8') <= MAX_BYTES - 4_096) {
23
- return { captured: true, mimeType: `image/${options.type}`, data, width: Math.round(clip.width), height: Math.round(clip.height) };
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
- 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: clipped.width > 0 && clipped.height > 0, outsideViewport: clipped.width === 0 || clipped.height === 0 };
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
- unavailableMetrics: { fps: 'UNSUPPORTED_PUBLIC_API', frameTime: 'UNSUPPORTED_PUBLIC_API', drawCalls: 'UNSUPPORTED_PUBLIC_API', triangles: 'UNSUPPORTED_PUBLIC_API', invalidComponentReferences: 'UNSUPPORTED_PUBLIC_API' },
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
- const uuid = dataProperty(value, 'uuid');
399
- if (reference && uuid && Array.isArray(dataProperty(value, 'children'))) {
400
- return { $type: 'Node', uuid: String(uuid), name: String(dataProperty(value, 'name') ?? '').slice(0, 500) };
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;
@@ -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
- annotations: { ...runtimeMutation, idempotentHint: false },
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.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cocos-web-inspector-mcp",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Read-only MCP inspector for Cocos Creator 3.x running in local Chromium",
5
5
  "license": "MIT",
6
6
  "repository": {