@ait-co/devtools 0.1.34 → 0.1.36
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.en.md +13 -7
- package/README.md +21 -14
- package/dist/mcp/cli.d.ts +22 -6
- package/dist/mcp/cli.d.ts.map +1 -1
- package/dist/mcp/cli.js +1018 -33
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +38 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.js +2 -2
- package/package.json +3 -2
package/dist/mcp/cli.js
CHANGED
|
@@ -8,26 +8,30 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
8
8
|
import { EventEmitter } from "node:events";
|
|
9
9
|
import { WebSocket } from "ws";
|
|
10
10
|
import { createServer } from "node:http";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
import { platform } from "node:os";
|
|
11
15
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
16
|
import { Tunnel, bin, install } from "cloudflared";
|
|
13
17
|
//#region src/mcp/ait-chii-source.ts
|
|
14
|
-
function isObject$
|
|
18
|
+
function isObject$3(value) {
|
|
15
19
|
return typeof value === "object" && value !== null;
|
|
16
20
|
}
|
|
17
21
|
/** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
|
|
18
22
|
function asSdkCallHistory(raw) {
|
|
19
|
-
if (isObject$
|
|
23
|
+
if (isObject$3(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
|
|
20
24
|
return { calls: [] };
|
|
21
25
|
}
|
|
22
26
|
/** Narrows an `AIT.getMockState` response to an opaque record. */
|
|
23
27
|
function asMockState(raw) {
|
|
24
|
-
return isObject$
|
|
28
|
+
return isObject$3(raw) ? raw : {};
|
|
25
29
|
}
|
|
26
30
|
/** Narrows an `AIT.getOperationalEnvironment` response. */
|
|
27
31
|
function asOperationalEnvironment(raw) {
|
|
28
32
|
return {
|
|
29
|
-
environment: isObject$
|
|
30
|
-
sdkVersion: isObject$
|
|
33
|
+
environment: isObject$3(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
|
|
34
|
+
sdkVersion: isObject$3(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
37
|
var ChiiAitSource = class {
|
|
@@ -60,27 +64,27 @@ var ChiiAitSource = class {
|
|
|
60
64
|
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
61
65
|
*/
|
|
62
66
|
/** Max events retained per domain ring buffer. */
|
|
63
|
-
const DEFAULT_BUFFER_SIZE = 500;
|
|
64
|
-
function isObject$
|
|
67
|
+
const DEFAULT_BUFFER_SIZE$1 = 500;
|
|
68
|
+
function isObject$2(value) {
|
|
65
69
|
return typeof value === "object" && value !== null;
|
|
66
70
|
}
|
|
67
|
-
function parseInbound(raw) {
|
|
71
|
+
function parseInbound$1(raw) {
|
|
68
72
|
let parsed;
|
|
69
73
|
try {
|
|
70
74
|
parsed = JSON.parse(raw);
|
|
71
75
|
} catch {
|
|
72
76
|
return null;
|
|
73
77
|
}
|
|
74
|
-
if (!isObject$
|
|
78
|
+
if (!isObject$2(parsed)) return null;
|
|
75
79
|
const message = {};
|
|
76
80
|
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
77
81
|
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
78
82
|
if ("params" in parsed) message.params = parsed.params;
|
|
79
83
|
if ("result" in parsed) message.result = parsed.result;
|
|
80
|
-
if (isObject$
|
|
84
|
+
if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
81
85
|
return message;
|
|
82
86
|
}
|
|
83
|
-
const PHASE_1_EVENTS = [
|
|
87
|
+
const PHASE_1_EVENTS$1 = [
|
|
84
88
|
"Runtime.consoleAPICalled",
|
|
85
89
|
"Network.requestWillBeSent",
|
|
86
90
|
"Network.responseReceived"
|
|
@@ -103,8 +107,8 @@ var ChiiCdpConnection = class {
|
|
|
103
107
|
pending = /* @__PURE__ */ new Map();
|
|
104
108
|
constructor(options) {
|
|
105
109
|
this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
|
|
106
|
-
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
|
|
107
|
-
for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
|
|
110
|
+
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE$1;
|
|
111
|
+
for (const event of PHASE_1_EVENTS$1) this.buffers.set(event, []);
|
|
108
112
|
this.emitter.setMaxListeners(0);
|
|
109
113
|
}
|
|
110
114
|
/** Refresh the attached-target list from the relay's `GET /targets`. */
|
|
@@ -112,10 +116,10 @@ var ChiiCdpConnection = class {
|
|
|
112
116
|
const res = await fetch(`${this.relayBaseUrl}/targets`);
|
|
113
117
|
if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
|
|
114
118
|
const body = await res.json();
|
|
115
|
-
const list = isObject$
|
|
119
|
+
const list = isObject$2(body) && Array.isArray(body.targets) ? body.targets : [];
|
|
116
120
|
this.targets.clear();
|
|
117
121
|
for (const item of list) {
|
|
118
|
-
if (!isObject$
|
|
122
|
+
if (!isObject$2(item) || typeof item.id !== "string") continue;
|
|
119
123
|
this.targets.set(item.id, {
|
|
120
124
|
id: item.id,
|
|
121
125
|
title: typeof item.title === "string" ? item.title : "",
|
|
@@ -193,7 +197,7 @@ var ChiiCdpConnection = class {
|
|
|
193
197
|
});
|
|
194
198
|
}
|
|
195
199
|
handleMessage(raw) {
|
|
196
|
-
const message = parseInbound(raw);
|
|
200
|
+
const message = parseInbound$1(raw);
|
|
197
201
|
if (!message) return;
|
|
198
202
|
if (typeof message.id === "number" && this.pending.has(message.id)) {
|
|
199
203
|
const waiter = this.pending.get(message.id);
|
|
@@ -311,7 +315,404 @@ async function startChiiRelay(options = {}) {
|
|
|
311
315
|
};
|
|
312
316
|
}
|
|
313
317
|
//#endregion
|
|
318
|
+
//#region src/mcp/local-connection.ts
|
|
319
|
+
/**
|
|
320
|
+
* Local-browser `CdpConnection` — attaches directly to a Chromium instance
|
|
321
|
+
* started with `--remote-debugging-port=<port>`.
|
|
322
|
+
*
|
|
323
|
+
* Topology (local debug mode, env 1):
|
|
324
|
+
* Chromium --CDP WS--> this connection <--stdio--> MCP host
|
|
325
|
+
*
|
|
326
|
+
* The core insight: local Chromium and the phone's Toss WebView both speak
|
|
327
|
+
* Chrome DevTools Protocol. The only difference is the attach strategy — how
|
|
328
|
+
* you reach the CDP endpoint. Here we hit the Chromium DevTools HTTP endpoint
|
|
329
|
+
* (`GET /json`) to discover per-target websocket URLs, then connect directly.
|
|
330
|
+
* The Chii relay (env 2/3) uses `GET /targets` + `/client/<id>?target=<id>`.
|
|
331
|
+
* Every tool (list_console_messages, get_dom_document, take_screenshot, …)
|
|
332
|
+
* reads only the `CdpConnection` interface and works unchanged on both.
|
|
333
|
+
*
|
|
334
|
+
* Node-only: imports `ws`. Never bundled into the browser/in-app entries.
|
|
335
|
+
*/
|
|
336
|
+
/** Max events retained per domain ring buffer. */
|
|
337
|
+
const DEFAULT_BUFFER_SIZE = 500;
|
|
338
|
+
function isObject$1(value) {
|
|
339
|
+
return typeof value === "object" && value !== null;
|
|
340
|
+
}
|
|
341
|
+
function parseInbound(raw) {
|
|
342
|
+
let parsed;
|
|
343
|
+
try {
|
|
344
|
+
parsed = JSON.parse(raw);
|
|
345
|
+
} catch {
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
if (!isObject$1(parsed)) return null;
|
|
349
|
+
const message = {};
|
|
350
|
+
if (typeof parsed.id === "number") message.id = parsed.id;
|
|
351
|
+
if (typeof parsed.method === "string") message.method = parsed.method;
|
|
352
|
+
if ("params" in parsed) message.params = parsed.params;
|
|
353
|
+
if ("result" in parsed) message.result = parsed.result;
|
|
354
|
+
if (isObject$1(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
|
|
355
|
+
return message;
|
|
356
|
+
}
|
|
357
|
+
const PHASE_1_EVENTS = [
|
|
358
|
+
"Runtime.consoleAPICalled",
|
|
359
|
+
"Network.requestWillBeSent",
|
|
360
|
+
"Network.responseReceived"
|
|
361
|
+
];
|
|
362
|
+
/**
|
|
363
|
+
* `CdpConnection` that attaches directly to a local Chromium over its built-in
|
|
364
|
+
* CDP websocket. Mirrors `ChiiCdpConnection`'s buffering/command-routing/event
|
|
365
|
+
* logic — same `parseInbound`, ring-buffer, `pending` map patterns — but the
|
|
366
|
+
* attach strategy differs:
|
|
367
|
+
*
|
|
368
|
+
* Chii relay: `GET /targets` → open `/client/<id>?target=<id>` WS
|
|
369
|
+
* Local CDP: `GET /json` → open `webSocketDebuggerUrl` per target directly
|
|
370
|
+
*
|
|
371
|
+
* Target selection: first `type === 'page'` target whose URL is not
|
|
372
|
+
* `about:blank`, `about:newtab`, or a devtools:// URL.
|
|
373
|
+
*/
|
|
374
|
+
var LocalCdpConnection = class {
|
|
375
|
+
devtoolsHttpUrl;
|
|
376
|
+
bufferSize;
|
|
377
|
+
emitter = new EventEmitter();
|
|
378
|
+
buffers = /* @__PURE__ */ new Map();
|
|
379
|
+
targets = /* @__PURE__ */ new Map();
|
|
380
|
+
ws = null;
|
|
381
|
+
nextCommandId = 1;
|
|
382
|
+
/** In-flight enableDomains() promise — concurrent callers share it. */
|
|
383
|
+
enablingPromise = null;
|
|
384
|
+
/** Pending request→response commands keyed by CDP message id. */
|
|
385
|
+
pending = /* @__PURE__ */ new Map();
|
|
386
|
+
constructor(options) {
|
|
387
|
+
this.devtoolsHttpUrl = options.devtoolsHttpUrl.replace(/\/$/, "");
|
|
388
|
+
this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
|
|
389
|
+
for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
|
|
390
|
+
this.emitter.setMaxListeners(0);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Fetch the target list from the Chromium DevTools `/json` (or `/json/list`)
|
|
394
|
+
* endpoint and pick the first non-blank page target.
|
|
395
|
+
*
|
|
396
|
+
* Returns the selected target's `webSocketDebuggerUrl` alongside the
|
|
397
|
+
* normalized `CdpTarget` list (all page targets visible to the server).
|
|
398
|
+
*/
|
|
399
|
+
async fetchTargets() {
|
|
400
|
+
const res = await fetch(`${this.devtoolsHttpUrl}/json`);
|
|
401
|
+
if (!res.ok) throw new Error(`Chromium DevTools /json returned HTTP ${res.status} ${res.statusText}. Is the browser running with --remote-debugging-port?`);
|
|
402
|
+
const body = await res.json();
|
|
403
|
+
const list = Array.isArray(body) ? body : [];
|
|
404
|
+
this.targets.clear();
|
|
405
|
+
let selected = null;
|
|
406
|
+
for (const item of list) {
|
|
407
|
+
if (!isObject$1(item) || typeof item.id !== "string") continue;
|
|
408
|
+
const cdpTarget = {
|
|
409
|
+
id: item.id,
|
|
410
|
+
title: typeof item.title === "string" ? item.title : "",
|
|
411
|
+
url: typeof item.url === "string" ? item.url : ""
|
|
412
|
+
};
|
|
413
|
+
this.targets.set(item.id, cdpTarget);
|
|
414
|
+
if (selected === null && item.type === "page" && typeof item.webSocketDebuggerUrl === "string" && !isBlankOrDevtoolsUrl(item.url)) selected = item;
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
selected,
|
|
418
|
+
all: [...this.targets.values()]
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
listTargets() {
|
|
422
|
+
return [...this.targets.values()];
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Discover the target, open a direct CDP websocket to its
|
|
426
|
+
* `webSocketDebuggerUrl`, and enable Phase 1+2 domains. Resolves once the
|
|
427
|
+
* socket is open and domain-enable commands are sent. Idempotent — concurrent
|
|
428
|
+
* callers share the in-flight promise.
|
|
429
|
+
*/
|
|
430
|
+
async enableDomains() {
|
|
431
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
|
432
|
+
if (this.enablingPromise) return this.enablingPromise;
|
|
433
|
+
this.enablingPromise = this._doEnableDomains().finally(() => {
|
|
434
|
+
this.enablingPromise = null;
|
|
435
|
+
});
|
|
436
|
+
return this.enablingPromise;
|
|
437
|
+
}
|
|
438
|
+
async _doEnableDomains() {
|
|
439
|
+
const { selected } = await this.fetchTargets();
|
|
440
|
+
if (!selected) throw new Error("No suitable page target found in the local Chromium instance. Ensure the browser has a non-blank page open and was started with --remote-debugging-port matching devtoolsHttpUrl.");
|
|
441
|
+
const wsUrl = selected.webSocketDebuggerUrl;
|
|
442
|
+
const ws = new WebSocket(wsUrl);
|
|
443
|
+
this.ws = ws;
|
|
444
|
+
await new Promise((resolve, reject) => {
|
|
445
|
+
ws.once("open", () => resolve());
|
|
446
|
+
ws.once("error", (err) => reject(err));
|
|
447
|
+
});
|
|
448
|
+
ws.on("message", (data) => this.handleMessage(data.toString()));
|
|
449
|
+
this.sendFireAndForget("Runtime.enable");
|
|
450
|
+
this.sendFireAndForget("Network.enable");
|
|
451
|
+
this.sendFireAndForget("DOM.enable");
|
|
452
|
+
this.sendFireAndForget("Page.enable");
|
|
453
|
+
}
|
|
454
|
+
/** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
|
|
455
|
+
sendFireAndForget(method, params = {}) {
|
|
456
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
457
|
+
const id = this.nextCommandId++;
|
|
458
|
+
this.ws.send(JSON.stringify({
|
|
459
|
+
id,
|
|
460
|
+
method,
|
|
461
|
+
params
|
|
462
|
+
}));
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Issue a CDP command and resolve with its typed result. Rejects on a CDP
|
|
466
|
+
* error frame or when no websocket is open.
|
|
467
|
+
*/
|
|
468
|
+
send(method, params) {
|
|
469
|
+
return this.sendCommand(method, params ?? {});
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Issue an arbitrary request→response command and resolve with its raw
|
|
473
|
+
* result. Both the typed CDP `send` and any AIT domain commands build on this.
|
|
474
|
+
*/
|
|
475
|
+
sendCommand(method, params = {}) {
|
|
476
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No local Chromium page attached yet. Call enableDomains() first and ensure the browser is running with --remote-debugging-port."));
|
|
477
|
+
const id = this.nextCommandId++;
|
|
478
|
+
const ws = this.ws;
|
|
479
|
+
return new Promise((resolve, reject) => {
|
|
480
|
+
this.pending.set(id, {
|
|
481
|
+
resolve,
|
|
482
|
+
reject
|
|
483
|
+
});
|
|
484
|
+
ws.send(JSON.stringify({
|
|
485
|
+
id,
|
|
486
|
+
method,
|
|
487
|
+
params
|
|
488
|
+
}));
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
handleMessage(raw) {
|
|
492
|
+
const message = parseInbound(raw);
|
|
493
|
+
if (!message) return;
|
|
494
|
+
if (typeof message.id === "number" && this.pending.has(message.id)) {
|
|
495
|
+
const waiter = this.pending.get(message.id);
|
|
496
|
+
this.pending.delete(message.id);
|
|
497
|
+
if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
|
|
498
|
+
else waiter.resolve(message.result);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (typeof message.method !== "string") return;
|
|
502
|
+
if (!this.buffers.has(message.method)) return;
|
|
503
|
+
const event = message.method;
|
|
504
|
+
const buffer = this.buffers.get(event);
|
|
505
|
+
if (!buffer) return;
|
|
506
|
+
buffer.push(message.params);
|
|
507
|
+
if (buffer.length > this.bufferSize) buffer.shift();
|
|
508
|
+
this.emitter.emit(event, message.params);
|
|
509
|
+
}
|
|
510
|
+
getBufferedEvents(event) {
|
|
511
|
+
return this.buffers.get(event) ?? [];
|
|
512
|
+
}
|
|
513
|
+
on(event, listener) {
|
|
514
|
+
this.emitter.on(event, listener);
|
|
515
|
+
return () => this.emitter.off(event, listener);
|
|
516
|
+
}
|
|
517
|
+
/** Close the local CDP websocket and reject any in-flight commands. */
|
|
518
|
+
close() {
|
|
519
|
+
this.ws?.close();
|
|
520
|
+
this.ws = null;
|
|
521
|
+
for (const waiter of this.pending.values()) waiter.reject(/* @__PURE__ */ new Error("Local Chromium CDP connection closed."));
|
|
522
|
+
this.pending.clear();
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
/** True for URLs that should be skipped when selecting a page target. */
|
|
526
|
+
function isBlankOrDevtoolsUrl(url) {
|
|
527
|
+
return url === "" || url === "about:blank" || url === "about:newtab" || url.startsWith("devtools://") || url.startsWith("chrome://") || url.startsWith("chrome-extension://");
|
|
528
|
+
}
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/mcp/local-launcher.ts
|
|
531
|
+
/**
|
|
532
|
+
* Chromium launcher for the local debug mode (env 1).
|
|
533
|
+
*
|
|
534
|
+
* Launch decision rationale:
|
|
535
|
+
* - `chrome-launcher` (npm) is purpose-built and finds installed Chrome, but
|
|
536
|
+
* adds a runtime dependency to the MCP bundle. The repo already has a clear
|
|
537
|
+
* "external dependency minimization" policy; `chrome-launcher` is not worth
|
|
538
|
+
* pulling in for what is essentially `spawn(chromeBin, [...flags])`.
|
|
539
|
+
* - Playwright is a devDependency used for E2E only — pulling `chromium.launch`
|
|
540
|
+
* into the runtime MCP path would add ~100 MB of bundled Chromium to the
|
|
541
|
+
* production install and break the "devDep = e2e only" boundary.
|
|
542
|
+
* - `child_process.spawn` with a platform-aware binary search is the lightest
|
|
543
|
+
* option: zero new dependencies, portable across macOS/Linux/Windows, and
|
|
544
|
+
* trivially testable by injecting a `spawnFn`.
|
|
545
|
+
*
|
|
546
|
+
* The launcher finds an installed Chrome/Chromium using a prioritized list of
|
|
547
|
+
* well-known binary paths per platform, then spawns it with:
|
|
548
|
+
* --remote-debugging-port=<port>
|
|
549
|
+
* --no-first-run
|
|
550
|
+
* --no-default-browser-check
|
|
551
|
+
* <devUrl>
|
|
552
|
+
*
|
|
553
|
+
* `pnpm dev` is started by the user; the MCP only launches the browser pointing
|
|
554
|
+
* at it.
|
|
555
|
+
*
|
|
556
|
+
* Node-only.
|
|
557
|
+
*/
|
|
558
|
+
/**
|
|
559
|
+
* Find an ephemeral free TCP port by briefly binding a server on port 0.
|
|
560
|
+
* Resolves with the OS-assigned port number.
|
|
561
|
+
*/
|
|
562
|
+
function findFreePort() {
|
|
563
|
+
return new Promise((resolve, reject) => {
|
|
564
|
+
const server = net.createServer();
|
|
565
|
+
server.listen(0, "127.0.0.1", () => {
|
|
566
|
+
const addr = server.address();
|
|
567
|
+
const port = typeof addr === "object" && addr !== null ? addr.port : null;
|
|
568
|
+
server.close(() => {
|
|
569
|
+
if (port === null) reject(/* @__PURE__ */ new Error("Failed to determine free port from net.Server."));
|
|
570
|
+
else resolve(port);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
server.once("error", reject);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Returns an ordered list of Chromium/Chrome binary paths to try for the
|
|
578
|
+
* current platform.
|
|
579
|
+
*/
|
|
580
|
+
function candidateChromePaths() {
|
|
581
|
+
const os = platform();
|
|
582
|
+
if (os === "darwin") return [
|
|
583
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
584
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
585
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
586
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
|
|
587
|
+
];
|
|
588
|
+
if (os === "linux") return [
|
|
589
|
+
"/usr/bin/google-chrome",
|
|
590
|
+
"/usr/bin/google-chrome-stable",
|
|
591
|
+
"/usr/bin/chromium",
|
|
592
|
+
"/usr/bin/chromium-browser",
|
|
593
|
+
"/usr/local/bin/google-chrome",
|
|
594
|
+
"/usr/local/bin/chromium",
|
|
595
|
+
"/snap/bin/chromium"
|
|
596
|
+
];
|
|
597
|
+
if (os === "win32") {
|
|
598
|
+
const programFiles = process.env.PROGRAMFILES ?? "C:\\Program Files";
|
|
599
|
+
const programFilesX86 = process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
|
|
600
|
+
return [
|
|
601
|
+
`${programFiles}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
602
|
+
`${programFilesX86}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
603
|
+
`${programFiles}\\Chromium\\Application\\chrome.exe`
|
|
604
|
+
];
|
|
605
|
+
}
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
/** Find the first Chrome/Chromium binary that exists on this machine. */
|
|
609
|
+
function findChromeBinary() {
|
|
610
|
+
for (const p of candidateChromePaths()) if (existsSync(p)) return p;
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Launch a local Chromium instance with CDP remote debugging enabled.
|
|
615
|
+
*
|
|
616
|
+
* The caller is responsible for calling `handle.stop()` when done.
|
|
617
|
+
*
|
|
618
|
+
* @throws if no Chrome/Chromium binary is found on the system.
|
|
619
|
+
*/
|
|
620
|
+
async function launchChromium(options = {}) {
|
|
621
|
+
const spawnImpl = options.spawnFn ?? spawn;
|
|
622
|
+
const requestedPort = options.port ?? 0;
|
|
623
|
+
const port = requestedPort === 0 ? await findFreePort() : requestedPort;
|
|
624
|
+
const devUrl = options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
|
|
625
|
+
const binary = findChromeBinary();
|
|
626
|
+
if (binary === null) throw new Error("No Chrome/Chromium binary found on this system. Install Google Chrome or Chromium and try again. Searched: " + candidateChromePaths().join(", "));
|
|
627
|
+
const child = spawnImpl(binary, [
|
|
628
|
+
`--remote-debugging-port=${port}`,
|
|
629
|
+
"--no-first-run",
|
|
630
|
+
"--no-default-browser-check",
|
|
631
|
+
"--user-data-dir=/tmp/ait-devtools-chromium-profile",
|
|
632
|
+
...options.extraArgs ?? [],
|
|
633
|
+
devUrl
|
|
634
|
+
], {
|
|
635
|
+
stdio: "ignore",
|
|
636
|
+
detached: false
|
|
637
|
+
});
|
|
638
|
+
child.unref();
|
|
639
|
+
const devtoolsUrl = `http://127.0.0.1:${port}`;
|
|
640
|
+
process.stderr.write(`[ait-local-debug] Launched Chromium: ${binary}\n[ait-local-debug] CDP endpoint: ${devtoolsUrl}\n[ait-local-debug] Opening: ${devUrl}\n`);
|
|
641
|
+
return {
|
|
642
|
+
port,
|
|
643
|
+
devtoolsUrl,
|
|
644
|
+
stop() {
|
|
645
|
+
try {
|
|
646
|
+
child.kill();
|
|
647
|
+
} catch {}
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
//#endregion
|
|
314
652
|
//#region src/mcp/deeplink.ts
|
|
653
|
+
/**
|
|
654
|
+
* Build a self-attaching dogfood deep link.
|
|
655
|
+
*
|
|
656
|
+
* `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`
|
|
657
|
+
* URL that opens a dogfood bundle on a phone. The in-app debug gate
|
|
658
|
+
* (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries
|
|
659
|
+
* `debug=1` and `relay=<wss-url>`. This helper splices those params (plus
|
|
660
|
+
* `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result
|
|
661
|
+
* as a QR code and scanning it with the phone camera opens the mini-app and
|
|
662
|
+
* attaches it to the live Chii relay. QR is the single entry path — it needs
|
|
663
|
+
* no USB cable, platform CLI, or driver, and works the same on iOS/Android.
|
|
664
|
+
*
|
|
665
|
+
* The Toss app propagates extra query params from the entry deep link into the
|
|
666
|
+
* mini-app WebView's `location.search` (confirmed behavior), so the gate reads
|
|
667
|
+
* them at attach time.
|
|
668
|
+
*
|
|
669
|
+
* TOTP `at=` param:
|
|
670
|
+
* When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional
|
|
671
|
+
* `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.
|
|
672
|
+
* The code must be computed by the caller at call time — do NOT pre-compute
|
|
673
|
+
* and cache it, because the 30-second window expires quickly. The in-app gate
|
|
674
|
+
* (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.
|
|
675
|
+
*
|
|
676
|
+
* Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.
|
|
677
|
+
* The WHATWG `URL` parser treats such schemes opaquely (no host/path/query
|
|
678
|
+
* decomposition you can rely on across runtimes), so query manipulation via
|
|
679
|
+
* `url.searchParams` is not portable here. We splice the query string directly
|
|
680
|
+
* on the raw string instead, which keeps the scheme, authority, path, and any
|
|
681
|
+
* pre-existing params (notably `_deploymentId`) byte-for-byte intact.
|
|
682
|
+
*/
|
|
683
|
+
/**
|
|
684
|
+
* Suspicious/generic authority values that indicate a malformed or placeholder
|
|
685
|
+
* scheme URL. These are host strings that will almost certainly cause the Toss
|
|
686
|
+
* app to fail with "bundle not found" silently.
|
|
687
|
+
*
|
|
688
|
+
* The expected form from `ait deploy --scheme-only` is:
|
|
689
|
+
* intoss-private://<appName>?_deploymentId=<uuid>
|
|
690
|
+
* where `<appName>` is a non-generic string like `aitc-sdk-example`.
|
|
691
|
+
*/
|
|
692
|
+
const SUSPICIOUS_AUTHORITIES = new Set([
|
|
693
|
+
"",
|
|
694
|
+
"web",
|
|
695
|
+
"localhost",
|
|
696
|
+
"127.0.0.1",
|
|
697
|
+
"app"
|
|
698
|
+
]);
|
|
699
|
+
/**
|
|
700
|
+
* Validates the authority (host) portion of a scheme URL.
|
|
701
|
+
*
|
|
702
|
+
* Returns a warning message if the authority is missing or looks like a
|
|
703
|
+
* placeholder, or `null` if the authority looks valid.
|
|
704
|
+
*
|
|
705
|
+
* Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`
|
|
706
|
+
* The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).
|
|
707
|
+
*/
|
|
708
|
+
function validateSchemeAuthority(schemeUrl) {
|
|
709
|
+
const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//, "");
|
|
710
|
+
if (afterScheme === schemeUrl) return "scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). Use the URL printed by `ait deploy --scheme-only`.";
|
|
711
|
+
const authorityEnd = afterScheme.search(/[/?#]/);
|
|
712
|
+
const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);
|
|
713
|
+
if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) return `scheme_url authority ${authority === "" ? "(empty)" : `"${authority}"`} looks like a placeholder. Expected an app name like \`intoss-private://aitc-sdk-example?_deploymentId=<uuid>\`. Use the URL printed by \`ait deploy --scheme-only\` — it includes the correct app name as the host.`;
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
315
716
|
function stripExisting(query, key) {
|
|
316
717
|
if (query === "") return "";
|
|
317
718
|
return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
|
|
@@ -392,17 +793,21 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
392
793
|
},
|
|
393
794
|
{
|
|
394
795
|
name: "build_attach_url",
|
|
395
|
-
description: "
|
|
796
|
+
description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Turns an `ait deploy --scheme-only` URL (intoss-private://…?_deploymentId=<uuid>) into a self-attaching deep link by splicing in debug=1 and the live relay URL for this session. Returns the deep link JSON and a unicode QR of that deep link. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Requires the tunnel to be up — call list_pages first. Set wait_for_attach=true to block until the phone scans and a page attaches (polls listTargets up to 90 s), then returns the attached page info too. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers).",
|
|
396
797
|
inputSchema: {
|
|
397
798
|
type: "object",
|
|
398
799
|
properties: {
|
|
399
800
|
scheme_url: {
|
|
400
801
|
type: "string",
|
|
401
|
-
description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId)."
|
|
802
|
+
description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). The authority (host) must be the app name (e.g. intoss-private://aitc-sdk-example?_deploymentId=…). Generic values like \"web\" or an empty host indicate a malformed URL."
|
|
402
803
|
},
|
|
403
804
|
wait_for_attach: {
|
|
404
805
|
type: "boolean",
|
|
405
806
|
description: "If true, block after returning the QR until a page attaches to the relay (polls listTargets ~1 s interval, timeout 90 s). On attach, the response includes the attached page list. On timeout, returns an error with a list_pages retry hint."
|
|
807
|
+
},
|
|
808
|
+
open_in_browser: {
|
|
809
|
+
type: "boolean",
|
|
810
|
+
description: "If true (default), render the QR as a PNG and open it in the OS default browser. Only works when the MCP server is running on a local GUI machine — headless or remote container environments should set this to false to use the text QR fallback."
|
|
406
811
|
}
|
|
407
812
|
},
|
|
408
813
|
required: ["scheme_url"]
|
|
@@ -444,6 +849,37 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
444
849
|
required: []
|
|
445
850
|
}
|
|
446
851
|
},
|
|
852
|
+
{
|
|
853
|
+
name: "evaluate",
|
|
854
|
+
description: "Evaluates an arbitrary JavaScript expression on the attached mini-app page via CDP Runtime.evaluate (returnByValue: true) and returns the result. NOT read-only — the expression can have side effects (DOM mutations, SDK calls, state changes). Requires the relay to be attached — call list_pages first. Throws if the evaluation throws an exception on the page.",
|
|
855
|
+
inputSchema: {
|
|
856
|
+
type: "object",
|
|
857
|
+
properties: { expression: {
|
|
858
|
+
type: "string",
|
|
859
|
+
description: "JavaScript expression to evaluate in the page context."
|
|
860
|
+
} },
|
|
861
|
+
required: ["expression"]
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
name: "call_sdk",
|
|
866
|
+
description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 2/3 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle).",
|
|
867
|
+
inputSchema: {
|
|
868
|
+
type: "object",
|
|
869
|
+
properties: {
|
|
870
|
+
name: {
|
|
871
|
+
type: "string",
|
|
872
|
+
description: "SDK method name to call (e.g. \"getOperationalEnvironment\")."
|
|
873
|
+
},
|
|
874
|
+
args: {
|
|
875
|
+
type: "array",
|
|
876
|
+
description: "Arguments to pass to the SDK method (optional, default []).",
|
|
877
|
+
items: {}
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
required: ["name"]
|
|
881
|
+
}
|
|
882
|
+
},
|
|
447
883
|
{
|
|
448
884
|
name: "AIT.getSdkCallHistory",
|
|
449
885
|
description: "Returns the recent Apps In Toss SDK call trace (method, args, result/error, timestamp) that raw CDP cannot observe. Read-only. Use to confirm an SDK call fired and how it resolved (e.g. a saveBase64Data permission regression).",
|
|
@@ -476,6 +912,16 @@ const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
|
|
|
476
912
|
function isDebugToolName(name) {
|
|
477
913
|
return DEBUG_TOOL_NAMES.has(name);
|
|
478
914
|
}
|
|
915
|
+
/**
|
|
916
|
+
* Tool names that are available before any page attaches (bootstrap tier).
|
|
917
|
+
*
|
|
918
|
+
* `build_attach_url` — pure URL synthesis, no attach needed.
|
|
919
|
+
* `list_pages` — reports tunnel status + empty pages even pre-attach.
|
|
920
|
+
*
|
|
921
|
+
* All other tools require an attached page (`enableDomains` must succeed) and
|
|
922
|
+
* are only advertised in `tools/list` once a target appears.
|
|
923
|
+
*/
|
|
924
|
+
const BOOTSTRAP_TOOL_NAMES = new Set(["build_attach_url", "list_pages"]);
|
|
479
925
|
/** Renders a CDP `RemoteObject` console arg to a stable display string. */
|
|
480
926
|
function renderRemoteObject(arg) {
|
|
481
927
|
if (arg.value !== void 0) {
|
|
@@ -530,12 +976,135 @@ function listPages(connection, tunnel) {
|
|
|
530
976
|
* Builds a self-attaching dogfood deep link from an `ait deploy --scheme-only`
|
|
531
977
|
* URL plus this session's live relay. Throws if the tunnel is not up yet (no
|
|
532
978
|
* relay URL to splice in) — the caller surfaces that as a tool error.
|
|
979
|
+
*
|
|
980
|
+
* Also validates the scheme URL's authority. A suspicious authority (empty,
|
|
981
|
+
* "web", "localhost", etc.) is surfaced as a non-fatal `authorityWarning` on
|
|
982
|
+
* the result so the caller can show a helpful hint without blocking the link
|
|
983
|
+
* generation (the warning is consistent with how other validation in
|
|
984
|
+
* `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for
|
|
985
|
+
* the scheme authority which is in the caller's input, not ours to own).
|
|
533
986
|
*/
|
|
534
987
|
function buildAttachUrl(schemeUrl, tunnel) {
|
|
535
988
|
if (!tunnel.up || tunnel.wssUrl === null) throw new Error("No relay URL yet — the cloudflared quick tunnel is not up. Call list_pages to check tunnel status.");
|
|
989
|
+
const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
|
|
536
990
|
return {
|
|
537
991
|
attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
|
|
538
|
-
relayUrl: tunnel.wssUrl
|
|
992
|
+
relayUrl: tunnel.wssUrl,
|
|
993
|
+
...authorityWarning !== void 0 ? { authorityWarning } : {}
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Heuristic: can this process open a GUI browser?
|
|
998
|
+
*
|
|
999
|
+
* Returns `true` when we think a GUI is available:
|
|
1000
|
+
* - On macOS (`darwin`) we assume yes (MCP normally runs on the user's Mac).
|
|
1001
|
+
* - On Linux we check for `DISPLAY` or `WAYLAND_DISPLAY`.
|
|
1002
|
+
* - On Windows we assume yes.
|
|
1003
|
+
* - In a CI environment (`CI=true`) we assume no.
|
|
1004
|
+
*/
|
|
1005
|
+
function canOpenBrowser() {
|
|
1006
|
+
if (process.env.CI === "true" || process.env.CI === "1") return false;
|
|
1007
|
+
const platform = process.platform;
|
|
1008
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
1009
|
+
if (platform === "linux") return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);
|
|
1010
|
+
return false;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Writes the attach URL as a QR PNG + a wrapper HTML page to the OS temp
|
|
1014
|
+
* directory, then opens the HTML in the OS default browser.
|
|
1015
|
+
*
|
|
1016
|
+
* SECRET-HANDLING:
|
|
1017
|
+
* - File names are derived from a short timestamp, NOT from the attach URL or
|
|
1018
|
+
* any token/code value. The `at=` code is NOT in the file name.
|
|
1019
|
+
* - The attach URL (which may carry `at=`) is embedded inside the HTML page
|
|
1020
|
+
* body — that is the intended delivery channel for the QR.
|
|
1021
|
+
* - This function must NOT write the attach URL, deploymentId, or any
|
|
1022
|
+
* TOTP code to stdout, stderr, or any log.
|
|
1023
|
+
*
|
|
1024
|
+
* @param attachUrl - The deep link to encode as a QR. May contain `at=<code>`.
|
|
1025
|
+
* @param deploymentId - Optional human-readable label for the HTML page (e.g. UUID substring).
|
|
1026
|
+
* Must NOT be derived from the `at=` code value.
|
|
1027
|
+
* @returns `OpenQrInBrowserResult` — never throws (errors are returned in `.error`).
|
|
1028
|
+
*/
|
|
1029
|
+
async function openQrInBrowser(attachUrl, deploymentId) {
|
|
1030
|
+
const { tmpdir } = await import("node:os");
|
|
1031
|
+
const { writeFileSync } = await import("node:fs");
|
|
1032
|
+
const { join } = await import("node:path");
|
|
1033
|
+
const { spawnSync } = await import("node:child_process");
|
|
1034
|
+
const { default: QRCode } = await import("qrcode");
|
|
1035
|
+
const stamp = Date.now();
|
|
1036
|
+
const pngPath = join(tmpdir(), `ait-qr-${stamp}.png`);
|
|
1037
|
+
const htmlPath = join(tmpdir(), `ait-qr-${stamp}.html`);
|
|
1038
|
+
try {
|
|
1039
|
+
await QRCode.toFile(pngPath, attachUrl, {
|
|
1040
|
+
type: "png",
|
|
1041
|
+
errorCorrectionLevel: "M"
|
|
1042
|
+
});
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
return {
|
|
1045
|
+
opened: false,
|
|
1046
|
+
htmlPath,
|
|
1047
|
+
pngPath,
|
|
1048
|
+
error: `QR PNG write failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
const htmlContent = `<!DOCTYPE html>
|
|
1052
|
+
<html lang="ko">
|
|
1053
|
+
<head>
|
|
1054
|
+
<meta charset="utf-8" />
|
|
1055
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1056
|
+
<title>AIT Debug — QR</title>
|
|
1057
|
+
<style>
|
|
1058
|
+
body { font-family: monospace; background: #111; color: #eee; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; gap: 1.5rem; padding: 2rem; box-sizing: border-box; }
|
|
1059
|
+
img { width: min(90vw, 400px); height: auto; image-rendering: pixelated; background: #fff; padding: 1rem; border-radius: 8px; }
|
|
1060
|
+
.label { font-size: 0.85rem; opacity: 0.6; }
|
|
1061
|
+
.url { font-size: 0.75rem; word-break: break-all; max-width: 60ch; opacity: 0.5; }
|
|
1062
|
+
</style>
|
|
1063
|
+
</head>
|
|
1064
|
+
<body>
|
|
1065
|
+
<img src="${pngPath}" alt="QR code" />
|
|
1066
|
+
<p class="label">deployment: ${deploymentId ? deploymentId.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`) : "attach"}</p>
|
|
1067
|
+
</body>
|
|
1068
|
+
</html>`;
|
|
1069
|
+
try {
|
|
1070
|
+
writeFileSync(htmlPath, htmlContent, "utf8");
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
return {
|
|
1073
|
+
opened: false,
|
|
1074
|
+
htmlPath,
|
|
1075
|
+
pngPath,
|
|
1076
|
+
error: `HTML write failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
const platform = process.platform;
|
|
1080
|
+
let openCmd;
|
|
1081
|
+
let openArgs;
|
|
1082
|
+
if (platform === "darwin") {
|
|
1083
|
+
openCmd = "open";
|
|
1084
|
+
openArgs = [htmlPath];
|
|
1085
|
+
} else if (platform === "win32") {
|
|
1086
|
+
openCmd = "cmd";
|
|
1087
|
+
openArgs = [
|
|
1088
|
+
"/c",
|
|
1089
|
+
"start",
|
|
1090
|
+
"",
|
|
1091
|
+
htmlPath
|
|
1092
|
+
];
|
|
1093
|
+
} else {
|
|
1094
|
+
openCmd = "xdg-open";
|
|
1095
|
+
openArgs = [htmlPath];
|
|
1096
|
+
}
|
|
1097
|
+
const spawnResult = spawnSync(openCmd, openArgs, { timeout: 5e3 });
|
|
1098
|
+
if (spawnResult.error) return {
|
|
1099
|
+
opened: false,
|
|
1100
|
+
htmlPath,
|
|
1101
|
+
pngPath,
|
|
1102
|
+
error: `Browser open failed (${openCmd}): ${spawnResult.error.message}`
|
|
1103
|
+
};
|
|
1104
|
+
return {
|
|
1105
|
+
opened: true,
|
|
1106
|
+
htmlPath,
|
|
1107
|
+
pngPath
|
|
539
1108
|
};
|
|
540
1109
|
}
|
|
541
1110
|
/** Returns the DOM tree of the attached page (`DOM.getDocument`). */
|
|
@@ -558,7 +1127,21 @@ async function takeScreenshot(connection) {
|
|
|
558
1127
|
mimeType: "image/png"
|
|
559
1128
|
};
|
|
560
1129
|
}
|
|
561
|
-
|
|
1130
|
+
/**
|
|
1131
|
+
* The JS probe injected via `Runtime.evaluate`. It reads:
|
|
1132
|
+
* 1. `env(safe-area-inset-*)` via a temporary element with padding set to
|
|
1133
|
+
* those CSS env vars, then `getComputedStyle`.
|
|
1134
|
+
* 2. `SafeAreaInsets.get()` if the native SDK object is available.
|
|
1135
|
+
* 3. nav bar geometry (first `.ait-navbar` element height, if present).
|
|
1136
|
+
* 4. `innerWidth`, `innerHeight`, `devicePixelRatio`, `navigator.userAgent`.
|
|
1137
|
+
*
|
|
1138
|
+
* Returns a plain JSON-serialisable object so `returnByValue: true` works.
|
|
1139
|
+
*
|
|
1140
|
+
* NOTE: This expression is evaluated in the page context on the real device.
|
|
1141
|
+
* It does not mutate any page state — the temporary element is removed after
|
|
1142
|
+
* reading. No secret or auth token is read or returned.
|
|
1143
|
+
*/
|
|
1144
|
+
const SAFE_AREA_PROBE_EXPRESSION = `
|
|
562
1145
|
(function() {
|
|
563
1146
|
var el = document.createElement('div');
|
|
564
1147
|
el.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;visibility:hidden;' +
|
|
@@ -597,6 +1180,157 @@ async function takeScreenshot(connection) {
|
|
|
597
1180
|
});
|
|
598
1181
|
})()
|
|
599
1182
|
`.trim();
|
|
1183
|
+
/**
|
|
1184
|
+
* Parses a raw `Runtime.evaluate` result value into a `SafeAreaMeasurement`.
|
|
1185
|
+
* The probe returns a JSON string (because `returnByValue:true` with a plain
|
|
1186
|
+
* object works unreliably across Chii relay versions — stringifying is safer).
|
|
1187
|
+
*
|
|
1188
|
+
* Throws if the result is missing, contains an exception, or cannot be parsed.
|
|
1189
|
+
*/
|
|
1190
|
+
function normalizeSafeAreaResult(rawValue) {
|
|
1191
|
+
if (typeof rawValue !== "string") throw new Error(`measure_safe_area: probe returned unexpected type "${typeof rawValue}" — expected JSON string`);
|
|
1192
|
+
let parsed;
|
|
1193
|
+
try {
|
|
1194
|
+
parsed = JSON.parse(rawValue);
|
|
1195
|
+
} catch {
|
|
1196
|
+
throw new Error(`measure_safe_area: probe returned non-JSON string: ${rawValue}`);
|
|
1197
|
+
}
|
|
1198
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("measure_safe_area: parsed result is not an object");
|
|
1199
|
+
const obj = parsed;
|
|
1200
|
+
function requireInsets(key) {
|
|
1201
|
+
const v = obj[key];
|
|
1202
|
+
if (v === null || v === void 0) return null;
|
|
1203
|
+
if (typeof v !== "object") return null;
|
|
1204
|
+
const r = v;
|
|
1205
|
+
return {
|
|
1206
|
+
top: typeof r.top === "number" ? r.top : 0,
|
|
1207
|
+
right: typeof r.right === "number" ? r.right : 0,
|
|
1208
|
+
bottom: typeof r.bottom === "number" ? r.bottom : 0,
|
|
1209
|
+
left: typeof r.left === "number" ? r.left : 0
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
return {
|
|
1213
|
+
cssEnv: requireInsets("cssEnv") ?? {
|
|
1214
|
+
top: 0,
|
|
1215
|
+
right: 0,
|
|
1216
|
+
bottom: 0,
|
|
1217
|
+
left: 0
|
|
1218
|
+
},
|
|
1219
|
+
sdkInsets: requireInsets("sdkInsets"),
|
|
1220
|
+
navBarHeight: typeof obj.navBarHeight === "number" ? obj.navBarHeight : null,
|
|
1221
|
+
innerWidth: typeof obj.innerWidth === "number" ? obj.innerWidth : 0,
|
|
1222
|
+
innerHeight: typeof obj.innerHeight === "number" ? obj.innerHeight : 0,
|
|
1223
|
+
devicePixelRatio: typeof obj.devicePixelRatio === "number" ? obj.devicePixelRatio : 1,
|
|
1224
|
+
userAgent: typeof obj.userAgent === "string" ? obj.userAgent : ""
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Runs the safe-area probe on the attached page and returns a normalized
|
|
1229
|
+
* `SafeAreaMeasurement`. Read-only — does not mutate page state.
|
|
1230
|
+
*
|
|
1231
|
+
* Throws on CDP error, probe exception, or result parse failure.
|
|
1232
|
+
*/
|
|
1233
|
+
async function measureSafeArea(connection) {
|
|
1234
|
+
const result = await connection.send("Runtime.evaluate", {
|
|
1235
|
+
expression: SAFE_AREA_PROBE_EXPRESSION,
|
|
1236
|
+
returnByValue: true,
|
|
1237
|
+
awaitPromise: false
|
|
1238
|
+
});
|
|
1239
|
+
if (result.exceptionDetails) {
|
|
1240
|
+
const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
1241
|
+
throw new Error(`measure_safe_area: probe threw — ${msg}`);
|
|
1242
|
+
}
|
|
1243
|
+
return normalizeSafeAreaResult(result.result.value);
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Evaluates an arbitrary JS expression on the attached page via
|
|
1247
|
+
* `Runtime.evaluate`. NOT read-only — the expression may have side effects.
|
|
1248
|
+
*
|
|
1249
|
+
* Throws if the evaluation produced a CDP exception.
|
|
1250
|
+
*
|
|
1251
|
+
* SECRET-HANDLING: expression and result value are NOT written to any log.
|
|
1252
|
+
*/
|
|
1253
|
+
async function evaluate(connection, expression) {
|
|
1254
|
+
const result = await connection.send("Runtime.evaluate", {
|
|
1255
|
+
expression,
|
|
1256
|
+
returnByValue: true,
|
|
1257
|
+
awaitPromise: false
|
|
1258
|
+
});
|
|
1259
|
+
if (result.exceptionDetails) {
|
|
1260
|
+
const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
1261
|
+
throw new Error(`evaluate failed: ${msg}`);
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
value: result.result.value,
|
|
1265
|
+
type: result.result.type
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Builds the Runtime.evaluate expression that calls `window.__sdkCall` with
|
|
1270
|
+
* the given method name and args, awaits the promise, and returns a JSON
|
|
1271
|
+
* envelope `{ok, value/error}` as a string.
|
|
1272
|
+
*
|
|
1273
|
+
* Name and args are embedded via `JSON.stringify` so they are safely escaped.
|
|
1274
|
+
* The expression checks for `window.__sdkCall` and returns a clear error if
|
|
1275
|
+
* it is absent (non-dogfood bundle).
|
|
1276
|
+
*
|
|
1277
|
+
* SECRET-HANDLING: the expression is built here and MUST NOT be written to
|
|
1278
|
+
* any log or stderr by the caller.
|
|
1279
|
+
*/
|
|
1280
|
+
function buildCallSdkExpression(name, args) {
|
|
1281
|
+
return `(async () => { if (typeof window.__sdkCall !== 'function') { return JSON.stringify({ok:false,error:'window.__sdkCall is not available — is this a dogfood (__DEBUG_BUILD__) bundle?'}); } try { const r = await window.__sdkCall(${JSON.stringify(name)}, ...${JSON.stringify(args)}); return JSON.stringify({ok:true,value:r}); } catch(e) { return JSON.stringify({ok:false,error:String(e && e.message || e)}); }})()`;
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Parses the JSON envelope string returned by the `call_sdk` expression.
|
|
1285
|
+
* Returns a typed `CallSdkResult`.
|
|
1286
|
+
*
|
|
1287
|
+
* Throws only on parse failure (not on ok:false — that is a normal result).
|
|
1288
|
+
*/
|
|
1289
|
+
function normalizeCallSdkResult(rawValue) {
|
|
1290
|
+
if (typeof rawValue !== "string") throw new Error(`call_sdk: bridge returned unexpected type "${typeof rawValue}" — expected JSON string`);
|
|
1291
|
+
let parsed;
|
|
1292
|
+
try {
|
|
1293
|
+
parsed = JSON.parse(rawValue);
|
|
1294
|
+
} catch {
|
|
1295
|
+
throw new Error("call_sdk: bridge returned non-JSON string");
|
|
1296
|
+
}
|
|
1297
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("call_sdk: parsed result is not an object");
|
|
1298
|
+
const obj = parsed;
|
|
1299
|
+
if (obj.ok === true) return {
|
|
1300
|
+
ok: true,
|
|
1301
|
+
value: obj.value
|
|
1302
|
+
};
|
|
1303
|
+
if (obj.ok === false) return {
|
|
1304
|
+
ok: false,
|
|
1305
|
+
error: typeof obj.error === "string" ? obj.error : String(obj.error)
|
|
1306
|
+
};
|
|
1307
|
+
throw new Error("call_sdk: bridge result missing \"ok\" field");
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
|
|
1311
|
+
* NOT read-only — SDK calls may have side effects.
|
|
1312
|
+
*
|
|
1313
|
+
* On env 2/3 (real device relay) this hits the real SDK; on env 1 (local
|
|
1314
|
+
* mock) it hits the mock SDK.
|
|
1315
|
+
*
|
|
1316
|
+
* Throws on CDP error or result parse failure. Returns `{ok:false, error}`
|
|
1317
|
+
* for bridge-level errors (method not found, SDK threw, bridge absent).
|
|
1318
|
+
*
|
|
1319
|
+
* SECRET-HANDLING: name, args, and the result value are NOT written to any log.
|
|
1320
|
+
*/
|
|
1321
|
+
async function callSdk(connection, name, args) {
|
|
1322
|
+
const expression = buildCallSdkExpression(name, args);
|
|
1323
|
+
const result = await connection.send("Runtime.evaluate", {
|
|
1324
|
+
expression,
|
|
1325
|
+
returnByValue: true,
|
|
1326
|
+
awaitPromise: true
|
|
1327
|
+
});
|
|
1328
|
+
if (result.exceptionDetails) {
|
|
1329
|
+
const msg = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Runtime.evaluate threw an exception";
|
|
1330
|
+
throw new Error(`call_sdk threw: ${msg}`);
|
|
1331
|
+
}
|
|
1332
|
+
return normalizeCallSdkResult(result.result.value);
|
|
1333
|
+
}
|
|
600
1334
|
/** Set of tool names served by the AIT source rather than the CDP connection. */
|
|
601
1335
|
const AIT_TOOL_NAMES = new Set([
|
|
602
1336
|
"AIT.getSdkCallHistory",
|
|
@@ -862,20 +1596,41 @@ async function printAttachBanner(input) {
|
|
|
862
1596
|
* wires the live pieces (relay + tunnel + production connection); the phone
|
|
863
1597
|
* roundtrip is fully wired and pending only on-device acceptance.
|
|
864
1598
|
*
|
|
1599
|
+
* Dynamic tool registration (issue #208):
|
|
1600
|
+
* The server advertises `listChanged: true` so MCP clients can subscribe to
|
|
1601
|
+
* `notifications/tools/list_changed`. Before any page attaches, only bootstrap
|
|
1602
|
+
* tools (`build_attach_url`, `list_pages`) are listed. Once a target appears,
|
|
1603
|
+
* the full attach-dependent tool set is added and a `list_changed` notification
|
|
1604
|
+
* is sent — without requiring a session restart. `runDebugServer` and
|
|
1605
|
+
* `runLocalDebugServer` start a polling watcher that detects the 0→N target
|
|
1606
|
+
* transition and calls `server.sendToolListChanged()`.
|
|
1607
|
+
*
|
|
1608
|
+
* Note: `src/mcp/server.ts` (dev mode, HTTP mock-state) is NOT subject to this
|
|
1609
|
+
* model — it has no attach concept and always exposes the full tool surface.
|
|
1610
|
+
*
|
|
865
1611
|
* Node-only.
|
|
866
1612
|
*/
|
|
867
1613
|
/**
|
|
868
1614
|
* Builds the debug-mode MCP server around an injected CDP connection + AIT
|
|
869
1615
|
* source + tunnel status getter. Pure wiring — does not start a relay or
|
|
870
1616
|
* tunnel, which is what makes the tool surface unit-testable.
|
|
1617
|
+
*
|
|
1618
|
+
* `tools/list` is two-tiered (issue #208):
|
|
1619
|
+
* - bootstrap (always): `build_attach_url`, `list_pages`
|
|
1620
|
+
* - attach-dependent (after `connection.listTargets().length > 0`): all others
|
|
1621
|
+
*
|
|
1622
|
+
* `CallTool` is NOT tiered — hidden tools still execute (attach errors surface
|
|
1623
|
+
* naturally via `enableDomains`). The tier only controls visibility.
|
|
871
1624
|
*/
|
|
872
1625
|
function createDebugServer(deps) {
|
|
873
1626
|
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4 } = deps;
|
|
874
1627
|
const server = new Server({
|
|
875
1628
|
name: "ait-debug",
|
|
876
|
-
version: "0.1.
|
|
877
|
-
}, { capabilities: { tools: {} } });
|
|
878
|
-
server.setRequestHandler(ListToolsRequestSchema, () =>
|
|
1629
|
+
version: "0.1.36"
|
|
1630
|
+
}, { capabilities: { tools: { listChanged: true } } });
|
|
1631
|
+
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
1632
|
+
return { tools: connection.listTargets().length > 0 ? DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) : DEBUG_TOOL_DEFINITIONS.filter((tool) => BOOTSTRAP_TOOL_NAMES.has(tool.name)).map((tool) => ({ ...tool })) };
|
|
1633
|
+
});
|
|
879
1634
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
880
1635
|
const name = request.params.name;
|
|
881
1636
|
if (!isDebugToolName(name)) return {
|
|
@@ -906,10 +1661,80 @@ function createDebugServer(deps) {
|
|
|
906
1661
|
isError: true
|
|
907
1662
|
};
|
|
908
1663
|
const waitForAttach = request.params.arguments?.wait_for_attach === true;
|
|
1664
|
+
const openInBrowser = request.params.arguments?.open_in_browser !== false;
|
|
909
1665
|
try {
|
|
910
|
-
const { attachUrl, relayUrl } = buildAttachUrl(schemeUrl, getTunnelStatus());
|
|
1666
|
+
const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
|
|
1667
|
+
const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
|
|
1668
|
+
const header = "This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).";
|
|
1669
|
+
if (openInBrowser && canOpenBrowser()) {
|
|
1670
|
+
let deploymentIdLabel;
|
|
1671
|
+
try {
|
|
1672
|
+
const dpMatch = attachUrl.match(/[?&]_deploymentId=([^&]+)/);
|
|
1673
|
+
if (dpMatch?.[1]) deploymentIdLabel = decodeURIComponent(dpMatch[1]).slice(0, 36);
|
|
1674
|
+
} catch {}
|
|
1675
|
+
const browserResult = await openQrInBrowser(attachUrl, deploymentIdLabel);
|
|
1676
|
+
if (browserResult.opened) {
|
|
1677
|
+
const shortText = `${warningPrefix}${header}\n${JSON.stringify({ relayUrl }, null, 2)}\n\nブラウザにQRを表示しました。\nQR画像: ${browserResult.pngPath}\nHTMLページ: ${browserResult.htmlPath}\n\n브라우저에 QR을 띄웠습니다. 스마트폰 카메라로 스캔하세요.\nPNG: ${browserResult.pngPath}`;
|
|
1678
|
+
if (!waitForAttach) return { content: [{
|
|
1679
|
+
type: "text",
|
|
1680
|
+
text: shortText
|
|
1681
|
+
}] };
|
|
1682
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
1683
|
+
const TIMEOUT_MS = waitForAttachTimeoutMs;
|
|
1684
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
1685
|
+
let attachedPages = [];
|
|
1686
|
+
while (Date.now() < deadline) {
|
|
1687
|
+
attachedPages = connection.listTargets();
|
|
1688
|
+
if (attachedPages.length > 0) break;
|
|
1689
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
1690
|
+
}
|
|
1691
|
+
if (attachedPages.length === 0) return {
|
|
1692
|
+
content: [{
|
|
1693
|
+
type: "text",
|
|
1694
|
+
text: `${shortText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
|
|
1695
|
+
}],
|
|
1696
|
+
isError: true
|
|
1697
|
+
};
|
|
1698
|
+
const pagesResult = listPages(connection, getTunnelStatus());
|
|
1699
|
+
return { content: [{
|
|
1700
|
+
type: "text",
|
|
1701
|
+
text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
1702
|
+
}] };
|
|
1703
|
+
}
|
|
1704
|
+
const fallbackNote = `(브라우저 열기 실패: ${browserResult.error ?? "unknown"} — 텍스트 QR로 대체)\n`;
|
|
1705
|
+
const qr = await renderQr(attachUrl);
|
|
1706
|
+
const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
|
|
1707
|
+
attachUrl,
|
|
1708
|
+
relayUrl
|
|
1709
|
+
}, null, 2)}\n\n${qr}`;
|
|
1710
|
+
if (!waitForAttach) return { content: [{
|
|
1711
|
+
type: "text",
|
|
1712
|
+
text: baseText
|
|
1713
|
+
}] };
|
|
1714
|
+
const POLL_INTERVAL_MS_FB = 1e3;
|
|
1715
|
+
const TIMEOUT_MS_FB = waitForAttachTimeoutMs;
|
|
1716
|
+
const deadline2 = Date.now() + TIMEOUT_MS_FB;
|
|
1717
|
+
let attachedPagesFb = [];
|
|
1718
|
+
while (Date.now() < deadline2) {
|
|
1719
|
+
attachedPagesFb = connection.listTargets();
|
|
1720
|
+
if (attachedPagesFb.length > 0) break;
|
|
1721
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS_FB));
|
|
1722
|
+
}
|
|
1723
|
+
if (attachedPagesFb.length === 0) return {
|
|
1724
|
+
content: [{
|
|
1725
|
+
type: "text",
|
|
1726
|
+
text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS_FB / 1e3}s — call list_pages to retry.`
|
|
1727
|
+
}],
|
|
1728
|
+
isError: true
|
|
1729
|
+
};
|
|
1730
|
+
const pagesResultFb = listPages(connection, getTunnelStatus());
|
|
1731
|
+
return { content: [{
|
|
1732
|
+
type: "text",
|
|
1733
|
+
text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
|
|
1734
|
+
}] };
|
|
1735
|
+
}
|
|
911
1736
|
const qr = await renderQr(attachUrl);
|
|
912
|
-
const baseText =
|
|
1737
|
+
const baseText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
913
1738
|
attachUrl,
|
|
914
1739
|
relayUrl
|
|
915
1740
|
}, null, 2)}\n\n${qr}`;
|
|
@@ -970,6 +1795,30 @@ function createDebugServer(deps) {
|
|
|
970
1795
|
mimeType: shot.mimeType
|
|
971
1796
|
}] };
|
|
972
1797
|
}
|
|
1798
|
+
case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection));
|
|
1799
|
+
case "evaluate": {
|
|
1800
|
+
const expression = request.params.arguments?.expression;
|
|
1801
|
+
if (typeof expression !== "string" || expression === "") return {
|
|
1802
|
+
content: [{
|
|
1803
|
+
type: "text",
|
|
1804
|
+
text: "evaluate requires a non-empty expression."
|
|
1805
|
+
}],
|
|
1806
|
+
isError: true
|
|
1807
|
+
};
|
|
1808
|
+
return jsonResult$1(await evaluate(connection, expression));
|
|
1809
|
+
}
|
|
1810
|
+
case "call_sdk": {
|
|
1811
|
+
const sdkName = request.params.arguments?.name;
|
|
1812
|
+
if (typeof sdkName !== "string" || sdkName === "") return {
|
|
1813
|
+
content: [{
|
|
1814
|
+
type: "text",
|
|
1815
|
+
text: "call_sdk requires a non-empty name."
|
|
1816
|
+
}],
|
|
1817
|
+
isError: true
|
|
1818
|
+
};
|
|
1819
|
+
const rawArgs = request.params.arguments?.args;
|
|
1820
|
+
return jsonResult$1(await callSdk(connection, sdkName, Array.isArray(rawArgs) ? rawArgs : []));
|
|
1821
|
+
}
|
|
973
1822
|
default: return unknownTool(name);
|
|
974
1823
|
}
|
|
975
1824
|
} catch (err) {
|
|
@@ -1003,6 +1852,35 @@ function errorResult(err, name) {
|
|
|
1003
1852
|
};
|
|
1004
1853
|
}
|
|
1005
1854
|
/**
|
|
1855
|
+
* Starts a polling watcher that detects the first 0→N target transition on
|
|
1856
|
+
* `connection.listTargets()` and sends a `notifications/tools/list_changed`
|
|
1857
|
+
* notification on the given server.
|
|
1858
|
+
*
|
|
1859
|
+
* The watcher polls every `intervalMs` (default 1 000 ms). It fires
|
|
1860
|
+
* `server.sendToolListChanged()` exactly once — on the first transition — then
|
|
1861
|
+
* clears itself. Shutdown calls `stop()` to clear the interval.
|
|
1862
|
+
*
|
|
1863
|
+
* SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
|
|
1864
|
+
* Only an attach-detected stderr line is emitted (no target details).
|
|
1865
|
+
*
|
|
1866
|
+
* @returns `stop` — call this during shutdown to clear the interval.
|
|
1867
|
+
*/
|
|
1868
|
+
function startAttachWatcher(connection, server, intervalMs = 1e3) {
|
|
1869
|
+
let wasAttached = connection.listTargets().length > 0;
|
|
1870
|
+
if (wasAttached) server.sendToolListChanged();
|
|
1871
|
+
const handle = setInterval(() => {
|
|
1872
|
+
const isAttached = connection.listTargets().length > 0;
|
|
1873
|
+
if (!wasAttached && isAttached) {
|
|
1874
|
+
wasAttached = true;
|
|
1875
|
+
server.sendToolListChanged();
|
|
1876
|
+
clearInterval(handle);
|
|
1877
|
+
}
|
|
1878
|
+
}, intervalMs);
|
|
1879
|
+
return { stop() {
|
|
1880
|
+
clearInterval(handle);
|
|
1881
|
+
} };
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1006
1884
|
* Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
|
|
1007
1885
|
* `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
|
|
1008
1886
|
*
|
|
@@ -1070,9 +1948,11 @@ async function runDebugServer(options = {}) {
|
|
|
1070
1948
|
});
|
|
1071
1949
|
const transport = new StdioServerTransport();
|
|
1072
1950
|
let closed = false;
|
|
1951
|
+
let attachWatcher = null;
|
|
1073
1952
|
const shutdown = () => {
|
|
1074
1953
|
if (closed) return;
|
|
1075
1954
|
closed = true;
|
|
1955
|
+
attachWatcher?.stop();
|
|
1076
1956
|
connection.close();
|
|
1077
1957
|
tunnel?.stop();
|
|
1078
1958
|
relay.close();
|
|
@@ -1084,6 +1964,7 @@ async function runDebugServer(options = {}) {
|
|
|
1084
1964
|
process.on("exit", () => {
|
|
1085
1965
|
if (!closed) {
|
|
1086
1966
|
closed = true;
|
|
1967
|
+
attachWatcher?.stop();
|
|
1087
1968
|
tunnel?.stop();
|
|
1088
1969
|
}
|
|
1089
1970
|
});
|
|
@@ -1098,6 +1979,76 @@ async function runDebugServer(options = {}) {
|
|
|
1098
1979
|
process.exit(1);
|
|
1099
1980
|
});
|
|
1100
1981
|
await server.connect(transport);
|
|
1982
|
+
attachWatcher = startAttachWatcher(connection, server);
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Boots the local-browser debug stack and serves it over stdio:
|
|
1986
|
+
* 1. launch a local Chromium with `--remote-debugging-port=<port>`,
|
|
1987
|
+
* 2. attach a `LocalCdpConnection` to the first non-blank page target,
|
|
1988
|
+
* 3. expose the debug tools backed by that connection + a `ChiiAitSource`.
|
|
1989
|
+
*
|
|
1990
|
+
* `build_attach_url` (relay-specific, generates a deep-link + QR for the phone)
|
|
1991
|
+
* is not applicable in local mode because there is no relay or tunnel. The tool
|
|
1992
|
+
* is still listed (it is part of `DEBUG_TOOL_DEFINITIONS`) but will return a
|
|
1993
|
+
* clear "not applicable" message via the tunnel-down path (wssUrl is null).
|
|
1994
|
+
*
|
|
1995
|
+
* The AIT.* tools (`AIT.getSdkCallHistory`, `AIT.getMockState`,
|
|
1996
|
+
* `AIT.getOperationalEnvironment`) ride the same CDP channel via
|
|
1997
|
+
* `ChiiAitSource` → `LocalCdpConnection.sendCommand`. They will succeed once
|
|
1998
|
+
* the sdk-example dev-bridge (`window.__sdkCall` install) lands in sdk-example;
|
|
1999
|
+
* until then they return the sdk-example "bridge absent" message — which is
|
|
2000
|
+
* expected and noted in the PR as an explicit out-of-scope follow-up.
|
|
2001
|
+
*/
|
|
2002
|
+
async function runLocalDebugServer(options = {}) {
|
|
2003
|
+
const chromium = await launchChromium({
|
|
2004
|
+
port: options.cdpPort ?? 0,
|
|
2005
|
+
devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
|
|
2006
|
+
});
|
|
2007
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
2008
|
+
const connection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
|
|
2009
|
+
const aitSource = new ChiiAitSource(connection);
|
|
2010
|
+
const tunnelStatus = {
|
|
2011
|
+
up: false,
|
|
2012
|
+
wssUrl: null
|
|
2013
|
+
};
|
|
2014
|
+
const server = createDebugServer({
|
|
2015
|
+
connection,
|
|
2016
|
+
aitSource,
|
|
2017
|
+
getTunnelStatus: () => tunnelStatus
|
|
2018
|
+
});
|
|
2019
|
+
const transport = new StdioServerTransport();
|
|
2020
|
+
let closed = false;
|
|
2021
|
+
let attachWatcher = null;
|
|
2022
|
+
const shutdown = () => {
|
|
2023
|
+
if (closed) return;
|
|
2024
|
+
closed = true;
|
|
2025
|
+
attachWatcher?.stop();
|
|
2026
|
+
connection.close();
|
|
2027
|
+
chromium.stop();
|
|
2028
|
+
server.close();
|
|
2029
|
+
};
|
|
2030
|
+
process.once("SIGINT", shutdown);
|
|
2031
|
+
process.once("SIGTERM", shutdown);
|
|
2032
|
+
process.once("SIGHUP", shutdown);
|
|
2033
|
+
process.on("exit", () => {
|
|
2034
|
+
if (!closed) {
|
|
2035
|
+
closed = true;
|
|
2036
|
+
attachWatcher?.stop();
|
|
2037
|
+
chromium.stop();
|
|
2038
|
+
}
|
|
2039
|
+
});
|
|
2040
|
+
process.on("uncaughtException", (err) => {
|
|
2041
|
+
process.stderr.write(`[ait-local-debug] uncaughtException: ${String(err)}\n`);
|
|
2042
|
+
shutdown();
|
|
2043
|
+
process.exit(1);
|
|
2044
|
+
});
|
|
2045
|
+
process.on("unhandledRejection", (reason) => {
|
|
2046
|
+
process.stderr.write(`[ait-local-debug] unhandledRejection: ${String(reason)}\n`);
|
|
2047
|
+
shutdown();
|
|
2048
|
+
process.exit(1);
|
|
2049
|
+
});
|
|
2050
|
+
await server.connect(transport);
|
|
2051
|
+
attachWatcher = startAttachWatcher(connection, server);
|
|
1101
2052
|
}
|
|
1102
2053
|
//#endregion
|
|
1103
2054
|
//#region src/mcp/ait-http-source.ts
|
|
@@ -1218,7 +2169,7 @@ function createDevServer(deps = {}) {
|
|
|
1218
2169
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
1219
2170
|
const server = new Server({
|
|
1220
2171
|
name: "ait-devtools",
|
|
1221
|
-
version: "0.1.
|
|
2172
|
+
version: "0.1.36"
|
|
1222
2173
|
}, { capabilities: { tools: {} } });
|
|
1223
2174
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
1224
2175
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -1280,11 +2231,18 @@ async function runDevServer() {
|
|
|
1280
2231
|
/**
|
|
1281
2232
|
* `devtools-mcp` bin entry.
|
|
1282
2233
|
*
|
|
1283
|
-
* Single bin, two
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
2234
|
+
* Single bin, two modes selected by `--mode` and one target selected by
|
|
2235
|
+
* `--target`:
|
|
2236
|
+
*
|
|
2237
|
+
* --mode=debug (default)
|
|
2238
|
+
* --target=relay (default) — CDP/Chii relay + cloudflared quick tunnel.
|
|
2239
|
+
* Attach a running mini-app (real Toss WebView, env 2/3) and read its
|
|
2240
|
+
* console + network over CDP without a human watching a phone.
|
|
2241
|
+
* --target=local — CDP direct-attach to a local Chromium launched by the
|
|
2242
|
+
* MCP server (env 1). No relay or tunnel; the browser is launched
|
|
2243
|
+
* pointing at AIT_DEVTOOLS_URL (default http://localhost:5173).
|
|
2244
|
+
*
|
|
2245
|
+
* --mode=dev — dev mode — reads the live browser mock state from a running
|
|
1288
2246
|
* Vite dev server (the devtools#130 `devtools_get_mock_state` surface).
|
|
1289
2247
|
*
|
|
1290
2248
|
* Node-only stdio process.
|
|
@@ -1303,13 +2261,40 @@ function parseMode(argv) {
|
|
|
1303
2261
|
}
|
|
1304
2262
|
return "debug";
|
|
1305
2263
|
}
|
|
2264
|
+
/**
|
|
2265
|
+
* Parses `--target=<value>` / `--target <value>` from argv; default `relay`.
|
|
2266
|
+
*
|
|
2267
|
+
* Only meaningful when `--mode=debug`:
|
|
2268
|
+
* - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env 2/3).
|
|
2269
|
+
* - `local` — local Chromium CDP attach (env 1, no relay needed).
|
|
2270
|
+
*/
|
|
2271
|
+
function parseTarget(argv) {
|
|
2272
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2273
|
+
const arg = argv[i];
|
|
2274
|
+
if (arg === void 0) continue;
|
|
2275
|
+
if (arg.startsWith("--target=")) return normalizeTarget(arg.slice(9));
|
|
2276
|
+
if (arg === "--target") {
|
|
2277
|
+
const next = argv[i + 1];
|
|
2278
|
+
if (next === void 0) throw new Error("--target requires a value: 'relay' (default) or 'local'.");
|
|
2279
|
+
return normalizeTarget(next);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
return "relay";
|
|
2283
|
+
}
|
|
1306
2284
|
function normalizeMode(value) {
|
|
1307
2285
|
if (value === "dev") return "dev";
|
|
1308
2286
|
if (value === "debug") return "debug";
|
|
1309
2287
|
throw new Error(`Unknown --mode '${value}'. Expected 'debug' (default) or 'dev'.`);
|
|
1310
2288
|
}
|
|
2289
|
+
function normalizeTarget(value) {
|
|
2290
|
+
if (value === "relay") return "relay";
|
|
2291
|
+
if (value === "local") return "local";
|
|
2292
|
+
throw new Error(`Unknown --target '${value}'. Expected 'relay' (default) or 'local'.`);
|
|
2293
|
+
}
|
|
1311
2294
|
async function main() {
|
|
1312
|
-
|
|
2295
|
+
const args = process.argv.slice(2);
|
|
2296
|
+
if (parseMode(args) === "dev") await runDevServer();
|
|
2297
|
+
else if (parseTarget(args) === "local") await runLocalDebugServer();
|
|
1313
2298
|
else await runDebugServer();
|
|
1314
2299
|
}
|
|
1315
2300
|
/** True when this file is the process entry (the bin), not an import. */
|
|
@@ -1328,6 +2313,6 @@ if (isEntrypoint()) main().catch((err) => {
|
|
|
1328
2313
|
process.exitCode = 1;
|
|
1329
2314
|
});
|
|
1330
2315
|
//#endregion
|
|
1331
|
-
export { parseMode };
|
|
2316
|
+
export { parseMode, parseTarget };
|
|
1332
2317
|
|
|
1333
2318
|
//# sourceMappingURL=cli.js.map
|