cocos-web-inspector-mcp 2.2.0 → 2.3.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
@@ -50,21 +50,26 @@ Keep this browser profile separate from normal browsing. CDP provides code-execu
50
50
  A Cocos native build with **Debug** checked runs a V8 inspector inside the app; release builds do not. Attach to it over adb instead of Chromium:
51
51
 
52
52
  ```sh
53
- adb logcat -d -s Cocos | grep "Debugger listening" -A1 # prints ws=IP_ADDR_OF_THIS_DEVICE:<port>/...
54
- adb forward tcp:<port> tcp:<port>
53
+ npx --yes cocos-web-inspector-mcp doctor --native # finds the port in logcat, runs adb forward, checks the scene
55
54
  npx --yes cocos-web-inspector-mcp --native-endpoint http://127.0.0.1:<port>
56
55
  ```
57
56
 
57
+ `doctor --native` reads the port from the engine's `Debugger listening` logcat line, forwards it to the same loopback port, and prints the `claude mcp add` command. By hand: `adb logcat -d -s Cocos | grep "Debugger listening" -A1`, then `adb forward tcp:<port> tcp:<port>`.
58
+
58
59
  The template asks for port 6086, but on Android the engine moves it above 37000 when it cannot list network interfaces (43086 on a Creator 3.8.8 build), so read the port from logcat. `--native-endpoint` must be loopback, and it replaces `--cdp-endpoint` for that server process.
59
60
 
60
61
  The V8 inspector has no page, so native mode registers only the tools that read or change the Cocos graph, plus adb-backed input and logs:
61
62
 
63
+ - `cocos_capture_node` crops a device screenshot (`adb exec-out screencap`, gzipped on the device) to the node's viewport bounds and returns a PNG, downsampled by whole steps (`scale`) when it would exceed the response limit. It takes 1.5 to 2.5 s over Wi-Fi adb on a 3200x1440 screen and, like taps, assumes a full-screen game.
62
64
  - `cocos_click_node` and `cocos_drag_node` tap and swipe with `adb shell input`, at the node's viewport center, which equals screen pixels for a full-screen game. `steps` does not apply to a native drag. Set `ANDROID_SERIAL` when several devices are attached.
63
65
  - `cocos_type_text` taps the EditBox, waits for the engine's Android input activity (`CocosEditBoxActivity`), clears it, and types with `adb shell input text`, so `text-changed` fires per character. Text must be printable ASCII without `%s`. The device keyboard still processes the keys, so autocorrect or a Vietnamese Telex layout can rewrite them (`test` becomes `tét`); the result reports `matches: false` with the text the game received. `submit` presses Enter, which closes a single-line box with `editing-did-ended`; `editing-return` fires only from the activity's confirm button. Clearing uses `input keycombination`, Android 12+.
66
+ - With `--allow-browser-data`, `cocos_network_requests` and `cocos_network_request` report XHR, `fetch` (which the native polyfill runs over XHR), and WebSocket traffic. The first network call installs hooks in the game that keep the last 200 requests and the last 50 frames per socket, so traffic before that call is not seen; the note says when capture started. Bodies and frames are masked like web bodies, headers like web headers. `cocos_storage` reads `localStorage` (native builds back it with SQLite); `session` and `cookies` report `available: false`, since native builds have neither.
64
67
  - `cocos_console_messages` (with `--allow-browser-data`) reads the app process's `Cocos`-tagged logcat lines, so it includes logs from before the server attached, bounded by the device log buffer. `console.log` and `console.debug` both report as `log`; an uncaught error spans several `error` lines.
65
- - Not registered: `cocos_capture_node`, `cocos_emulate_device`, `cocos_highlight_node`, `cocos_get_selection` (DOM overlays draw nothing), `cocos_analyze_batches` (the native 2D batcher runs in C++), `cocos_step_frame` (stepping from the inspector renders outside the native frame loop and crashed the app), and the network and storage tools.
68
+ - Not registered: `cocos_emulate_device`, `cocos_highlight_node`, `cocos_get_selection` (DOM overlays draw nothing), `cocos_analyze_batches` (the native 2D batcher runs in C++), and `cocos_step_frame` (stepping from the inspector renders outside the native frame loop and crashed the app).
69
+
70
+ The inspector accepts one session at a time, so the server connects per call; Chrome DevTools can attach between calls, and a call made while DevTools is attached fails with `CDP_UNAVAILABLE`. An adb server restart or a USB drop takes the forward with it; when the endpoint stops answering, the server reads the port from logcat and forwards it again once before failing, so a call usually recovers on its own. If calls keep failing with `CDP_UNAVAILABLE`, run `doctor --native`. When a phone keeps dropping off USB (common through hubs), switch adb to Wi-Fi with `adb tcpip 5555` and `adb connect <phone-ip>:5555`, then forward and set `ANDROID_SERIAL=<phone-ip>:5555`; the endpoint stays `127.0.0.1`.
66
71
 
67
- The inspector accepts one session at a time, so the server connects per call; Chrome DevTools can attach between calls, and a call made while DevTools is attached fails with `CDP_UNAVAILABLE`. If calls start failing with `CDP_UNAVAILABLE` while the app runs, the adb server restarted and dropped the forward; run `adb forward` again.
72
+ Only a build with the inspector compiled in can attach. If a game's Debug build fails, setting `USE_V8_DEBUGGER_FORCE` and `CC_DEBUG_FORCE` to `ON` in `native/engine/common/CMakeLists.txt` keeps the inspector in a release build; never ship such a build.
68
73
 
69
74
  ## Install and run
70
75
 
@@ -132,7 +132,21 @@ type PropertyValue = boolean | number | string | {
132
132
  b: number;
133
133
  a?: number | undefined;
134
134
  };
135
+ /** Whether a tool result, sent as both text and structuredContent, stays under the response ceiling. */
136
+ export declare function fitsResponse(result: unknown): boolean;
135
137
  export declare function runBridge(page: Page, request: BridgeRequest): Promise<unknown>;
138
+ export type Clip = {
139
+ x: number;
140
+ y: number;
141
+ width: number;
142
+ height: number;
143
+ };
144
+ /** The node's visible viewport rectangle, or why it has none. */
145
+ export declare function visibleClip(page: Page, uuid: string): Promise<Clip | {
146
+ reason: string;
147
+ }>;
148
+ /** Whether base64 image data stays under the response ceiling. */
149
+ export declare const fitsImage: (data: string) => boolean;
136
150
  export declare function captureNode(page: Page, uuid: string): Promise<unknown>;
