cocos-web-inspector-mcp 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,11 +16,28 @@ By default it does not edit Cocos game state, launch browsers, expose browser ne
16
16
 
17
17
  ## Requirements
18
18
 
19
- - Node.js 20+
19
+ - Node.js 22+
20
20
  - A Chromium browser started with remote debugging bound to loopback
21
21
  - A Cocos Creator 3.x web build served from loopback
22
22
 
23
- Start Chrome on Windows with a disposable profile:
23
+ The quickest start launches a locally installed Chrome with a disposable profile, loopback-only remote debugging, and the game URL, then prints the matching `claude mcp add` command:
24
+
25
+ ```powershell
26
+ npx --yes cocos-web-inspector-mcp launch http://localhost:7456/
27
+ npx --yes cocos-web-inspector-mcp launch http://localhost:7456/ --port 9223 --device iphone-14 --landscape --cpu-slowdown 4 --network fast-3g
28
+ ```
29
+
30
+ `launch` options: `--port` (default `9222`), `--profile <dir>` (default a per-port directory under the system temp folder), `--chrome-path` (or `CHROME_PATH`), and the `cocos_emulate_device` settings `--device`, `--landscape`, `--cpu-slowdown`, and `--network`. Chrome drops emulation when its controlling CDP session closes, so with emulation flags the command stays attached; press Ctrl+C to close Chrome. The MCP server itself never launches a browser.
31
+
32
+ Check a setup step by step, with a fix for each failure:
33
+
34
+ ```powershell
35
+ npx --yes cocos-web-inspector-mcp doctor --cdp-endpoint http://127.0.0.1:9222
36
+ ```
37
+
38
+ `doctor` checks the endpoint policy, what answers on the port, eligible localhost pages, Cocos 3.x detection, and the active scene. It exits with status 1 when a check fails.
39
+
40
+ Or start Chrome manually on Windows with a disposable profile:
24
41
 
25
42
  ```powershell
26
43
  chrome.exe --remote-debugging-address=127.0.0.1 --remote-debugging-port=9222 --user-data-dir="$env:TEMP\cocos-mcp-profile"
@@ -56,7 +73,7 @@ Endpoint precedence is:
56
73
  2. `COCOS_CDP_ENDPOINT`
57
74
  3. `http://127.0.0.1:9222`
58
75
 
59
- `--cdp-endpoint` is the only supported CLI option. It identifies the browser CDP endpoint, not a game page URL.
76
+ `--cdp-endpoint` and `--allow-runtime-mutation` are the only server options; `launch` and `doctor` are separate commands. `--cdp-endpoint` identifies the browser CDP endpoint, not a game page URL.
60
77
 
61
78
  The server uses stdio for MCP. Standard output is reserved for protocol traffic; startup diagnostics are written to standard error.
62
79
 
@@ -112,6 +129,8 @@ All tool input objects are strict. Unknown fields are rejected. Inspection tools
112
129
  | `cocos_pause` | Pause the Cocos director when its public API supports it. | `pageUrl?` |
113
130
  | `cocos_resume` | Resume the Cocos director when its public API supports it. | `pageUrl?` |
114
131
  | `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?` |
132
+ | `cocos_show_stats` | Show or hide the engine's FPS, draw-call, and triangle overlay through the public `profiler` API. | `pageUrl?`; `visible` boolean |
133
+ | `cocos_emulate_device` | Emulate a mobile device like the Chrome device toolbar: viewport, DPR, touch (mouse input arrives as touch), user agent, and orientation; optionally slow the CPU or network. Settings merge across calls. | `pageUrl?`; `preset?` (`iphone-se`, `iphone-14`, `iphone-14-pro-max`, `pixel-7`, `galaxy-s20`, `ipad-mini`) or `width`+`height` `200..4000` with `deviceScaleFactor?` `1..4` and `mobile?`; `orientation?`; `cpuSlowdown?` `1..20`; `network?` (`online`, `offline`, `slow-3g`, `fast-3g`, `fast-4g`); `reload?`; or `reset: true` |
115
134
  | `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
135
  | `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` |
117
136
  | `cocos_get_components` | Return bounded component summaries for a node. | `pageUrl?`; `uuid` |
@@ -121,9 +140,10 @@ All tool input objects are strict. Unknown fields are rejected. Inspection tools
121
140
  | `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` |
122
141
  | `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 |
123
142
  | `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` |
143
+ | `cocos_get_selection` | Return the node the user last Alt+clicked on the game canvas: node summary and path, viewport box, and the hit stack under the point (topmost first). The first call installs the picker. | `pageUrl?`; `disable?` removes the picker, overlay, and selection |
124
144
  | `cocos_highlight_node` | Draw a temporary pointer-transparent overlay around a UI node. | `pageUrl?`; `uuid`; `durationMs?` integer `100..10000`, default `2000` |
