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 +17 -5
- package/dist/src/bridge.d.ts +1 -0
- package/dist/src/bridge.js +104 -15
- package/dist/src/browser.js +15 -3
- package/dist/src/server.js +43 -3
- package/package.json +1 -1
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
|
|
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,
|
|
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.
|
package/dist/src/bridge.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/src/bridge.js
CHANGED
|
@@ -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
|
|
17
|
-
return { captured: false, reason: '
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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 (
|
|
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
|
|
522
|
+
properties,
|
|
434
523
|
truncated,
|
|
435
524
|
truncationReasons: [...truncationReasons],
|
|
436
525
|
propertyCount,
|
package/dist/src/browser.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
package/dist/src/server.js
CHANGED
|
@@ -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
|
|
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
|
-
: '
|
|
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.`,
|