cocos-web-inspector-mcp 0.1.5 → 0.1.7

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
@@ -108,6 +108,7 @@ All tool input objects are strict. Unknown fields are rejected. Inspection tools
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
+ | `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` |
111
112
  | `cocos_pause` | Pause the Cocos director when its public API supports it. | `pageUrl?` |
112
113
  | `cocos_resume` | Resume the Cocos director when its public API supports it. | `pageUrl?` |
113
114
  | `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` |
@@ -116,11 +117,12 @@ All tool input objects are strict. Unknown fields are rejected. Inspection tools
116
117
  | `cocos_get_node` | Return one node's path, parent, bounded direct children, and components. | `pageUrl?`; `uuid` |
117
118
  | `cocos_snapshot_subtree` | Return a bounded stateless hierarchy snapshot; clients compare snapshots. | `pageUrl?`; `uuid`; `maxDepth?`; `maxNodes?` |
118
119
  | `cocos_get_node_bounds` | Return bounded canvas/viewport bounds, anchor, world position, and visibility for one UI node. | `pageUrl?`; `uuid` |
119
- | `cocos_capture_node` | Return an in-memory viewport-clipped PNG for one visible UI node; no file is written. | `pageUrl?`; `uuid` |
120
- | `cocos_get_properties` | Serialize public properties for a node or one component selected by type or UUID. | `pageUrl?`; `uuid`; `componentType?` or `componentUuid?`; `maxDepth?` integer `0..6`, default `3` |
120
+ | `cocos_capture_node` | Return an in-memory viewport-clipped image for one visible UI node: PNG, falling back to JPEG when PNG exceeds the response limit; no file is written. | `pageUrl?`; `uuid` |
121
+ | `cocos_get_properties` | Serialize public properties for a node or one component selected by type or UUID. | `pageUrl?`; `uuid`; `componentType?` or `componentUuid?`; `maxDepth?` integer `0..6`, default `3`; `0` returns top-level primitives |
122
+ | `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` |
121
123
  | `cocos_highlight_node` | Draw a temporary pointer-transparent overlay around a UI node. | `pageUrl?`; `uuid`; `durationMs?` integer `100..10000`, default `2000` |
122
124
 