125
145
 
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.
146
+ `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_get_selection` lets the user point at a node instead of describing it: it installs a capture-phase listener that consumes only Alt+clicks on the game canvas, so the game never sees them, and draws a labelled pointer-transparent overlay. It picks the topmost active node with a `UITransform` whose box contains the point; inactive nodes are skipped. The selection is reported `stale` once that node leaves the scene, and the picker disappears on reload. `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. `cocos_show_stats` is an explicit configuration change; while the overlay is visible, draw-call metrics include it. `cocos_emulate_device` holds a CDP session per page: emulation ends on `reset`, when the server disconnects, or when the server process exits, and `reset` also clears viewport overrides set by other CDP clients on that page. Cocos reads the user agent and touch support at startup, so pass `reload: true` for the game to see a new device class. Network throttling only delays traffic; it never reads requests or responses.
127
147
 
128
148
  ### Page selection
129
149
 
@@ -210,6 +230,8 @@ The test suite covers URL policy, CDP connection reuse and recovery, scene trave
210
230
  - 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
231
  - `cocos_snapshot_subtree` returns `RESPONSE_LIMIT` with a partial tree: snapshot a deeper node, or lower `maxDepth`.
212
232
  - `cocos_step_frame` returns `INVALID_MUTATION`: call `cocos_pause` first.
233
+ - `doctor` reports HTTP 404 on the port: Chrome's built-in remote debugging holds it; see `CDP_UNAVAILABLE` above.
234
+ - Game still behaves like desktop after `cocos_emulate_device`: call it again with `reload: true`; Cocos detects mobile and touch at startup.
213
235
  - Render metrics `fps` reads 0: the engine publishes FPS once per elapsed second, so read again after the scene has run for a second.
214
236
 
215
237
  ## Release and compatibility
@@ -64,6 +64,16 @@ export type BridgeRequest = {
64
64
  } | {
65
65
  action: 'stepFrame';
66
66
  frames?: number | undefined;
67
+ } | {
68
+ action: 'showStats';
69
+ visible: boolean;
70
+ } | {
71
+ action: 'selection';
72
+ disable?: boolean | undefined;
73
+ } | {
74
+ action: 'pickAt';
75
+ x: number;
76
+ y: number;
67
77
  };
68
78
  type Vector3 = {
69
79
  x: number;
@@ -93,6 +103,99 @@ type PropertyValue = boolean | number | string | {
93
103
  export declare function runBridge(page: Page, request: BridgeRequest): Promise<unknown>;
94
104
  export declare function captureNode(page: Page, uuid: string): Promise<unknown>;
95
105
  export declare function clickNode(page: Page, uuid: string): Promise<unknown>;
106
+ export declare const devicePresets: {
107
+ readonly 'iphone-se': {
108
+ readonly width: 375;
109
+ readonly height: 667;
110
+ readonly deviceScaleFactor: 2;
111
+ readonly mobile: true;
112
+ readonly userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
113
+ };
114
+ readonly 'iphone-14': {
115
+ readonly width: 390;
116
+ readonly height: 844;
117
+ readonly deviceScaleFactor: 3;
118
+ readonly mobile: true;
119
+ readonly userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
120
+ };
121
+ readonly 'iphone-14-pro-max': {
122
+ readonly width: 430;
123
+ readonly height: 932;
124
+ readonly deviceScaleFactor: 3;
125
+ readonly mobile: true;
126
+ readonly userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
127
+ };
128
+ readonly 'pixel-7': {
129
+ readonly width: 412;
130
+ readonly height: 915;
131
+ readonly deviceScaleFactor: 2.625;
132
+ readonly mobile: true;
133
+ readonly userAgent: string;
134
+ };
135
+ readonly 'galaxy-s20': {
136
+ readonly width: 360;
137
+ readonly height: 800;
138
+ readonly deviceScaleFactor: 3;
139
+ readonly mobile: true;
140
+ readonly userAgent: string;
141
+ };
142
+ readonly 'ipad-mini': {
143
+ readonly width: 768;
144
+ readonly height: 1024;
145
+ readonly deviceScaleFactor: 2;
146
+ readonly mobile: true;
147
+ readonly userAgent: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
148
+ };
149
+ };
150
+ export type DevicePreset = keyof typeof devicePresets;
151
+ export declare const devicePresetNames: [DevicePreset, ...DevicePreset[]];
152
+ declare const networkProfiles: {
153
+ readonly online: {
154
+ readonly offline: false;
155
+ readonly latency: 0;
156
+ readonly downloadThroughput: -1;
157
+ readonly uploadThroughput: -1;
158
+ };
159
+ readonly offline: {
160
+ readonly offline: true;
161
+ readonly latency: 0;
162
+ readonly downloadThroughput: -1;
163
+ readonly uploadThroughput: -1;
164
+ };
165
+ readonly 'slow-3g': {
166
+ readonly offline: false;
167
+ readonly latency: 2000;
168
+ readonly downloadThroughput: 50000;
169
+ readonly uploadThroughput: 50000;
170
+ };
171
+ readonly 'fast-3g': {
172
+ readonly offline: false;
173
+ readonly latency: 562.5;
174
+ readonly downloadThroughput: 180000;
175
+ readonly uploadThroughput: 84375;
176
+ };
177
+ readonly 'fast-4g': {
178
+ readonly offline: false;
179
+ readonly latency: 165;
180
+ readonly downloadThroughput: 1012500;
181
+ readonly uploadThroughput: 168750;
182
+ };
183
+ };
184
+ export type NetworkProfile = keyof typeof networkProfiles;
185
+ export declare const networkProfileNames: [NetworkProfile, ...NetworkProfile[]];
186
+ export type EmulationRequest = {
187
+ reset?: boolean | undefined;
188
+ preset?: DevicePreset | undefined;
189
+ width?: number | undefined;
190
+ height?: number | undefined;
191
+ deviceScaleFactor?: number | undefined;
192
+ mobile?: boolean | undefined;
193
+ orientation?: 'portrait' | 'landscape' | undefined;
194
+ cpuSlowdown?: number | undefined;
195
+ network?: NetworkProfile | undefined;
196
+ reload?: boolean | undefined;
197
+ };
198
+ export declare function emulateDevice(page: Page, request: EmulationRequest): Promise<unknown>;
96
199
  export declare function inspectCocosPage(): unknown;
97
200
  export declare function inspectCocos(request: BridgeRequest): unknown;
98
201
  export {};
@@ -48,6 +48,90 @@ export async function clickNode(page, uuid) {
48
48
  await page.mouse.click(point.x, point.y);
49
49
  return { clicked: true, target: { nodeUuid: uuid }, point, runtimeOnly: true };
50
50
  }
51
+ const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
52
+ const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
53
+ const androidUa = (model) => `Mozilla/5.0 (Linux; Android 14; ${model}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36`;
54
+ export const devicePresets = {
55
+ 'iphone-se': { width: 375, height: 667, deviceScaleFactor: 2, mobile: true, userAgent: IOS_UA },
56
+ 'iphone-14': { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, userAgent: IOS_UA },
57
+ 'iphone-14-pro-max': { width: 430, height: 932, deviceScaleFactor: 3, mobile: true, userAgent: IOS_UA },
58
+ 'pixel-7': { width: 412, height: 915, deviceScaleFactor: 2.625, mobile: true, userAgent: androidUa('Pixel 7') },
59
+ 'galaxy-s20': { width: 360, height: 800, deviceScaleFactor: 3, mobile: true, userAgent: androidUa('SM-G981B') },
60
+ 'ipad-mini': { width: 768, height: 1024, deviceScaleFactor: 2, mobile: true, userAgent: IPAD_UA },
61
+ };
62
+ export const devicePresetNames = Object.keys(devicePresets);
63
+ // Chrome DevTools throttling presets, in bytes per second and milliseconds.
64
+ const networkProfiles = {
65
+ online: { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
66
+ offline: { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 },
67
+ 'slow-3g': { offline: false, latency: 2_000, downloadThroughput: 50_000, uploadThroughput: 50_000 },
68
+ 'fast-3g': { offline: false, latency: 562.5, downloadThroughput: 180_000, uploadThroughput: 84_375 },
69
+ 'fast-4g': { offline: false, latency: 165, downloadThroughput: 1_012_500, uploadThroughput: 168_750 },
70
+ };
71
+ export const networkProfileNames = Object.keys(networkProfiles);
72
+ // Chrome drops every override when the owning CDP session detaches, so the session lives as long as the emulation.
73
+ const emulations = new WeakMap();
74
+ export async function emulateDevice(page, request) {
75
+ const current = emulations.get(page);
76
+ const before = current?.state ?? {};
77
+ let after = {};
78
+ if (request.reset) {
79
+ emulations.delete(page);
80
+ if (current) {
81
+ await applyEmulation(current.session, {});
82
+ await current.session.detach().catch(() => { });
83
+ }
84
+ }
85
+ else {
86
+ after = { ...before };
87
+ const base = request.preset ? { preset: request.preset, ...devicePresets[request.preset] }
88
+ : request.width !== undefined && request.height !== undefined ? { width: request.width, height: request.height, deviceScaleFactor: request.deviceScaleFactor ?? 1, mobile: request.mobile ?? false }
89
+ : before.device;
90
+ if (request.orientation && !base)
91
+ throw new Error('Invalid mutation: orientation needs a preset or width and height');
92
+ if (base)
93
+ after.device = { ...base, orientation: request.orientation ?? (base === before.device ? before.device.orientation : 'portrait') };
94
+ if (request.cpuSlowdown !== undefined)
95
+ after.cpuSlowdown = request.cpuSlowdown;
96
+ if (request.network)
97
+ after.network = request.network;
98
+ const session = current?.session ?? await page.context().newCDPSession(page);
99
+ emulations.set(page, { session, state: after });
100
+ await applyEmulation(session, after);
101
+ }
102
+ if (request.reload)
103
+ await page.reload();
104
+ const report = (state) => {
105
+ if (!state.device)
106
+ return state;
107
+ const { userAgent: _userAgent, ...device } = state.device;
108
+ const landscape = device.orientation === 'landscape';
109
+ return { ...state, device: { ...device, width: landscape ? device.height : device.width, height: landscape ? device.width : device.height } };
110
+ };
111
+ return { changed: JSON.stringify(before) !== JSON.stringify(after), target: {}, before: report(before), after: report(after), reloaded: !!request.reload, runtimeOnly: true };
112
+ }
113
+ async function applyEmulation(session, state) {
114
+ const device = state.device;
115
+ if (device) {
116
+ const landscape = device.orientation === 'landscape';
117
+ await session.send('Emulation.setDeviceMetricsOverride', {
118
+ width: landscape ? device.height : device.width,
119
+ height: landscape ? device.width : device.height,
120
+ deviceScaleFactor: device.deviceScaleFactor,
121
+ mobile: device.mobile,
122
+ screenOrientation: { type: landscape ? 'landscapePrimary' : 'portraitPrimary', angle: landscape ? 90 : 0 },
123
+ });
124
+ }
125
+ else
126
+ await session.send('Emulation.clearDeviceMetricsOverride');
127
+ const touch = !!device?.mobile;
128
+ await session.send('Emulation.setTouchEmulationEnabled', touch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
129
+ // Like the Chrome device toolbar: mouse input arrives as touch, which mobile Cocos builds listen for.
130
+ await session.send('Emulation.setEmitTouchEventsForMouse', { enabled: touch, configuration: 'mobile' });
131
+ await session.send('Emulation.setUserAgentOverride', { userAgent: device?.userAgent ?? (await session.send('Browser.getVersion')).userAgent });
132
+ await session.send('Emulation.setCPUThrottlingRate', { rate: state.cpuSlowdown ?? 1 });
133
+ await session.send('Network.emulateNetworkConditions', networkProfiles[state.network ?? 'online']);
134
+ }
51
135
  export function inspectCocosPage() {
52
136
  const root = globalThis;
53
137
  const cc = root.cc ?? root.CC;
@@ -354,6 +438,107 @@ export function inspectCocos(request) {
354
438
  truncationReasons: directChildren.length > 200 || nodeComponents.length > 200 ? ['NODE_LIMIT'] : [],
355
439
  };
356
440
  }
441
+ if (request.action === 'pickAt') {
442
+ // Cocos draws 2D nodes in pre-order, so the last hit in traversal order is the topmost one.
443
+ const hits = [];
444
+ walk((node, path) => {
445
+ if (node === scene || node.activeInHierarchy === false)
446
+ return;
447
+ const bounds = nodeBounds(node);
448
+ const box = bounds.available ? bounds.viewport : undefined;
449
+ if (box && request.x >= box.x && request.x <= box.x + box.width && request.y >= box.y && request.y <= box.y + box.height)
450
+ hits.push({ node, path, viewport: box });
451
+ });
452
+ const overlay = root.__cocosWebInspectorSelectionOverlay;
453
+ const top = hits.at(-1);
454
+ if (!top) {
455
+ overlay?.remove();
456
+ return { version, point: { x: request.x, y: request.y }, node: null };
457
+ }
458
+ const selection = {
459
+ point: { x: request.x, y: request.y },
460
+ node: { ...summary(top.node), path: top.path },
461
+ viewport: top.viewport,
462
+ // Parents and siblings under the point, topmost first, for when the pick lands on a child.
463
+ stack: hits.slice(-10).reverse().map(hit => ({ uuid: String(hit.node.uuid ?? ''), name: String(hit.node.name ?? '').slice(0, 500), path: hit.path })),
464
+ };
465
+ const document = root.document;
466
+ if (document?.body) {
467
+ let box = overlay;
468
+ if (!box?.isConnected) {
469
+ box = document.createElement('div');
470
+ box.appendChild(document.createElement('span'));
471
+ root.__cocosWebInspectorSelectionOverlay = box;
472
+ document.body.appendChild(box);
473
+ }
474
+ Object.assign(box.style, {
475
+ position: 'fixed', pointerEvents: 'none', zIndex: '2147483647', boxSizing: 'border-box',
476
+ border: '2px solid #ff9f1a', background: 'rgba(255, 159, 26, 0.15)',
477
+ left: `${top.viewport.x}px`, top: `${top.viewport.y}px`, width: `${top.viewport.width}px`, height: `${top.viewport.height}px`,
478
+ });
479
+ const label = box.firstElementChild;
480
+ Object.assign(label.style, {
481
+ position: 'absolute', left: '0', bottom: '100%', maxWidth: '480px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
482
+ font: '12px/1.4 monospace', color: '#1a1a1a', background: '#ff9f1a', padding: '1px 4px',
483
+ });
484
+ label.textContent = `${selection.node.name} · ${selection.node.path.slice(0, 200)} · ${selection.node.uuid.slice(0, 8)}`;
485
+ }
486
+ return selection;
487
+ }
488
+ if (request.action === 'selection') {
489
+ const events = ['pointerdown', 'pointerup', 'mousedown', 'mouseup', 'click', 'touchstart', 'touchend'];
490
+ const installed = root.__cocosWebInspectorPicker;
491
+ if (request.disable) {
492
+ if (installed)
493
+ for (const type of events)
494
+ root.removeEventListener?.(type, installed, true);
495
+ root.__cocosWebInspectorPicker = undefined;
496
+ root.__cocosWebInspectorSelection = undefined;
497
+ root.__cocosWebInspectorSelectionOverlay?.remove();
498
+ root.__cocosWebInspectorSelectionOverlay = undefined;
499
+ return { version, picker: false, selection: null };
500
+ }
501
+ if (!installed && typeof root.addEventListener === 'function') {
502
+ const picker = (event) => {
503
+ const pointer = event;
504
+ const canvas = cc.game?.canvas ?? root.document?.querySelector('#GameCanvas');
505
+ if (!pointer.altKey || event.target !== canvas)
506
+ return;
507
+ // Alt+click selects; the game must not also receive it as a tap.
508
+ event.preventDefault();
509
+ event.stopImmediatePropagation();
510
+ if (event.type !== 'pointerdown')
511
+ return;
512
+ try {
513
+ // The named function expression is in scope inside the page, so the picker reruns the bridge with a fresh scene.
514
+ root.__cocosWebInspectorSelection = inspectCocos({ action: 'pickAt', x: pointer.clientX, y: pointer.clientY });
515
+ }
516
+ catch (error) {
517
+ root.__cocosWebInspectorSelection = { error: String(error instanceof Error ? error.message : error).slice(0, 500) };
518
+ }
519
+ };
520
+ for (const type of events)
521
+ root.addEventListener(type, picker, true);
522
+ root.__cocosWebInspectorPicker = picker;
523
+ }
524
+ const selection = root.__cocosWebInspectorSelection;
525
+ let stale = false;
526
+ if (selection?.node?.uuid) {
527
+ try {
528
+ findByUuid(selection.node.uuid);
529
+ }
530
+ catch {
531
+ stale = true;
532
+ }
533
+ }
534
+ return {
535
+ version,
536
+ picker: true,
537
+ selection: selection ?? null,
538
+ ...(stale ? { stale: true } : {}),
539
+ ...(selection ? {} : { hint: 'Alt+click a node on the game canvas, then call again' }),
540
+ };
541
+ }
357
542
  if (request.action === 'snapshotSubtree') {
358
543
  const rootNode = findByUuid(request.uuid);
359
544
  const maxDepth = Math.min(Math.max(request.maxDepth ?? 6, 0), 20);
@@ -434,6 +619,10 @@ export function inspectCocos(request) {
434
619
  return undefined;
435
620
  // Cocos 3.x exposes uuid/children/name as accessors; read their backing fields instead.
436
621
  const uuid = dataProperty(value, 'uuid') ?? dataProperty(value, '_id');
622
+ // CCObject.isValid is a getter over this flag (Destroyed = 1 << 0 in 3.6-3.8); destroy also nulls _children/node.
623
+ const flags = dataProperty(value, '_objFlags');
624
+ if (reference && typeof flags === 'number' && flags & 1)
625
+ return { $type: typeof cc.Node === 'function' && value instanceof cc.Node ? 'Node' : componentName(value), uuid: String(uuid ?? ''), destroyed: true };
437
626
  if (reference && uuid && Array.isArray(dataProperty(value, 'children') ?? dataProperty(value, '_children'))) {
438
627
  return { $type: 'Node', uuid: String(uuid), name: String(dataProperty(value, 'name') ?? dataProperty(value, '_name') ?? '').slice(0, 500) };
439
628
  }
@@ -597,6 +786,16 @@ export function inspectCocos(request) {
597
786
  const after = cc.director.getTotalFrames();
598
787
  return { changed: after !== before, target: {}, before: { totalFrames: before }, after: { totalFrames: after }, runtimeOnly: true };
599
788
  }
789
+ if (request.action === 'showStats') {
790
+ const profiler = cc.profiler;
791
+ if (typeof profiler?.isShowingStats !== 'function' || typeof profiler.showStats !== 'function' || typeof profiler.hideStats !== 'function')
792
+ invalidMutation('profiler stats API is unavailable');
793
+ const before = Boolean(profiler.isShowingStats());
794
+ if (before !== request.visible)
795
+ request.visible ? profiler.showStats() : profiler.hideStats();
796
+ const after = Boolean(profiler.isShowingStats());
797
+ return { changed: before !== after, target: {}, before: { visible: before }, after: { visible: after }, runtimeOnly: true };
798
+ }
600
799
  if (request.action === 'setNodeActive') {
601
800
  const node = findByUuid(request.uuid);
602
801
  const before = node.active !== false;
@@ -0,0 +1 @@
1
+ export declare const commands: Record<string, (args: string[]) => Promise<void>>;
@@ -0,0 +1,189 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { chromium } from 'playwright-core';
6
+ import { BrowserConnection, InspectorError, sanitizeUrl, validateLocalUrl } from './browser.js';
7
+ import { devicePresetNames, emulateDevice, inspectCocosPage, networkProfileNames } from './bridge.js';
8
+ const write = (line) => process.stdout.write(`${line}\n`);
9
+ function flags(args, names, booleans = []) {
10
+ const values = {};
11
+ const positional = [];
12
+ for (let index = 0; index < args.length; index++) {
13
+ const arg = args[index];
14
+ if (booleans.includes(arg))
15
+ values[arg] = 'true';
16
+ else if (names.includes(arg)) {
17
+ const value = args[++index];
18
+ if (!value)
19
+ throw new Error(`${arg} requires a value`);
20
+ values[arg] = value;
21
+ }
22
+ else if (arg.startsWith('-'))
23
+ throw new Error(`Unknown argument: ${arg}`);
24
+ else
25
+ positional.push(arg);
26
+ }
27
+ return { values, positional };
28
+ }
29
+ function chromePath(override) {
30
+ const candidates = override ? [override] : process.env.CHROME_PATH ? [process.env.CHROME_PATH] : process.platform === 'darwin'
31
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium']
32
+ : process.platform === 'win32'
33
+ ? [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean).map(base => join(base, 'Google', 'Chrome', 'Application', 'chrome.exe'))
34
+ : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
35
+ const found = candidates.find(candidate => existsSync(candidate));
36
+ if (!found)
37
+ throw new Error(`Chrome not found (checked ${candidates.join(', ')}); pass --chrome-path or set CHROME_PATH`);
38
+ return found;
39
+ }
40
+ async function probe(endpoint) {
41
+ try {
42
+ const response = await fetch(new URL('/json/version', endpoint), { signal: AbortSignal.timeout(3_000) });
43
+ if (!response.ok)
44
+ return { status: 'http', code: response.status };
45
+ return { status: 'ok', browser: String((await response.json()).Browser ?? 'unknown') };
46
+ }
47
+ catch (error) {
48
+ return { status: 'down', message: error instanceof Error ? (error.cause instanceof Error ? error.cause.message : error.message) : 'unknown error' };
49
+ }
50
+ }
51
+ function listener(port) {
52
+ if (process.platform === 'win32')
53
+ return undefined;
54
+ try {
55
+ return execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\n')[1]?.split(/\s+/).slice(0, 2).join(' pid ');
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
61
+ async function launch(args) {
62
+ const { values, positional } = flags(args, ['--port', '--profile', '--chrome-path', '--device', '--cpu-slowdown', '--network'], ['--landscape']);
63
+ if (positional.length !== 1)
64
+ throw new Error('Usage: cocos-web-inspector-mcp launch <url> [--port 9222] [--profile <dir>] [--chrome-path <path>] [--device <preset>] [--landscape] [--cpu-slowdown <1-20>] [--network <profile>]');
65
+ const url = validateLocalUrl(positional[0], true).href;
66
+ const port = Number(values['--port'] ?? 9222);
67
+ if (!Number.isInteger(port) || port < 1_024 || port > 65_535)
68
+ throw new Error('--port must be an integer from 1024 to 65535');
69
+ const device = values['--device'];
70
+ if (device && !devicePresetNames.includes(device))
71
+ throw new Error(`--device must be one of: ${devicePresetNames.join(', ')}`);
72
+ const network = values['--network'];
73
+ if (network && !networkProfileNames.includes(network))
74
+ throw new Error(`--network must be one of: ${networkProfileNames.join(', ')}`);
75
+ const cpuSlowdown = values['--cpu-slowdown'] === undefined ? undefined : Number(values['--cpu-slowdown']);
76
+ if (cpuSlowdown !== undefined && !(cpuSlowdown >= 1 && cpuSlowdown <= 20))
77
+ throw new Error('--cpu-slowdown must be from 1 to 20');
78
+ if (values['--landscape'] && !device)
79
+ throw new Error('--landscape needs --device');
80
+ const endpoint = `http://127.0.0.1:${port}`;
81
+ if ((await probe(endpoint)).status !== 'down')
82
+ throw new Error(`Port ${port} is already in use${listener(String(port)) ? ` by ${listener(String(port))}` : ''}; pass --port or stop that process`);
83
+ const profile = values['--profile'] ?? join(tmpdir(), `cocos-web-inspector-profile-${port}`);
84
+ const chrome = spawn(chromePath(values['--chrome-path']), [
85
+ '--remote-debugging-address=127.0.0.1',
86
+ `--remote-debugging-port=${port}`,
87
+ `--user-data-dir=${profile}`,
88
+ '--no-first-run',
89
+ '--no-default-browser-check',
90
+ url,
91
+ ], { stdio: 'ignore' });
92
+ const exited = new Promise(resolve => chrome.once('exit', () => resolve()));
93
+ const failed = new Promise((_, reject) => chrome.once('error', reject));
94
+ const deadline = Date.now() + 15_000;
95
+ while ((await Promise.race([probe(endpoint), failed])).status !== 'ok') {
96
+ if (chrome.exitCode !== null || Date.now() > deadline)
97
+ throw new Error(`Chrome did not open remote debugging on port ${port}`);
98
+ await new Promise(resolve => setTimeout(resolve, 200));
99
+ }
100
+ const emulating = device || cpuSlowdown !== undefined || network;
101
+ if (emulating) {
102
+ // Chrome drops emulation when its CDP session closes, so this process keeps the connection open.
103
+ const browser = await chromium.connectOverCDP(endpoint);
104
+ const deadlinePage = Date.now() + 10_000;
105
+ let page = browser.contexts().flatMap(context => context.pages()).find(candidate => candidate.url() === url);
106
+ while (!page && Date.now() < deadlinePage) {
107
+ await new Promise(resolve => setTimeout(resolve, 200));
108
+ page = browser.contexts().flatMap(context => context.pages()).find(candidate => candidate.url() === url);
109
+ }
110
+ if (!page)
111
+ throw new Error(`Page ${sanitizeUrl(url)} did not open`);
112
+ await emulateDevice(page, { preset: device, orientation: values['--landscape'] ? 'landscape' : undefined, cpuSlowdown, network, reload: true });
113
+ }
114
+ write(`Chrome is running with loopback remote debugging at ${endpoint}`);
115
+ write(`Profile: ${profile}`);
116
+ if (emulating)
117
+ write(`Emulation: ${[device && `${device}${values['--landscape'] ? ' landscape' : ''}`, cpuSlowdown && `CPU ${cpuSlowdown}x slower`, network].filter(Boolean).join(', ')} (ends when this command stops)`);
118
+ write('Add the MCP server to Claude Code with:');
119
+ write(` claude mcp add cocos-web-inspector -- npx -y cocos-web-inspector-mcp --cdp-endpoint ${endpoint}`);
120
+ write('Press Ctrl+C to close Chrome.');
121
+ const stop = () => chrome.kill();
122
+ process.once('SIGINT', stop);
123
+ process.once('SIGTERM', stop);
124
+ await exited;
125
+ process.exit(0);
126
+ }
127
+ async function doctor(args) {
128
+ const { values, positional } = flags(args, ['--cdp-endpoint']);
129
+ if (positional.length)
130
+ throw new Error('Usage: cocos-web-inspector-mcp doctor [--cdp-endpoint <url>]');
131
+ const endpoint = values['--cdp-endpoint'] ?? process.env.COCOS_CDP_ENDPOINT ?? 'http://127.0.0.1:9222';
132
+ let failed = false;
133
+ const pass = (message) => write(`ok ${message}`);
134
+ const fail = (code, message, fix) => {
135
+ failed = true;
136
+ write(`FAIL ${code}: ${message}\n Fix: ${fix}`);
137
+ };
138
+ let url;
139
+ try {
140
+ url = validateLocalUrl(endpoint);
141
+ pass(`CDP endpoint ${url.origin} is loopback`);
142
+ }
143
+ catch (error) {
144
+ fail('CDP_UNAVAILABLE', `${error instanceof Error ? error.message : 'Invalid endpoint'}: ${sanitizeUrl(endpoint)}`, 'use an http://127.0.0.1:<port> endpoint');
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+ const port = url.port || (url.protocol === 'https:' || url.protocol === 'wss:' ? '443' : '80');
149
+ const owner = listener(port);
150
+ const httpEndpoint = url.origin.replace(/^ws/, 'http');
151
+ const probed = await probe(httpEndpoint);
152
+ if (probed.status === 'down')
153
+ fail('CDP_UNAVAILABLE', `nothing answers on port ${port} (${probed.message})`, `run: npx cocos-web-inspector-mcp launch <game-url> --port ${port}`);
154
+ else if (probed.status === 'http')
155
+ fail('CDP_UNAVAILABLE', `port ${port} answers HTTP ${probed.code}${owner ? ` (${owner})` : ''}`, probed.code === 404 ? 'Chrome built-in remote debugging (chrome://inspect/#remote-debugging) holds this port; turn it off or launch on another --port' : 'stop the process holding this port or use another port');
156
+ else
157
+ pass(`${probed.browser} answers on port ${port}${owner ? ` (${owner})` : ''}`);
158
+ const connection = new BrowserConnection(endpoint);
159
+ try {
160
+ if (probed.status === 'ok' || probed.status === 'http' && probed.code === 404) {
161
+ const pages = await connection.pages();
162
+ if (!pages.length)
163
+ fail('NO_LOCAL_PAGE', 'no localhost page is open', 'open the game at http://localhost:<port>/ in that Chrome');
164
+ else
165
+ pass(`${pages.length} localhost page${pages.length === 1 ? '' : 's'} open`);
166
+ for (const page of pages.slice(0, 10)) {
167
+ const info = await page.evaluate(inspectCocosPage).catch(() => undefined);
168
+ const name = sanitizeUrl(page.url());
169
+ if (!info?.cocos?.detected)
170
+ fail('COCOS_NOT_FOUND', `no Cocos Creator 3.x runtime on ${name}`, 'wait for the build to load, or check that it is a Cocos Creator 3.x web build');
171
+ else if (!info.cocos.sceneName)
172
+ fail('SCENE_NOT_READY', `Cocos ${info.cocos.version} on ${name} has no active scene yet`, 'wait for the first scene to load');
173
+ else
174
+ pass(`Cocos ${info.cocos.version}, scene "${info.cocos.sceneName}" on ${name}`);
175
+ }
176
+ if (pages.length > 1)
177
+ write(`note ${pages.length} pages: MCP tools need pageUrl (see cocos_list_pages)`);
178
+ }
179
+ }
180
+ catch (error) {
181
+ fail(error instanceof InspectorError ? error.code : 'CDP_UNAVAILABLE', error instanceof Error ? error.message : 'connection failed', 'restart Chrome with --remote-debugging-port, or use the launch command');
182
+ }
183
+ finally {
184
+ await connection.close().catch(() => { });
185
+ }
186
+ if (failed)
187
+ process.exitCode = 1;
188
+ }
189
+ export const commands = { launch, doctor };
package/dist/src/index.js CHANGED
@@ -24,6 +24,9 @@ function optionsFromArgs(args) {
24
24
  return { endpoint, allowRuntimeMutation };
25
25
  }
26
26
  async function main() {
27
+ const [command, ...rest] = process.argv.slice(2);
28
+ if (command === 'launch' || command === 'doctor')
29
+ return (await import('./cli.js')).commands[command](rest);
27
30
  const options = optionsFromArgs(process.argv.slice(2));
28
31
  const browser = new BrowserConnection(options.endpoint);
29
32
  const server = createServer(browser, { allowRuntimeMutation: options.allowRuntimeMutation });
@@ -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, clickNode, inspectCocosPage, runBridge } from './bridge.js';
5
+ import { captureNode, clickNode, devicePresetNames, emulateDevice, inspectCocosPage, networkProfileNames, 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 };
@@ -195,7 +195,46 @@ export function createServer(browser, options = {}) {
195
195
  inputSchema: z.object({ pageUrl, frames: z.number().int().min(1).max(60).optional() }).strict(),
196
196
  annotations: { ...runtimeMutation, idempotentHint: false },
197
197
  }, input => execute({ action: 'stepFrame', frames: input.frames }, input.pageUrl));
