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 +9 -4
- package/dist/src/bridge.d.ts +14 -0
- package/dist/src/bridge.js +44 -23
- package/dist/src/browser-data.d.ts +17 -2
- package/dist/src/browser-data.js +15 -3
- package/dist/src/cli.js +32 -2
- package/dist/src/native.d.ts +14 -0
- package/dist/src/native.js +260 -7
- package/dist/src/server.js +22 -26
- package/package.json +1 -1
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
|
-
|
|
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: `
|
|
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
|
-
|
|
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
|
|
package/dist/src/bridge.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/bridge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
15
|
+
return { reason: result.reason ?? 'OUTSIDE_VIEWPORT' };
|
|
15
16
|
const clip = result.clippedViewport;
|
|
16
|
-
if (!clip || !
|
|
17
|
-
return {
|
|
18
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 {};
|
package/dist/src/browser-data.js
CHANGED
|
@@ -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
|
|
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}`);
|
package/dist/src/native.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/src/native.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
-
import {
|
|
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
|
-
|
|
34
|
-
targets = await response.json();
|
|
38
|
+
targets = await this.#targets();
|
|
35
39
|
}
|
|
36
40
|
catch (error) {
|
|
37
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/src/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { InspectorError, sanitizeUrl } from './browser.js';
|
|
5
|
-
import { 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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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', {
|