123
- `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 base64 in-memory, and rejects oversized responses. 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.
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, 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. Frame stepping is unsupported pending a verified public Cocos API compatibility matrix.
124
126
 
125
127
  ### Page selection
126
128
 
@@ -129,6 +131,14 @@ Eligible game pages must use HTTP or HTTPS on a loopback host.
129
131
  - With one eligible page, `pageUrl` may be omitted.
130
132
  - With multiple eligible pages, `pageUrl` is required.
131
133
  - `pageUrl` must exactly match the full URL reported by the browser, including path, query, and fragment.
134
+ - Tabs with identical URLs cannot be told apart; close duplicates before inspecting.
135
+
136
+ Every MCP client pointed at the same CDP endpoint sees every loopback tab in that browser. When several projects run at once, give each project its own Chromium, port, and profile, then configure the server at project scope:
137
+
138
+ ```sh
139
+ chrome --remote-debugging-address=127.0.0.1 --remote-debugging-port=9223 --user-data-dir="$HOME/.cocos-mcp/project-a"
140
+ claude mcp add cocos-web-inspector npx -- -y cocos-web-inspector-mcp@latest --cdp-endpoint http://127.0.0.1:9223
141
+ ```
132
142
 
133
143
  Tool validation, connection, selection, and inspection failures are returned as MCP tool errors. Operational errors include JSON `structuredContent` and text content with stable `code` and `message` fields. Current codes include `CDP_UNAVAILABLE`, `NO_LOCAL_PAGE`, `MULTIPLE_PAGES`, `PAGE_NOT_FOUND`, `COCOS_NOT_FOUND`, `SCENE_NOT_READY`, `NODE_NOT_FOUND`, and `COMPONENT_NOT_FOUND`.
134
144
 
@@ -169,6 +179,8 @@ Inspection still executes fixed bridge code inside the attached page. Treat all
169
179
 
170
180
  Property serialization skips accessors, private-prefixed keys, functions, symbols, cycles, and secret-like key names such as tokens, cookies, passwords, credentials, authorization data, and storage.
171
181
 
182
+ An allowlist of display fields is read directly from their backing data fields, still without invoking getters: `Label.string`, `RichText.string`, `Button.interactable`, `Toggle.isChecked`, and `Sprite.spriteFrame` (name and UUID only).
183
+
172
184
  These controls reduce accidental disclosure; they do not make CDP a complete security boundary. Always:
173
185
 
174
186
  - Use a disposable browser profile.
@@ -190,8 +202,8 @@ The test suite covers URL policy, CDP connection reuse and recovery, scene trave
190
202
 
191
203
  ## Troubleshooting
192
204
 
193
- - `CDP_UNAVAILABLE`: start Chromium with loopback remote debugging; rerun `npm run install:chromium` for development tests.
194
- - `MULTIPLE_PAGES`: call `cocos_list_pages`, then pass the exact reported `pageUrl`.
205
+ - `CDP_UNAVAILABLE`: start Chromium with loopback remote debugging; rerun `npm run install:chromium` for development tests. A `404` usually means Chrome's built-in remote debugging toggle (`chrome://inspect/#remote-debugging`) holds the port; turn it off or use another port, and check listeners with `lsof -nP -iTCP:9222 -sTCP:LISTEN`. When `127.0.0.1` returns 404 or refuses the connection, the server retries `[::1]` on the same port.
206
+ - `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).
195
207
  - `COCOS_NOT_FOUND` or `SCENE_NOT_READY`: wait for the web build to finish loading; use `cocos_runtime_info` after the active scene exists.
196
208
  - Runtime mutation tools missing: restart the server with `--allow-runtime-mutation`; a tool call cannot enable this mode.
197
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.
@@ -89,6 +89,7 @@ type PropertyValue = boolean | number | string | {
89
89
  };
90
90
  export declare function runBridge(page: Page, request: BridgeRequest): Promise<unknown>;
91
91
  export declare function captureNode(page: Page, uuid: string): Promise<unknown>;
92
+ export declare function clickNode(page: Page, uuid: string): Promise<unknown>;
92
93
  export declare function inspectCocosPage(): unknown;
93
94
  export declare function inspectCocos(request: BridgeRequest): unknown;
94
95
  export {};
@@ -13,13 +13,40 @@ 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
- const png = await page.screenshot({ type: 'png', clip });
19
- const data = png.toString('base64');
20
- if (Buffer.byteLength(JSON.stringify({ data }), 'utf8') > MAX_BYTES - 4_096)
21
- return { captured: false, reason: 'RESPONSE_LIMIT' };
22
- return { captured: true, mimeType: 'image/png', data, width: Math.round(clip.width), height: Math.round(clip.height) };
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.
21
+ const attempts = [{ type: 'png', scale: 'device' }, ...[80, 60, 40].map(quality => ({ type: 'jpeg', quality, scale: 'css' }))];
22
+ for (const options of attempts) {
23
+ const data = (await page.screenshot({ ...options, clip })).toString('base64');
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 };
35
+ }
36
+ }
37
+ finally {
38
+ await session.detach().catch(() => { });
39
+ }
40
+ return { captured: false, reason: 'RESPONSE_LIMIT' };
41
+ }
42
+ export async function clickNode(page, uuid) {
43
+ const result = await page.evaluate(inspectCocos, { action: 'getNodeBounds', uuid });
44
+ if (!result.available || !result.visible)
45
+ return { clicked: false, reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
46
+ const clip = result.clippedViewport;
47
+ const point = { x: clip.x + clip.width / 2, y: clip.y + clip.height / 2 };
48
+ await page.mouse.click(point.x, point.y);
49
+ return { clicked: true, target: { nodeUuid: uuid }, point, runtimeOnly: true };
23
50
  }
24
51
  export function inspectCocosPage() {
25
52
  const root = globalThis;
@@ -158,12 +185,29 @@ export function inspectCocos(request) {
158
185
  const worldY = Math.min(...points.map(point => Number(point.y)));
159
186
  const worldWidth = Math.max(...points.map(point => Number(point.x))) - worldX;
160
187
  const worldHeight = Math.max(...points.map(point => Number(point.y))) - worldY;
188
+ // Project through the owning Canvas camera; the visible-area mapping is wrong once the camera no longer centers on it.
189
+ let camera;
190
+ for (let current = node, depth = 0; current && !camera && depth < 100; current = current.parent, depth++) {
191
+ camera = components(current).find(component => componentName(component) === 'Canvas')?.cameraComponent;
192
+ }
193
+ const screen = typeof camera?.worldToScreen === 'function' && cc.Vec3 && canvas.width > 0 && canvas.height > 0
194
+ ? points.map(point => camera.worldToScreen(new cc.Vec3(Number(point.x), Number(point.y), 0), new cc.Vec3()))
195
+ : undefined;
161
196
  const origin = cc.view?.getVisibleOrigin?.() ?? { x: 0, y: 0 };
162
- const viewport = { x: canvasRect.left + (worldX - Number(origin.x ?? 0)) * canvasRect.width / Number(visible.width), y: canvasRect.top + (Number(origin.y ?? 0) + Number(visible.height) - worldY - worldHeight) * canvasRect.height / Number(visible.height), width: worldWidth * canvasRect.width / Number(visible.width), height: worldHeight * canvasRect.height / Number(visible.height) };
197
+ const viewport = screen?.every(point => Number.isFinite(point.x) && Number.isFinite(point.y))
198
+ ? (() => {
199
+ const xs = screen.map(point => Number(point.x));
200
+ const ys = screen.map(point => Number(point.y));
201
+ const sx = canvasRect.width / canvas.width;
202
+ const sy = canvasRect.height / canvas.height;
203
+ return { x: canvasRect.left + Math.min(...xs) * sx, y: canvasRect.top + (canvas.height - Math.max(...ys)) * sy, width: (Math.max(...xs) - Math.min(...xs)) * sx, height: (Math.max(...ys) - Math.min(...ys)) * sy };
204
+ })()
205
+ : { x: canvasRect.left + (worldX - Number(origin.x ?? 0)) * canvasRect.width / Number(visible.width), y: canvasRect.top + (Number(origin.y ?? 0) + Number(visible.height) - worldY - worldHeight) * canvasRect.height / Number(visible.height), width: worldWidth * canvasRect.width / Number(visible.width), height: worldHeight * canvasRect.height / Number(visible.height) };
163
206
  if (!Object.values(viewport).every(Number.isFinite) || viewport.width <= 0 || viewport.height <= 0)
164
207
  return { available: false, reason: 'INVALID_GEOMETRY' };
165
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)) };
166
- 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' } : {}) };
167
211
  };
168
212
  if (request.action === 'runtimeInfo') {
169
213
  const canvas = cc.game?.canvas ?? root.document?.querySelector('#GameCanvas');
@@ -300,17 +344,24 @@ export function inspectCocos(request) {
300
344
  const maxDepth = Math.min(Math.max(request.maxDepth ?? 6, 0), 20);
301
345
  const maxNodes = Math.min(Math.max(request.maxNodes ?? 500, 1), 5_000);
302
346
  let count = 0;
347
+ // 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.
348
+ let bytes = 0;
303
349
  const reasons = new Set();
304
350
  const build = (node, depth) => {
305
351
  if (count >= maxNodes) {
306
352
  reasons.add('NODE_LIMIT');
307
353
  return undefined;
308
354
  }
309
- count++;
310
355
  const item = {
311
356
  ...summary(node),
312
357
  components: components(node).slice(0, 200).map(component => ({ type: componentName(component), uuid: String(component.uuid ?? ''), enabled: component.enabled !== false })),
313
358
  };
359
+ bytes += JSON.stringify(item).length;
360
+ if (bytes > 70_000) {
361
+ reasons.add('RESPONSE_LIMIT');
362
+ return undefined;
363
+ }
364
+ count++;
314
365
  if (components(node).length > 200)
315
366
  reasons.add('NODE_LIMIT');
316
367
  if (depth >= maxDepth) {
@@ -366,13 +417,17 @@ export function inspectCocos(request) {
366
417
  }
367
418
  if (typeof value !== 'object')
368
419
  return undefined;
369
- const uuid = dataProperty(value, 'uuid');
370
- if (reference && uuid && Array.isArray(dataProperty(value, 'children'))) {
371
- return { $type: 'Node', uuid: String(uuid), name: String(dataProperty(value, 'name') ?? '').slice(0, 500) };
420
+ // Cocos 3.x exposes uuid/children/name as accessors; read their backing fields instead.
421
+ const uuid = dataProperty(value, 'uuid') ?? dataProperty(value, '_id');
422
+ if (reference && uuid && Array.isArray(dataProperty(value, 'children') ?? dataProperty(value, '_children'))) {
423
+ return { $type: 'Node', uuid: String(uuid), name: String(dataProperty(value, 'name') ?? dataProperty(value, '_name') ?? '').slice(0, 500) };
372
424
  }
373
425
  if (reference && uuid && dataProperty(value, 'node'))
374
426
  return { $type: 'Component', uuid: String(uuid), type: componentName(value) };
375
- if (depth >= maxDepth) {
427
+ if (reference && typeof cc.Asset === 'function' && value instanceof cc.Asset) {
428
+ return { $type: componentName(value), name: String(dataProperty(value, '_name') ?? '').slice(0, 500), uuid: String(dataProperty(value, '_uuid') ?? '') };
429
+ }
430
+ if (depth > 0 && depth >= maxDepth) {
376
431
  truncate('MAX_DEPTH');
377
432
  return '[MaxDepth]';
378
433
  }
@@ -425,12 +480,46 @@ export function inspectCocos(request) {
425
480
  seen.delete(value);
426
481
  return output;
427
482
  };
483
+ // ponytail: fixed allowlist of display backing fields, read without getters; extend per component when smoke tests need more.
484
+ const displayFields = {};
485
+ const readDisplay = (type, key, backing) => {
486
+ if (typeof cc[type] !== 'function' || !(selected instanceof cc[type]))
487
+ return;
488
+ const value = dataProperty(selected, backing);
489
+ if (typeof value === 'string') {
490
+ if (value.length > 2_000)
491
+ truncate('STRING_LIMIT');
492
+ displayFields[key] = value.slice(0, 2_000);
493
+ }
494
+ else if (value === null || typeof value === 'boolean' || typeof value === 'number')
495
+ displayFields[key] = value;
496
+ };
497
+ readDisplay('Label', 'string', '_string');
498
+ readDisplay('RichText', 'string', '_string');
499
+ readDisplay('Button', 'interactable', '_interactable');
500
+ readDisplay('Toggle', 'isChecked', '_isChecked');
501
+ if (selected === node) {
502
+ for (const key of ['name', 'active', 'activeInHierarchy']) {
503
+ const value = dataProperty(node, key) ?? dataProperty(node, `_${key}`);
504
+ if (typeof value === 'string' || typeof value === 'boolean')
505
+ displayFields[key] = typeof value === 'string' ? value.slice(0, 500) : value;
506
+ }
507
+ }
508
+ if (typeof cc.Sprite === 'function' && selected instanceof cc.Sprite) {
509
+ const frame = dataProperty(selected, '_spriteFrame');
510
+ displayFields.spriteFrame = frame && typeof frame === 'object'
511
+ ? { $type: 'SpriteFrame', name: String(dataProperty(frame, '_name') ?? '').slice(0, 500), uuid: String(dataProperty(frame, '_uuid') ?? '') }
512
+ : null;
513
+ }
514
+ const properties = serialize(selected, 0, false);
515
+ Object.assign(properties, displayFields);
516
+ returned += Object.keys(displayFields).length;
428
517
  return {
429
518
  version,
430
519
  node: summary(node),
431
520
  componentType: request.componentType ?? (selected === node ? undefined : componentName(selected)),
432
521
  componentUuid: selected === node ? undefined : String(selected.uuid ?? ''),
433
- properties: serialize(selected, 0, false),
522
+ properties,
434
523
  truncated,
435
524
  truncationReasons: [...truncationReasons],
436
525
  propertyCount,
@@ -85,7 +85,7 @@ export class BrowserConnection {
85
85
  if (selected.length === 0)
86
86
  throw new InspectorError('PAGE_NOT_FOUND', `Local page not found: ${sanitizeUrl(pageUrl)}`);
87
87
  if (selected.length > 1)
88
- throw new InspectorError('MULTIPLE_PAGES', `Multiple localhost pages match pageUrl: ${sanitizeUrl(pageUrl)}`);
88
+ throw new InspectorError('MULTIPLE_PAGES', `Multiple localhost pages match pageUrl: ${sanitizeUrl(pageUrl)}; close duplicate tabs or use a separate Chromium per project`);
89
89
  return selected[0];
90
90
  }
91
91
  if (pages.length === 0)
@@ -113,7 +113,15 @@ export class BrowserConnection {
113
113
  return this.#browser;
114
114
  if (this.#connecting)
115
115
  return this.#connecting;
116
- const connecting = this.connectOverCDP(this.endpoint, { timeout: this.timeout }).then(async (browser) => {
116
+ const options = { timeout: this.timeout };
117
+ const connecting = this.connectOverCDP(this.endpoint, options).catch(error => {
118
+ // A second Chromium on a taken IPv4 port may bind only [::1].
119
+ const url = new URL(this.endpoint);
120
+ if (!['127.0.0.1', 'localhost'].includes(url.hostname) || !/\b404\b|ECONNREFUSED/.test(String(error?.message)))
121
+ throw error;
122
+ url.hostname = '[::1]';
123
+ return this.connectOverCDP(url.toString().replace(/\/$/, ''), options).catch(() => { throw error; });
124
+ }).then(async (browser) => {
117
125
  if (this.#closed) {
118
126
  await browser.close();
119
127
  throw new Error('Browser connection is closed');
@@ -127,7 +135,11 @@ export class BrowserConnection {
127
135
  }).catch(error => {
128
136
  if (error instanceof InspectorError || error instanceof Error && error.message === 'connect failed')
129
137
  throw error;
130
- throw new InspectorError('CDP_UNAVAILABLE', `Unable to connect to Chromium CDP: ${error instanceof Error ? error.message : 'unknown error'}`);
138
+ const message = error instanceof Error ? error.message : 'unknown error';
139
+ const hint = /\b404\b/.test(message)
140
+ ? '; 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
+ : '';
142
+ throw new InspectorError('CDP_UNAVAILABLE', `Unable to connect to Chromium CDP: ${message}${hint}`);
131
143
  });
132
144
  this.#connecting = connecting;
133
145
  try {
@@ -2,7 +2,7 @@ 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 { captureNode, inspectCocosPage, runBridge } from './bridge.js';
5
+ import { captureNode, clickNode, inspectCocosPage, runBridge } from './bridge.js';
6
6
  const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
7
7
  const pageUrl = z.url().optional();
8
8
  const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
@@ -30,13 +30,16 @@ function response(data) {
30
30
  function failure(error) {
31
31
  if (error instanceof InspectorError)
32
32
  return { ...response({ code: error.code, message: error.message }), isError: true };
33
- const message = error instanceof Error ? error.message : 'Inspector request failed';
33
+ const raw = error instanceof Error ? error.message : 'Inspector request failed';
34
+ // Playwright wraps in-page errors as "page.evaluate: Error: <message>\n<stack>".
35
+ const message = raw.replace(/^page\.evaluate: (?:Error: )?/, '').split('\n')[0];
34
36
  const code = message === 'Cocos Creator 3.x runtime not found' ? 'COCOS_NOT_FOUND'
35
37
  : message === 'Active Cocos scene not found' ? 'SCENE_NOT_READY'
36
38
  : message.startsWith('Node not found') ? 'NODE_NOT_FOUND'
37
39
  : message === 'Component not found' ? 'COMPONENT_NOT_FOUND'
38
40
  : message === 'Ambiguous component type' ? 'AMBIGUOUS_COMPONENT'
39
- : 'CDP_UNAVAILABLE';
41
+ : message.startsWith('Invalid mutation') ? 'INVALID_MUTATION'
42
+ : 'CDP_UNAVAILABLE';
40
43
  return { ...response({ code, message }), isError: true };
41
44
  }
42
45
  export function createServer(browser, options = {}) {
@@ -125,6 +128,31 @@ export function createServer(browser, options = {}) {
125
128
  .refine(value => !(value.componentType && value.componentUuid), 'Provide componentType or componentUuid, not both'),
126
129
  annotations: readOnly,
127
130
  }, input => execute({ action: 'getProperties', uuid: input.uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: input.maxDepth }, input.pageUrl));
131
+ server.registerTool('cocos_wait_for_property', {
132
+ description: 'Poll one top-level property of a Cocos node or component until it equals a value or the timeout passes.',
133
+ inputSchema: z.object({ pageUrl, uuid: z.string().min(1), 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()
134
+ .refine(value => !(value.componentType && value.componentUuid), 'Provide componentType or componentUuid, not both'),
135
+ annotations: readOnly,
136
+ }, async (input) => {
137
+ try {
138
+ const page = await browser.page(input.pageUrl);
139
+ const request = { action: 'getProperties', uuid: input.uuid, componentType: input.componentType, componentUuid: input.componentUuid, maxDepth: 0 };
140
+ const deadline = Date.now() + (input.timeoutMs ?? 5_000);
141
+ let polls = 0;
142
+ let value;
143
+ for (;;) {
144
+ polls++;
145
+ value = (await runBridge(page, request)).properties?.[input.key];
146
+ if (value === input.equals || Date.now() >= deadline)
147
+ break;
148
+ await new Promise(resolve => setTimeout(resolve, input.intervalMs ?? 200));
149
+ }
150
+ return response({ matched: value === input.equals, key: input.key, value: value ?? null, polls });
151
+ }
152
+ catch (error) {
153
+ return failure(error);
154
+ }
155
+ });
128
156
  if (options.allowRuntimeMutation) {
129
157
  server.registerTool('cocos_set_node_active', {
130
158
  description: 'Set the active state of one Cocos node selected by exact UUID.',
@@ -142,6 +170,18 @@ export function createServer(browser, options = {}) {
142
170
  inputSchema: z.object({ pageUrl, uuid: z.string().min(1), componentUuid: z.string().min(1), key: z.string().min(1).max(200), value: propertyValue }).strict(),
143
171
  annotations: runtimeMutation,
144
172
  }, input => execute({ action: 'setProperty', uuid: input.uuid, componentUuid: input.componentUuid, key: input.key, value: input.value }, input.pageUrl));
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.',
175
+ inputSchema: z.object({ pageUrl, uuid: z.string().min(1) }).strict(),
176
+ annotations: { ...runtimeMutation, idempotentHint: false },
177
+ }, async (input) => {
178
+ try {
179
+ return response(await clickNode(await browser.page(input.pageUrl), input.uuid));
180
+ }
181
+ catch (error) {
182
+ return failure(error);
183
+ }
184
+ });
145
185
  for (const action of ['pause', 'resume']) {
146
186
  server.registerTool(`cocos_${action}`, {
147
187
  description: `${action === 'pause' ? 'Pause' : 'Resume'} the Cocos director through its public API.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cocos-web-inspector-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Read-only MCP inspector for Cocos Creator 3.x running in local Chromium",
5
5
  "license": "MIT",
6
6
  "repository": {