198
+ server.registerTool('cocos_show_stats', {
199
+ description: 'Show or hide the Cocos profiler overlay (FPS, draw calls, triangles) through the public profiler API.',
200
+ inputSchema: z.object({ pageUrl, visible: z.boolean() }).strict(),
201
+ annotations: runtimeMutation,
202
+ }, input => execute({ action: 'showStats', visible: input.visible }, input.pageUrl));
203
+ server.registerTool('cocos_emulate_device', {
204
+ description: 'Emulate a mobile device (viewport, DPR, touch, user agent, orientation) and optionally slow the CPU or network, like the Chrome device toolbar. Settings merge across calls and last until reset or server disconnect; reload lets the game re-detect touch and user agent.',
205
+ inputSchema: z.object({
206
+ pageUrl,
207
+ reset: z.boolean().optional(),
208
+ preset: z.enum(devicePresetNames).optional(),
209
+ width: z.number().int().min(200).max(4_000).optional(),
210
+ height: z.number().int().min(200).max(4_000).optional(),
211
+ deviceScaleFactor: z.number().min(1).max(4).optional(),
212
+ mobile: z.boolean().optional(),
213
+ orientation: z.enum(['portrait', 'landscape']).optional(),
214
+ cpuSlowdown: z.number().min(1).max(20).optional(),
215
+ network: z.enum(networkProfileNames).optional(),
216
+ reload: z.boolean().optional(),
217
+ }).strict()
218
+ .refine(value => (value.width === undefined) === (value.height === undefined), 'Provide width and height together')
219
+ .refine(value => !(value.preset && value.width !== undefined), 'Provide preset or width and height, not both')
220
+ .refine(value => value.width !== undefined || (value.deviceScaleFactor === undefined && value.mobile === undefined), 'deviceScaleFactor and mobile need width and height')
221
+ .refine(value => !value.reset || Object.keys(value).every(key => ['pageUrl', 'reset', 'reload'].includes(key)), 'reset accepts only pageUrl and reload'),
222
+ annotations: { ...runtimeMutation, idempotentHint: false },
223
+ }, async (input) => {
224
+ try {
225
+ return response(await emulateDevice(await browser.page(input.pageUrl), input));
226
+ }
227
+ catch (error) {
228
+ return failure(error);
229
+ }
230
+ });
198
231
  }