137
151
  export declare function nodeCenter(page: Page, uuid: string): Promise<{
138
152
  x: number;
@@ -1,21 +1,30 @@
1
1
  const MAX_BYTES = 200_000;
2
+ /** Whether a tool result, sent as both text and structuredContent, stays under the response ceiling. */
3
+ export function fitsResponse(result) {
4
+ const encoded = JSON.stringify({ content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result });
5
+ return Buffer.byteLength(encoded, 'utf8') <= MAX_BYTES - 4_096;
6
+ }
2
7
  export async function runBridge(page, request) {
3
8
  const result = await page.evaluate(inspectCocos, request);
4
- const text = JSON.stringify(result);
5
- const encoded = JSON.stringify({ content: [{ type: 'text', text }], structuredContent: result });
6
- if (Buffer.byteLength(encoded, 'utf8') > MAX_BYTES - 4_096) {
7
- return { truncated: true, truncationReasons: ['RESPONSE_LIMIT'] };
8
- }
9
- return result;
9
+ return fitsResponse(result) ? result : { truncated: true, truncationReasons: ['RESPONSE_LIMIT'] };
10
10
  }
11
- export async function captureNode(page, uuid) {
11
+ /** The node's visible viewport rectangle, or why it has none. */
12
+ export async function visibleClip(page, uuid) {
12
13
  const result = await page.evaluate(inspectCocos, { action: 'getNodeBounds', uuid });
13
14
  if (!result.available || !result.visible)
14
- return { captured: false, reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
15
+ return { reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
15
16
  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: 'INVALID_GEOMETRY' };
18
- const fits = (data) => Buffer.byteLength(JSON.stringify({ data }), 'utf8') <= MAX_BYTES - 4_096;
17
+ if (!clip || ![clip.x, clip.y, clip.width, clip.height].every(Number.isFinite) || clip.width <= 0 || clip.height <= 0)
18
+ return { reason: 'INVALID_GEOMETRY' };
19
+ return clip;
20
+ }
21
+ /** Whether base64 image data stays under the response ceiling. */
22
+ export const fitsImage = (data) => Buffer.byteLength(JSON.stringify({ data }), 'utf8') <= MAX_BYTES - 4_096;
23
+ export async function captureNode(page, uuid) {
24
+ const clip = await visibleClip(page, uuid);
25
+ if ('reason' in clip)
26
+ return { captured: false, reason: clip.reason };
27
+ const fits = fitsImage;
19
28
  const size = { width: Math.round(clip.width), height: Math.round(clip.height) };
20
29
  // Device-pixel PNG, then CSS-pixel JPEG quality steps.
21
30
  const attempts = [{ type: 'png', scale: 'device' }, ...[80, 60, 40].map(quality => ({ type: 'jpeg', quality, scale: 'css' }))];
@@ -40,11 +49,8 @@ export async function captureNode(page, uuid) {
40
49
  return { captured: false, reason: 'RESPONSE_LIMIT' };
41
50
  }
42
51
  export async function nodeCenter(page, uuid) {
43
- const result = await page.evaluate(inspectCocos, { action: 'getNodeBounds', uuid });
44
- if (!result.available || !result.visible)
45
- return { reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
46
- const clip = result.clippedViewport;
47
- return { x: clip.x + clip.width / 2, y: clip.y + clip.height / 2 };
52
+ const clip = await visibleClip(page, uuid);
53
+ return 'reason' in clip ? clip : { x: clip.x + clip.width / 2, y: clip.y + clip.height / 2 };
48
54
  }
49
55
  // Under touch emulation Chrome turns mouse input into touch and never acknowledges Playwright's mouse calls, so send touch directly.
50
56
  function pointer(page) {
@@ -810,11 +816,9 @@ export function inspectCocos(request) {
810
816
  }
811
817
  }
812
818
  if (selected === node) {
813
- for (const key of ['name', 'active', 'activeInHierarchy']) {
814
- const value = dataProperty(node, key) ?? dataProperty(node, `_${key}`);
815
- if (typeof value === 'string' || typeof value === 'boolean')
816
- displayFields[key] = typeof value === 'string' ? value.slice(0, 500) : value;
817
- }
819
+ // Native (JSB) nodes keep these in C++ behind accessors; summary reads the same public getters the scene tree reports.
820
+ const { name, active, activeInHierarchy } = summary(node);
821
+ Object.assign(displayFields, { name, active, activeInHierarchy });
818
822
  }
819
823
  if (typeof cc.Sprite === 'function' && selected instanceof cc.Sprite) {
820
824
  const frame = dataProperty(selected, '_spriteFrame');
@@ -1162,7 +1166,21 @@ export function inspectCocos(request) {
1162
1166
  }
1163
1167
  // Asset helpers read backing fields only; refCount, width, and texture getters stay untouched.
1164
1168
  const assetName = (asset) => String(dataProperty(asset, '_name') ?? '').slice(0, 500);
1165
- const assetUuid = (asset) => String(dataProperty(asset, '_uuid') ?? '');
1169
+ // Native (JSB) assets keep _uuid behind a C++ accessor, which data reads skip; the asset cache is keyed by uuid, so look it up there.
1170
+ let cachedUuids;
1171
+ const assetUuid = (asset) => {
1172
+ const own = dataProperty(asset, '_uuid');
1173
+ if (typeof own === 'string' && own)
1174
+ return own;
1175
+ if (!cachedUuids) {
1176
+ cachedUuids = new Map();
1177
+ const map = dataProperty(cc.assetManager?.assets ?? {}, '_map');
1178
+ for (const [key, value] of Object.entries((map && typeof map === 'object' ? map : {})))
1179
+ if (value && typeof value === 'object')
1180
+ cachedUuids.set(value, key);
1181
+ }
1182
+ return cachedUuids.get(asset) ?? '';
1183
+ };
1166
1184
  const gfxBytes = (texture) => {
1167
1185
  const gfx = texture && typeof texture === 'object' ? dataProperty(texture, '_gfxTexture') : undefined;
1168
1186
  const size = gfx && typeof gfx === 'object' ? dataProperty(gfx, '_size') : undefined;
@@ -1681,10 +1699,13 @@ export function inspectCocos(request) {
1681
1699
  // Unused first, then by GPU bytes and refCount: the likeliest leaks lead.
1682
1700
  items.sort((a, b) => Number(b.status === 'unused') - Number(a.status === 'unused') || (b.bytes ?? 0) - (a.bytes ?? 0) || b.refCount - a.refCount);
1683
1701
  const memory = dataProperty(dataProperty(dataProperty(cc.director, '_root') ?? {}, '_device') ?? {}, '_memoryStatus');
1702
+ // Native (JSB) GFX textures live in C++ and expose no byte size to JS; report that instead of zeros that read as "no textures".
1703
+ const textureCount = Object.entries(byType).filter(([type]) => /Texture/.test(type)).reduce((sum, [, bucket]) => sum + bucket.count, 0);
1704
+ const bytesUnavailable = totalBytes === 0 && textureCount > 0;
1684
1705
  return {
1685
1706
  version,
1686
1707
  assetCount: Math.min(total, 50_000),
1687
- textureBytes: totalBytes,
1708
+ ...(bytesUnavailable ? { textureBytes: null, unavailableMetrics: { textureBytes: 'UNSUPPORTED_PUBLIC_API' } } : { textureBytes: totalBytes }),
1688
1709
  ...(memory ? { gpuMemory: { textureBytes: Number(dataProperty(memory, 'textureSize') ?? 0), bufferBytes: Number(dataProperty(memory, 'bufferSize') ?? 0) } } : {}),
1689
1710
  byType,
1690
1711
  assets: items.slice(0, limit),
@@ -13,11 +13,26 @@ export declare function consoleMessages(page: Page, options: {
13
13
  textContains?: string | undefined;
14
14
  limit?: number | undefined;
15
15
  }): Promise<unknown>;
16
- export declare function networkRequests(page: Page, options: {
16
+ export declare function networkRequests(page: Page, options: RequestFilter): Promise<unknown>;
17
+ type RequestRow = {
18
+ url: string;
19
+ resourceType: string;
20
+ status: number | null;
21
+ failure?: string;
22
+ };
23
+ type RequestFilter = {
17
24
  urlContains?: string | undefined;
18
25
  resourceType?: string | undefined;
19
26
  failedOnly?: boolean | undefined;
20
27
  limit?: number | undefined;
21
- }): Promise<unknown>;
28
+ };
29
+ /** Filters request rows and keeps the newest `limit`; shared by the browser and native network tools. */
30
+ export declare function selectRequests<T extends RequestRow>(rows: T[], options: RequestFilter, note: string): {
31
+ requests: T[];
32
+ matched: number;
33
+ truncated: boolean;
34
+ note: string;
35
+ };
22
36
  export declare function networkRequest(page: Page, id: number, includeBody: boolean): Promise<unknown>;
23
37
  export declare function storage(page: Page, area: 'local' | 'session' | 'cookies', keyContains?: string, limit?: number): Promise<unknown>;
38
+ export {};
@@ -84,7 +84,6 @@ const idOf = (request) => {
84
84
  return id;
85
85
  };
86
86
  export async function networkRequests(page, options) {
87
- const limit = options.limit ?? 50;
88
87
  const rows = await Promise.all((await page.requests()).map(async (request) => {
89
88
  const response = await request.response().catch(() => null);
90
89
  const failure = request.failure()?.errorText;
@@ -99,10 +98,15 @@ export async function networkRequests(page, options) {
99
98
  ...(timing.responseEnd > 0 ? { durationMs: Math.round(timing.responseEnd) } : {}),
100
99
  };
101
100
  }));
101
+ return selectRequests(rows, options, 'Requests since this server attached; Playwright drops old entries to bound memory.');
102
+ }
103
+ /** Filters request rows and keeps the newest `limit`; shared by the browser and native network tools. */
104
+ export function selectRequests(rows, options, note) {
105
+ const limit = options.limit ?? 50;
102
106
  const matched = rows.filter(row => (!options.urlContains || row.url.includes(options.urlContains))
103
107
  && (!options.resourceType || row.resourceType === options.resourceType)
104
108
  && (!options.failedOnly || row.failure || (row.status ?? 0) >= 400));
105
- return { requests: matched.slice(-limit), matched: matched.length, truncated: matched.length > limit, note: 'Requests since this server attached; Playwright drops old entries to bound memory.' };
109
+ return { requests: matched.slice(-limit), matched: matched.length, truncated: matched.length > limit, note };
106
110
  }
107
111
  export async function networkRequest(page, id, includeBody) {
108
112
  const request = (await page.requests()).find(candidate => requestIds.get(candidate) === id);
@@ -132,6 +136,9 @@ export async function networkRequest(page, id, includeBody) {
132
136
  }
133
137
  export async function storage(page, area, keyContains, limit = 100) {
134
138
  if (area === 'cookies') {
139
+ // A native (JSB) runtime has no browser context or cookie jar.
140
+ if (typeof page.context !== 'function')
141
+ return { area, available: false, reason: 'UNSUPPORTED_PUBLIC_API', entries: [], matched: 0, truncated: false };
135
142
  // Cookie values are always withheld; names and attributes are enough to debug expiry and scope.
136
143
  const cookies = (await page.context().cookies(page.url())).filter(cookie => !keyContains || cookie.name.includes(keyContains));
137
144
  return {
@@ -141,8 +148,11 @@ export async function storage(page, area, keyContains, limit = 100) {
141
148
  truncated: cookies.length > limit,
142
149
  };
143
150
  }
151
+ // Native (JSB) builds have localStorage only, backed by a SQLite file; there is no sessionStorage or cookie jar.
144
152
  const entries = await page.evaluate(store => {
145
- const target = store === 'local' ? localStorage : sessionStorage;
153
+ const target = store === 'local' ? localStorage : globalThis.sessionStorage;
154
+ if (!target)
155
+ return null;
146
156
  const out = [];
147
157
  for (let index = 0; index < target.length && index < 5_000; index++) {
148
158
  const key = target.key(index);
@@ -151,6 +161,8 @@ export async function storage(page, area, keyContains, limit = 100) {
151
161
  }
152
162
  return out;
153
163
  }, area);
164
+ if (!entries)
165
+ return { area, available: false, reason: 'UNSUPPORTED_PUBLIC_API', entries: [], matched: 0, truncated: false };
154
166
  const matched = entries.filter(([key]) => !keyContains || key.includes(keyContains));
155
167
  return {
156
168
  area,
package/dist/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { chromium } from 'playwright-core';
6
6
  import { BrowserConnection, InspectorError, sanitizeUrl, validateLocalUrl } from './browser.js';
7
7
  import { devicePresetNames, emulateDevice, inspectCocosPage, networkProfileNames } from './bridge.js';
8
+ import { forwardInspector, NativeConnection } from './native.js';
8
9
  const write = (line) => process.stdout.write(`${line}\n`);
9
10
  function flags(args, names, booleans = []) {
10
11
  const values = {};
@@ -124,10 +125,39 @@ async function launch(args) {
124
125
  await exited;
125
126
  process.exit(0);
126
127
  }
128
+ // Native debug builds: find the inspector port in logcat, forward it over adb, and check the Cocos runtime through it.
129
+ async function nativeDoctor() {
130
+ const fail = (code, message, fix) => {
131
+ write(`FAIL ${code}: ${message}\n Fix: ${fix}`);
132
+ process.exitCode = 1;
133
+ };
134
+ let port;
135
+ try {
136
+ port = await forwardInspector();
137
+ }
138
+ catch (error) {
139
+ return fail('CDP_UNAVAILABLE', error instanceof Error ? error.message : 'adb failed', 'install Android platform-tools, check adb devices, and set ANDROID_SERIAL when several devices are attached');
140
+ }
141
+ if (port === undefined)
142
+ return fail('CDP_UNAVAILABLE', 'no "Debugger listening" line in the device log', 'start a Cocos native build made with Debug checked; release builds have no inspector');
143
+ write(`ok debug build listens on device port ${port}; forwarded to 127.0.0.1:${port}`);
144
+ const endpoint = `http://127.0.0.1:${port}`;
145
+ const info = await (await new NativeConnection(endpoint).page()).evaluate(inspectCocosPage).catch(error => error);
146
+ if (info instanceof Error)
147
+ return fail('CDP_UNAVAILABLE', info.message, 'close Chrome DevTools attached to the game; the inspector takes one session at a time');
148
+ if (!info.cocos?.detected)
149
+ return fail('COCOS_NOT_FOUND', 'no Cocos Creator 3.x runtime in the app', 'wait for the game to finish loading');
150
+ if (!info.cocos.sceneName)
151
+ return fail('SCENE_NOT_READY', `Cocos ${info.cocos.version} has no active scene yet`, 'wait for the first scene to load');
152
+ write(`ok Cocos ${info.cocos.version}, scene "${info.cocos.sceneName}"`);
153
+ write(`\nAdd it to Claude Code:\n claude mcp add cocos-native -- npx -y cocos-web-inspector-mcp --native-endpoint ${endpoint}`);
154
+ }
127
155
  async function doctor(args) {
128
- const { values, positional } = flags(args, ['--cdp-endpoint']);
156
+ const { values, positional } = flags(args, ['--cdp-endpoint'], ['--native']);
129
157
  if (positional.length)
130
- throw new Error('Usage: cocos-web-inspector-mcp doctor [--cdp-endpoint <url>]');
158
+ throw new Error('Usage: cocos-web-inspector-mcp doctor [--cdp-endpoint <url> | --native]');
159
+ if (values['--native'])
160
+ return nativeDoctor();
131
161
  const endpoint = values['--cdp-endpoint'] ?? process.env.COCOS_CDP_ENDPOINT ?? 'http://127.0.0.1:9222';
132
162
  let failed = false;
133
163
  const pass = (message) => write(`ok ${message}`);
@@ -1,4 +1,6 @@
1
1
  import type { Page } from 'playwright-core';
2
+ import { type Clip } from './bridge.js';
3
+ import { selectRequests } from './browser-data.js';
2
4
  type Evaluate = (webSocketUrl: string, expression: string, timeout: number) => Promise<unknown>;
3
5
  export declare class NativeConnection {
4
6
  #private;
@@ -11,6 +13,16 @@ export declare class NativeConnection {
11
13
  }
12
14
  export declare function nativeClick(page: Page, uuid: string): Promise<unknown>;
13
15
  export declare function nativeDrag(page: Page, uuid: string, dx: number, dy: number, durationMs?: number): Promise<unknown>;
16
+ export declare function nativeCaptureNode(page: Page, uuid: string): Promise<unknown>;
17
+ /** Crops a raw screencap to the clip and encodes an RGB PNG, sampling every n-th pixel until it fits the response. */
18
+ export declare function cropPng(raw: Buffer, clip: Clip): unknown;
19
+ /**
20
+ * The inspector port from the engine's own "Debugger listening" logcat lines: the latest one, or `expected` only when
21
+ * some line names it. Any app can log under the Cocos tag, so reconnects pass the port they already used.
22
+ */
23
+ export declare function inspectorPort(logcat: string, expected?: number): number | undefined;
24
+ /** Forwards the device's inspector port to the same loopback port; returns it, or undefined when no debug build has logged one. */
25
+ export declare function forwardInspector(expected?: number): Promise<number | undefined>;
14
26
  /** Parses `logcat -v epoch` lines of the Cocos tag; console.log and console.debug share one level there and map to log. */
15
27
  export declare function parseCocosLogcat(output: string): Array<{
16
28
  type: string;
@@ -23,4 +35,6 @@ export declare function nativeConsoleMessages(page: Page, options: {
23
35
  limit?: number | undefined;
24
36
  }): Promise<unknown>;
25
37
  export declare function nativeTypeText(page: Page, uuid: string, text: string, submit: boolean): Promise<unknown>;
38
+ export declare function nativeNetworkRequests(page: Page, options: Parameters<typeof selectRequests>[1]): Promise<unknown>;
39
+ export declare function nativeNetworkRequest(page: Page, id: number, includeBody: boolean): Promise<unknown>;
26
40
  export {};
@@ -1,8 +1,9 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
- import { nodeCenter, runBridge } from './bridge.js';
3
+ import { crc32, deflateSync, gunzipSync } from 'node:zlib';
4
+ import { fitsImage, fitsResponse, nodeCenter, runBridge, visibleClip } from './bridge.js';
4
5
  import { InspectorError, validateLocalUrl } from './browser.js';
5
- import { redactText } from './browser-data.js';
6
+ import { redactBody, redactHeaders, redactText, redactUrl, selectRequests } from './browser-data.js';
6
7
  // Cocos native debug builds (V8) run a Node-style inspector: Runtime and Debugger domains only, no pages, one session at a time.
7
8
  // ponytail: one WebSocket per call so Chrome DevTools can attach between calls; keep a socket open if per-call latency matters.
8
9
  export class NativeConnection {
@@ -27,14 +28,21 @@ export class NativeConnection {
27
28
  return this.#page;
28
29
  }
29
30
  async close() { }
31
+ async #targets() {
32
+ const response = await fetch(new URL('/json/list', this.#http), { signal: AbortSignal.timeout(10_000) });
33
+ return await response.json();
34
+ }
30
35
  async #evaluate(fn, arg) {
31
36
  let targets;
32
37
  try {
33
- const response = await fetch(new URL('/json/list', this.#http), { signal: AbortSignal.timeout(10_000) });
34
- targets = await response.json();
38
+ targets = await this.#targets();
35
39
  }
36
40
  catch (error) {
37
- throw new InspectorError('CDP_UNAVAILABLE', `Unable to reach the native inspector at ${this.#http.host}: ${error instanceof Error ? error.message : 'unknown error'}; run the debug build, find its port in logcat "Debugger listening", then adb forward tcp:<port> tcp:<port>`);
41
+ // An adb server restart or a USB drop takes the forward with it; restore it once, only to the device port this endpoint names.
42
+ const restored = await forwardInspector(Number(this.#http.port)).catch(() => undefined);
43
+ targets = restored === undefined ? [] : await this.#targets().catch(() => []);
44
+ if (!targets.length)
45
+ throw new InspectorError('CDP_UNAVAILABLE', `Unable to reach the native inspector at ${this.#http.host}: ${error instanceof Error ? error.message : 'unknown error'}; check that the debug build is running and adb sees the device (run doctor --native)`);
38
46
  }
39
47
  if (!Array.isArray(targets) || targets.length === 0)
40
48
  throw new InspectorError('NO_LOCAL_PAGE', 'Native inspector lists no target');
@@ -79,15 +87,16 @@ async function evaluateOverWebSocket(webSocketUrl, expression, timeout) {
79
87
  // Real input and logs go through adb on the device the inspector is forwarded from. adb picks the device itself;
80
88
  // with several attached, set ANDROID_SERIAL. Arguments are numbers or a validated package name, never free text.
81
89
  const run = promisify(execFile);
82
- async function adb(args) {
90
+ async function adbBuffer(args) {
83
91
  try {
84
- return (await run('adb', args, { timeout: 15_000, maxBuffer: 16 * 1024 * 1024 })).stdout;
92
+ return (await run('adb', args, { encoding: 'buffer', timeout: 15_000, maxBuffer: 16 * 1024 * 1024 })).stdout;
85
93
  }
86
94
  catch (error) {
87
95
  const message = error instanceof Error ? error.message.split('\n')[0] : 'unknown error';
88
96
  throw new InspectorError('CDP_UNAVAILABLE', `adb ${args[0]} failed: ${message.slice(0, 300)}; check adb devices, and set ANDROID_SERIAL when several are attached`);
89
97
  }
90
98
  }
99
+ const adb = async (args) => (await adbBuffer(args)).toString();
91
100
  // ponytail: viewport coordinates equal screen pixels only for a full-screen game (the Cocos default); add the window offset for windowed apps.
92
101
  const pixel = (value) => String(Math.round(value));
93
102
  const settle = () => new Promise(resolve => setTimeout(resolve, 300));
@@ -110,6 +119,65 @@ export async function nativeDrag(page, uuid, dx, dy, durationMs = 300) {
110
119
  await settle();
111
120
  return { dragged: true, target: { nodeUuid: uuid }, from, to, input: 'adb', runtimeOnly: true };
112
121
  }
122
+ // Raw `screencap` output: width, height, and pixel format as uint32 LE, a colorspace word on Android 12+, then RGBA rows
123
+ // in the current display rotation. The device gzips it (18 MB raw takes ~20 s over Wi-Fi adb, ~1.5 s gzipped), and cropping
124
+ // raw pixels needs only zlib here, where decoding `screencap -p` would need a PNG decoder.
125
+ export async function nativeCaptureNode(page, uuid) {
126
+ const clip = await visibleClip(page, uuid);
127
+ if ('reason' in clip)
128
+ return { captured: false, reason: clip.reason };
129
+ const raw = gunzipSync(await adbBuffer(['exec-out', 'screencap | gzip -1']), { maxOutputLength: 256 * 1024 * 1024 });
130
+ return cropPng(raw, clip);
131
+ }
132
+ /** Crops a raw screencap to the clip and encodes an RGB PNG, sampling every n-th pixel until it fits the response. */
133
+ export function cropPng(raw, clip) {
134
+ if (raw.length < 12)
135
+ return { captured: false, reason: 'SCREENCAP_FAILED' };
136
+ const screenWidth = raw.readUInt32LE(0);
137
+ const screenHeight = raw.readUInt32LE(4);
138
+ const header = raw.length - screenWidth * screenHeight * 4;
139
+ // 1 is RGBA_8888 and 2 is RGBX_8888; other formats are not 4 bytes per pixel.
140
+ if (![1, 2].includes(raw.readUInt32LE(8)) || (header !== 12 && header !== 16))
141
+ return { captured: false, reason: 'UNSUPPORTED_PIXEL_FORMAT' };
142
+ // ponytail: viewport pixels equal screen pixels only for a full-screen game, as for adb taps; add the window offset for windowed apps.
143
+ const left = Math.max(0, Math.floor(clip.x));
144
+ const top = Math.max(0, Math.floor(clip.y));
145
+ const right = Math.min(screenWidth, Math.ceil(clip.x + clip.width));
146
+ const bottom = Math.min(screenHeight, Math.ceil(clip.y + clip.height));
147
+ if (right <= left || bottom <= top)
148
+ return { captured: false, reason: 'OUTSIDE_VIEWPORT' };
149
+ // ponytail: nearest-pixel sampling keeps this dependency-free; box filtering would read text better when downscaled.
150
+ for (const step of [1, 2, 3, 4, 6, 8]) {
151
+ const width = Math.ceil((right - left) / step);
152
+ const height = Math.ceil((bottom - top) / step);
153
+ const rows = Buffer.alloc((width * 3 + 1) * height);
154
+ for (let y = 0; y < height; y++) {
155
+ const out = y * (width * 3 + 1) + 1;
156
+ const source = header + ((top + y * step) * screenWidth + left) * 4;
157
+ for (let x = 0; x < width; x++)
158
+ raw.copy(rows, out + x * 3, source + x * step * 4, source + x * step * 4 + 3);
159
+ }
160
+ const data = png(width, height, rows).toString('base64');
161
+ if (fitsImage(data))
162
+ return { captured: true, mimeType: 'image/png', data, width: right - left, height: bottom - top, ...(step > 1 ? { scale: 1 / step } : {}) };
163
+ }
164
+ return { captured: false, reason: 'RESPONSE_LIMIT' };
165
+ }
166
+ function png(width, height, rows) {
167
+ const chunk = (type, body) => {
168
+ const head = Buffer.alloc(8);
169
+ head.writeUInt32BE(body.length, 0);
170
+ head.write(type, 4, 'ascii');
171
+ const crc = Buffer.alloc(4);
172
+ crc.writeUInt32BE(crc32(body, crc32(type)), 0);
173
+ return Buffer.concat([head, body, crc]);
174
+ };
175
+ const ihdr = Buffer.alloc(13);
176
+ ihdr.writeUInt32BE(width, 0);
177
+ ihdr.writeUInt32BE(height, 4);
178
+ ihdr.set([8, 2, 0, 0, 0], 8); // 8-bit RGB, no interlace
179
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(rows, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
180
+ }
113
181
  // The app's own process, from its files directory, so logs of other Cocos apps on the device never mix in.
114
182
  async function appProcess(page) {
115
183
  const path = String(await page.evaluate(() => globalThis.jsb?.fileUtils?.getWritablePath?.() ?? ''));
@@ -121,6 +189,21 @@ async function appProcess(page) {
121
189
  throw new InspectorError('CDP_UNAVAILABLE', `App ${name} is not running`);
122
190
  return pid;
123
191
  }
192
+ /**
193
+ * The inspector port from the engine's own "Debugger listening" logcat lines: the latest one, or `expected` only when
194
+ * some line names it. Any app can log under the Cocos tag, so reconnects pass the port they already used.
195
+ */
196
+ export function inspectorPort(logcat, expected) {
197
+ const ports = [...logcat.matchAll(/js_app\.html\?v8only=true&ws=[^:\s]+:(\d{2,5})\//g)].map(match => Number(match[1])).filter(port => port >= 1_024 && port <= 65_535);
198
+ return expected === undefined ? ports.at(-1) : ports.includes(expected) ? expected : undefined;
199
+ }
200
+ /** Forwards the device's inspector port to the same loopback port; returns it, or undefined when no debug build has logged one. */
201
+ export async function forwardInspector(expected) {
202
+ const port = inspectorPort(await adb(['logcat', '-d', '-s', 'Cocos:D']), expected);
203
+ if (port !== undefined)
204
+ await adb(['forward', `tcp:${port}`, `tcp:${port}`]);
205
+ return port;
206
+ }
124
207
  const levels = { V: 'debug', D: 'log', I: 'info', W: 'warning', E: 'error', F: 'error' };
125
208
  /** Parses `logcat -v epoch` lines of the Cocos tag; console.log and console.debug share one level there and map to log. */
126
209
  export function parseCocosLogcat(output) {
@@ -182,3 +265,173 @@ export async function nativeTypeText(page, uuid, text, submit) {
182
265
  const matches = typeof properties?.string === 'string' ? properties.string === text : undefined;
183
266
  return { typed: true, target: { nodeUuid: uuid }, length: text.length, submitted: submit, after, ...(matches === undefined ? {} : { matches }), ...(matches === false ? { hint: 'The device keyboard changed the text; switch it to a plain English layout without autocorrect' } : {}), input: 'adb', runtimeOnly: true };
184
267
  }
268
+ function nativeNetwork(query) {
269
+ const root = globalThis;
270
+ const MAX_ENTRIES = 200;
271
+ const MAX_BODY = 100_000;
272
+ const clip = (value) => typeof value === 'string' ? value.slice(0, MAX_BODY)
273
+ : value instanceof ArrayBuffer ? `[binary ${value.byteLength} bytes]` : value == null ? '' : `[${typeof value}]`;
274
+ let state = root.__cocosWebInspectorNetwork;
275
+ const installedNow = !state;
276
+ if (!state) {
277
+ state = root.__cocosWebInspectorNetwork = { entries: [], nextId: 1, installedAt: Date.now() };
278
+ const record = (entry) => {
279
+ entry.id = state.nextId++;
280
+ state.entries.push(entry);
281
+ if (state.entries.length > MAX_ENTRIES)
282
+ state.entries.shift();
283
+ return entry;
284
+ };
285
+ const Xhr = root.XMLHttpRequest;
286
+ if (typeof Xhr === 'function') {
287
+ const proto = Xhr.prototype;
288
+ const open = proto.open;
289
+ const send = proto.send;
290
+ const setHeader = proto.setRequestHeader;
291
+ proto.open = function (method, url, ...rest) {
292
+ this.__cwi = { method: String(method).toUpperCase(), url: String(url), headers: {} };
293
+ return open.call(this, method, url, ...rest);
294
+ };
295
+ proto.setRequestHeader = function (name, value) {
296
+ // The ring lives in game memory that other game code can read, so credential headers are masked as they are captured.
297
+ // ponytail: bodies stay raw in the ring and are redacted only when returned; redact here too if third-party SDKs are a concern.
298
+ if (this.__cwi)
299
+ this.__cwi.headers[String(name)] = /auth|cookie|token|secret|api-?key|password|session/i.test(String(name)) ? '[redacted]' : String(value);
300
+ return setHeader.call(this, name, value);
301
+ };
302
+ proto.send = function (body) {
303
+ const meta = this.__cwi;
304
+ if (meta) {
305
+ const entry = record({ kind: 'xhr', method: meta.method, url: meta.url, requestHeaders: meta.headers, requestBody: clip(body), startedAt: Date.now() });
306
+ this.__cwiDone = (failure) => {
307
+ if (entry.endedAt)
308
+ return;
309
+ entry.endedAt = Date.now();
310
+ entry.status = Number(this.status) || null;
311
+ if (failure)
312
+ entry.failure = failure;
313
+ try {
314
+ entry.responseHeaders = String(this.getAllResponseHeaders() ?? '').slice(0, 10_000);
315
+ }
316
+ catch { /* not ready */ }
317
+ try {
318
+ entry.responseBody = clip(this.responseType === '' || this.responseType === 'text' ? this.responseText : this.response);
319
+ }
320
+ catch { /* unreadable */ }
321
+ };
322
+ // Native XHR dispatches only through on* properties, and its addEventListener just assigns them, so listeners
323
+ // would replace the game's handlers. Chain through accessors instead; the native side reads them with a plain get.
324
+ // ponytail: reading xhr.onload returns the chain, not the game's function; keep a handler map if identity matters.
325
+ if (!this.__cwiHooked) {
326
+ this.__cwiHooked = true;
327
+ const xhr = this;
328
+ for (const [name, failure] of [['onload', undefined], ['onerror', 'error'], ['ontimeout', 'timeout'], ['onabort', 'abort']]) {
329
+ let handler = this[name];
330
+ const chain = function (event) {
331
+ try {
332
+ xhr.__cwiDone(failure);
333
+ }
334
+ catch { /* never break the game */ }
335
+ return typeof handler === 'function' ? handler.call(this, event) : undefined;
336
+ };
337
+ Object.defineProperty(this, name, { configurable: true, get: () => chain, set: (fn) => { handler = fn; } });
338
+ }
339
+ }
340
+ }
341
+ return send.call(this, body);
342
+ };
343
+ }
344
+ const NativeSocket = root.WebSocket;
345
+ if (typeof NativeSocket === 'function') {
346
+ // Pass every argument through: native WebSocket takes (url, protocols, caFilePath), and dropping the CA file
347
+ // changes how the game's TLS connection is verified.
348
+ const Wrapped = function (...args) {
349
+ const socket = new NativeSocket(...args);
350
+ const entry = record({ kind: 'websocket', method: 'GET', url: String(args[0]), startedAt: Date.now(), frames: [], framesSeen: 0 });
351
+ const frame = (direction, data) => {
352
+ entry.framesSeen++;
353
+ entry.frames.push({ direction, at: Date.now(), data: clip(data).slice(0, 2_000) });
354
+ if (entry.frames.length > 50)
355
+ entry.frames.shift();
356
+ };
357
+ const send = socket.send;
358
+ socket.send = function (data) { frame('sent', data); return send.call(socket, data); };
359
+ // Native sockets dispatch only through on* properties; wrap each handler as the game assigns it.
360
+ for (const [name, hook] of [
361
+ ['onopen', () => { entry.status = 101; }],
362
+ ['onmessage', (event) => frame('received', event?.data)],
363
+ ['onerror', () => { entry.failure = 'error'; }],
364
+ ['onclose', (event) => { entry.endedAt = Date.now(); entry.closeCode = Number(event?.code) || null; }],
365
+ ]) {
366
+ let handler = null;
367
+ Object.defineProperty(socket, name, {
368
+ configurable: true,
369
+ get: () => handler,
370
+ set: (fn) => { handler = typeof fn === 'function' ? function (event) { try {
371
+ hook(event);
372
+ }
373
+ catch { /* never break the game */ } return fn.call(this, event); } : fn; },
374
+ });
375
+ }
376
+ return socket;
377
+ };
378
+ Wrapped.prototype = NativeSocket.prototype;
379
+ for (const key of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'])
380
+ Wrapped[key] = NativeSocket[key];
381
+ root.WebSocket = Wrapped;
382
+ }
383
+ }
384
+ if (query.action === 'list')
385
+ return { installedAt: state.installedAt, installedNow, entries: state.entries.map(({ requestBody: _b, responseBody: _r, frames: _f, requestHeaders: _h, responseHeaders: _rh, ...summary }) => summary) };
386
+ const entry = state.entries.find(candidate => candidate.id === query.id);
387
+ return entry ? { installedAt: state.installedAt, entry } : { installedAt: state.installedAt, entry: null };
388
+ }
389
+ const sinceNote = (installedAt, installedNow) => installedNow
390
+ ? 'Capture started with this call; make the game send traffic, then list again.'
391
+ : `Requests and WebSocket frames since capture started at ${new Date(installedAt).toISOString()}; the game keeps the last 200.`;
392
+ export async function nativeNetworkRequests(page, options) {
393
+ const { installedAt, installedNow, entries } = await page.evaluate(nativeNetwork, { action: 'list' });
394
+ const rows = entries.map(entry => ({
395
+ id: entry.id,
396
+ method: entry.method,
397
+ url: redactUrl(entry.url),
398
+ resourceType: entry.kind,
399
+ status: entry.status ?? null,
400
+ ...(entry.failure ? { failure: String(entry.failure).slice(0, 300) } : {}),
401
+ ...(entry.endedAt ? { durationMs: entry.endedAt - entry.startedAt } : {}),
402
+ ...(entry.kind === 'websocket' ? { frames: entry.framesSeen } : {}),
403
+ }));
404
+ return selectRequests(rows, options, sinceNote(installedAt, installedNow));
405
+ }
406
+ export async function nativeNetworkRequest(page, id, includeBody) {
407
+ const { entry } = await page.evaluate(nativeNetwork, { action: 'get', id });
408
+ if (!entry)
409
+ throw new InspectorError('REQUEST_NOT_FOUND', `Request ${id} not found; list requests again, older entries are dropped`);
410
+ // Split each "name: value" line at its first colon only; dates and URLs carry more.
411
+ const headers = (raw) => Object.fromEntries(raw.split(/\r?\n/).map(line => /^([^:]+):\s*(.*)$/.exec(line)).filter(match => !!match).map(match => [match[1].trim().toLowerCase(), match[2]]));
412
+ const frames = entry.kind === 'websocket' && includeBody ? entry.frames.map((frame) => ({ direction: frame.direction, at: new Date(frame.at).toISOString(), data: redactBody(frame.data).body })) : undefined;
413
+ // Native results skip runBridge, so enforce the response ceiling here: drop the oldest frames, then the bodies.
414
+ let result = detail(entry, id, includeBody, headers, frames);
415
+ while (frames?.length && !fitsResponse(result)) {
416
+ frames.splice(0, Math.max(1, Math.ceil(frames.length / 4)));
417
+ result = { ...detail(entry, id, includeBody, headers, frames), framesDropped: true };
418
+ }
419
+ return fitsResponse(result) ? result : { ...detail(entry, id, false, headers, undefined), truncated: true, truncationReasons: ['RESPONSE_LIMIT'] };
420
+ }
421
+ function detail(entry, id, includeBody, headers, frames) {
422
+ return {
423
+ id,
424
+ method: entry.method,
425
+ url: redactUrl(entry.url),
426
+ resourceType: entry.kind,
427
+ ...(entry.requestHeaders ? { requestHeaders: redactHeaders(entry.requestHeaders) } : {}),
428
+ ...(includeBody && entry.requestBody ? { requestBody: redactBody(entry.requestBody) } : {}),
429
+ status: entry.status ?? null,
430
+ ...(entry.responseHeaders ? { responseHeaders: redactHeaders(headers(entry.responseHeaders)) } : {}),
431
+ ...(entry.failure ? { failure: String(entry.failure).slice(0, 300) } : {}),
432
+ ...(entry.closeCode ? { closeCode: entry.closeCode } : {}),
433
+ ...(includeBody && entry.responseBody ? { responseBody: redactBody(entry.responseBody) } : {}),
434
+ ...(entry.kind === 'websocket' ? { framesSeen: entry.framesSeen, ...(frames ? { frames } : {}) } : {}),
435
+ timing: { startedAt: new Date(entry.startedAt).toISOString(), ...(entry.endedAt ? { durationMs: entry.endedAt - entry.startedAt } : {}) },
436
+ };
437
+ }
@@ -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 { nativeClick, nativeConsoleMessages, nativeDrag, nativeTypeText } from './native.js';
5
+ import { nativeCaptureNode, nativeClick, nativeConsoleMessages, nativeDrag, nativeNetworkRequest, nativeNetworkRequests, nativeTypeText } from './native.js';
6
6
  import { consoleMessages, networkRequest, networkRequests, storage } from './browser-data.js';
7
7
  import { captureNode, clickNode, devicePresetNames, dragNode, emulateDevice, inspectCocosPage, networkProfileNames, runBridge, typeText } from './bridge.js';
8
8
  const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
@@ -61,7 +61,7 @@ function failure(error) {
61
61
  }
62
62
  export function createServer(browser, options = {}) {
63
63
  const server = new McpServer({ name: 'cocos-web-inspector-mcp', version });
64
- // A native (JSB) runtime has no page: screenshots, device emulation, and network/cookie readers need a browser,
64
+ // A native (JSB) runtime has no page: screenshots and device emulation need a browser,
65
65
  // the DOM overlays draw nothing, the 2D batcher runs in C++ where analyze_batches cannot observe it,
66
66
  // and game.step renders outside the native frame loop, which crashed a 3.8.8 Android build in the GFX pipeline.
67
67
  const web = !options.native;
@@ -136,12 +136,11 @@ export function createServer(browser, options = {}) {
136
136
  inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
137
137
  annotations: readOnly,
138
138
  }, input => executeNode(input, uuid => ({ action: 'getNodeBounds', uuid })));
139
- if (web)
140
- server.registerTool('cocos_capture_node', {
141
- description: 'Capture a bounded viewport-clipped PNG for one visible Cocos UI node UUID.',
142
- inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
143
- annotations: readOnly,
144
- }, input => withNode(input, captureNode));
139
+ server.registerTool('cocos_capture_node', {
140
+ description: 'Capture a bounded viewport-clipped PNG for one visible Cocos UI node UUID.',
141
+ inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
142
+ annotations: readOnly,
143
+ }, input => withNode(input, web ? captureNode : nativeCaptureNode));
145
144
  server.registerTool('cocos_get_components', {
146
145
  description: 'List bounded component summaries for one Cocos node UUID.',
147
146
  inputSchema: z.object({ pageUrl, ...target }).strict().refine(oneTarget, oneTargetMessage),
@@ -316,24 +315,21 @@ export function createServer(browser, options = {}) {
316
315
  inputSchema: z.object({ pageUrl, types: z.array(z.enum(['log', 'debug', 'info', 'error', 'warning', 'assert', 'trace', 'pageerror'])).max(8).optional(), textContains: z.string().min(1).max(200).optional(), limit: z.number().int().min(1).max(200).optional() }).strict(),
317
316
  annotations: browserData,
318
317
  }, input => run(async () => (web ? consoleMessages : nativeConsoleMessages)(await browser.page(input.pageUrl), input)));
319
- // ponytail: native network and storage need jsb XHR hooks and a jsb.localStorage reader; add them when a native game needs them.
320
- if (web) {
321
- server.registerTool('cocos_network_requests', {
322
- description: 'List recent network requests since the server attached: id, method, URL with secret query values masked, resource type, status, failure, and duration. Use cocos_network_request for headers and bodies.',
323
- inputSchema: z.object({ pageUrl, urlContains: z.string().min(1).max(500).optional(), resourceType: z.string().min(1).max(40).optional(), failedOnly: z.boolean().optional(), limit: z.number().int().min(1).max(200).optional() }).strict(),
324
- annotations: browserData,
325
- }, input => run(async () => networkRequests(await browser.page(input.pageUrl), input)));
326
- server.registerTool('cocos_network_request', {
327
- description: 'Return one request by id from cocos_network_requests: headers with authorization and cookies masked, status, timing, and optionally text bodies up to 20 KB with secret-like JSON and form fields masked.',
328
- inputSchema: z.object({ pageUrl, id: z.number().int().min(1), includeBody: z.boolean().optional() }).strict(),
329
- annotations: browserData,
330
- }, input => run(async () => networkRequest(await browser.page(input.pageUrl), input.id, input.includeBody ?? false)));
331
- server.registerTool('cocos_storage', {
332
- description: 'Read localStorage or sessionStorage entries, or cookie names and attributes for the page. Secret-like keys and every cookie value are masked; JSON values are masked by key.',
333
- inputSchema: z.object({ pageUrl, area: z.enum(['local', 'session', 'cookies']), keyContains: z.string().min(1).max(200).optional(), limit: z.number().int().min(1).max(500).optional() }).strict(),
334
- annotations: browserData,
335
- }, input => run(async () => storage(await browser.page(input.pageUrl), input.area, input.keyContains, input.limit)));
336
- }
318
+ server.registerTool('cocos_network_requests', {
319
+ description: 'List recent network requests since the server attached: id, method, URL with secret query values masked, resource type, status, failure, and duration. Use cocos_network_request for headers and bodies.',
320
+ inputSchema: z.object({ pageUrl, urlContains: z.string().min(1).max(500).optional(), resourceType: z.string().min(1).max(40).optional(), failedOnly: z.boolean().optional(), limit: z.number().int().min(1).max(200).optional() }).strict(),
321
+ annotations: browserData,
322
+ }, input => run(async () => (web ? networkRequests : nativeNetworkRequests)(await browser.page(input.pageUrl), input)));
323
+ server.registerTool('cocos_network_request', {
324
+ description: 'Return one request by id from cocos_network_requests: headers with authorization and cookies masked, status, timing, and optionally text bodies up to 20 KB with secret-like JSON and form fields masked.',
325
+ inputSchema: z.object({ pageUrl, id: z.number().int().min(1), includeBody: z.boolean().optional() }).strict(),
326
+ annotations: browserData,
327
+ }, input => run(async () => (web ? networkRequest : nativeNetworkRequest)(await browser.page(input.pageUrl), input.id, input.includeBody ?? false)));
328
+ server.registerTool('cocos_storage', {
329
+ description: 'Read localStorage or sessionStorage entries, or cookie names and attributes for the page. Secret-like keys and every cookie value are masked; JSON values are masked by key.',
330
+ inputSchema: z.object({ pageUrl, area: z.enum(['local', 'session', 'cookies']), keyContains: z.string().min(1).max(200).optional(), limit: z.number().int().min(1).max(500).optional() }).strict(),
331
+ annotations: browserData,
332
+ }, input => run(async () => storage(await browser.page(input.pageUrl), input.area, input.keyContains, input.limit)));
337
333
  }
338
334
  if (web)
339
335
  server.registerTool('cocos_get_selection', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cocos-web-inspector-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.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": {