232
+ server.registerTool('cocos_get_selection', {
233
+ description: 'Return the node the user last Alt+clicked on the game canvas. The first call installs the picker; Alt+clicks are kept from the game, other input is untouched. disable removes the picker, overlay, and selection.',
234
+ inputSchema: z.object({ pageUrl, disable: z.boolean().optional() }).strict(),
235
+ // Installs a page-level listener and DOM overlay only; never touches the Cocos graph.
236
+ annotations: { ...readOnly, readOnlyHint: false },
237
+ }, input => execute({ action: 'selection', disable: input.disable }, input.pageUrl));
199
238
  server.registerTool('cocos_highlight_node', {
200
239
  description: 'Temporarily draw a pointer-transparent DOM overlay around one Cocos UI node.',
201
240
  inputSchema: z.object({ pageUrl, uuid: z.string().min(1), durationMs: z.number().int().min(100).max(10_000).optional() }).strict(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cocos-web-inspector-mcp",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "MCP server to inspect and debug Cocos Creator 3.x web builds in local Chromium over CDP",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,7 +20,7 @@
20
20
  "README.md"
21
21
  ],
22
22
  "engines": {
23
- "node": ">=20"
23
+ "node": ">=22"
24
24
  },
25
25
  "scripts": {
26
26
  "build": "tsc",