@ait-co/devtools 0.1.33 → 0.1.35

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/dist/mcp/cli.js CHANGED
@@ -8,27 +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
- import qrcode from "qrcode-terminal";
14
17
  //#region src/mcp/ait-chii-source.ts
15
- function isObject$2(value) {
18
+ function isObject$3(value) {
16
19
  return typeof value === "object" && value !== null;
17
20
  }
18
21
  /** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
19
22
  function asSdkCallHistory(raw) {
20
- if (isObject$2(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
23
+ if (isObject$3(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
21
24
  return { calls: [] };
22
25
  }
23
26
  /** Narrows an `AIT.getMockState` response to an opaque record. */
24
27
  function asMockState(raw) {
25
- return isObject$2(raw) ? raw : {};
28
+ return isObject$3(raw) ? raw : {};
26
29
  }
27
30
  /** Narrows an `AIT.getOperationalEnvironment` response. */
28
31
  function asOperationalEnvironment(raw) {
29
32
  return {
30
- environment: isObject$2(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
31
- sdkVersion: isObject$2(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
33
+ environment: isObject$3(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
34
+ sdkVersion: isObject$3(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
32
35
  };
33
36
  }
34
37
  var ChiiAitSource = class {
@@ -61,27 +64,27 @@ var ChiiAitSource = class {
61
64
  * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
62
65
  */
63
66
  /** Max events retained per domain ring buffer. */
64
- const DEFAULT_BUFFER_SIZE = 500;
65
- function isObject$1(value) {
67
+ const DEFAULT_BUFFER_SIZE$1 = 500;
68
+ function isObject$2(value) {
66
69
  return typeof value === "object" && value !== null;
67
70
  }
68
- function parseInbound(raw) {
71
+ function parseInbound$1(raw) {
69
72
  let parsed;
70
73
  try {
71
74
  parsed = JSON.parse(raw);
72
75
  } catch {
73
76
  return null;
74
77
  }
75
- if (!isObject$1(parsed)) return null;
78
+ if (!isObject$2(parsed)) return null;
76
79
  const message = {};
77
80
  if (typeof parsed.id === "number") message.id = parsed.id;
78
81
  if (typeof parsed.method === "string") message.method = parsed.method;
79
82
  if ("params" in parsed) message.params = parsed.params;
80
83
  if ("result" in parsed) message.result = parsed.result;
81
- if (isObject$1(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
84
+ if (isObject$2(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
82
85
  return message;
83
86
  }
84
- const PHASE_1_EVENTS = [
87
+ const PHASE_1_EVENTS$1 = [
85
88
  "Runtime.consoleAPICalled",
86
89
  "Network.requestWillBeSent",
87
90
  "Network.responseReceived"
@@ -104,8 +107,8 @@ var ChiiCdpConnection = class {
104
107
  pending = /* @__PURE__ */ new Map();
105
108
  constructor(options) {
106
109
  this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
107
- this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
108
- 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, []);
109
112
  this.emitter.setMaxListeners(0);
110
113
  }
111
114
  /** Refresh the attached-target list from the relay's `GET /targets`. */
@@ -113,10 +116,10 @@ var ChiiCdpConnection = class {
113
116
  const res = await fetch(`${this.relayBaseUrl}/targets`);
114
117
  if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
115
118
  const body = await res.json();
116
- const list = isObject$1(body) && Array.isArray(body.targets) ? body.targets : [];
119
+ const list = isObject$2(body) && Array.isArray(body.targets) ? body.targets : [];
117
120
  this.targets.clear();
118
121
  for (const item of list) {
119
- if (!isObject$1(item) || typeof item.id !== "string") continue;
122
+ if (!isObject$2(item) || typeof item.id !== "string") continue;
120
123
  this.targets.set(item.id, {
121
124
  id: item.id,
122
125
  title: typeof item.title === "string" ? item.title : "",
@@ -194,7 +197,7 @@ var ChiiCdpConnection = class {
194
197
  });
195
198
  }
196
199
  handleMessage(raw) {
197
- const message = parseInbound(raw);
200
+ const message = parseInbound$1(raw);
198
201
  if (!message) return;
199
202
  if (typeof message.id === "number" && this.pending.has(message.id)) {
200
203
  const waiter = this.pending.get(message.id);
@@ -263,9 +266,24 @@ function loadChiiServer() {
263
266
  if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
264
267
  throw new Error("chii server module did not expose start()");
265
268
  }
266
- /** Starts the Chii relay on the given port and resolves once listening. */
269
+ /**
270
+ * Starts the Chii relay and resolves once listening.
271
+ *
272
+ * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
273
+ * port on every start, so a stale cloudflared orphan holding any particular
274
+ * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`
275
+ * always reflect the actual bound port.
276
+ *
277
+ * chii.start() is called with `server` (our pre-created httpServer) BEFORE
278
+ * httpServer.listen(). This is intentional: chii attaches its Koa handler and
279
+ * WS upgrade listener to the server object, but the actual TCP bind is
280
+ * performed by our httpServer.listen() call below. The `port`/`domain` values
281
+ * passed to chii.start() are used for display/banner purposes inside chii and
282
+ * do not affect which port the server binds. The connection path (clients
283
+ * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.
284
+ */
267
285
  async function startChiiRelay(options = {}) {
268
- const port = options.port ?? 9100;
286
+ const requestedPort = options.port ?? 0;
269
287
  const host = options.host ?? "127.0.0.1";
270
288
  const { verifyAuth } = options;
271
289
  const httpServer = createServer();
@@ -278,26 +296,423 @@ async function startChiiRelay(options = {}) {
278
296
  });
279
297
  await loadChiiServer().start({
280
298
  server: httpServer,
281
- domain: `${host}:${port}`,
282
- port
299
+ domain: `${host}:${requestedPort}`,
300
+ port: requestedPort
283
301
  });
284
- await new Promise((resolve, reject) => {
302
+ const actualPort = await new Promise((resolve, reject) => {
285
303
  httpServer.once("error", reject);
286
- httpServer.listen(port, host, () => {
304
+ httpServer.listen(requestedPort, host, () => {
287
305
  httpServer.off("error", reject);
288
- resolve();
306
+ resolve(httpServer.address().port);
289
307
  });
290
308
  });
291
309
  return {
292
- port,
293
- baseUrl: `http://${host}:${port}`,
310
+ port: actualPort,
311
+ baseUrl: `http://${host}:${actualPort}`,
294
312
  close: () => new Promise((resolve) => {
295
313
  httpServer.close(() => resolve());
296
314
  })
297
315
  };
298
316
  }
299
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
300
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
+ }
301
716
  function stripExisting(query, key) {
302
717
  if (query === "") return "";
303
718
  return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
@@ -378,13 +793,23 @@ const DEBUG_TOOL_DEFINITIONS = [
378
793
  },
379
794
  {
380
795
  name: "build_attach_url",
381
- description: "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. Opening the result on the phone (e.g. `adb shell am start -d \"<url>\"`) attaches the mini-app to this debug session with no QR scan. Requires the tunnel to be up — call list_pages first.",
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).",
382
797
  inputSchema: {
383
798
  type: "object",
384
- properties: { scheme_url: {
385
- type: "string",
386
- description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId)."
387
- } },
799
+ properties: {
800
+ scheme_url: {
801
+ type: "string",
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."
803
+ },
804
+ wait_for_attach: {
805
+ type: "boolean",
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."
811
+ }
812
+ },
388
813
  required: ["scheme_url"]
389
814
  }
390
815
  },
@@ -424,6 +849,37 @@ const DEBUG_TOOL_DEFINITIONS = [
424
849
  required: []
425
850
  }
426
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
+ },
427
883
  {
428
884
  name: "AIT.getSdkCallHistory",
429
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).",
@@ -456,6 +912,16 @@ const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
456
912
  function isDebugToolName(name) {
457
913
  return DEBUG_TOOL_NAMES.has(name);
458
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"]);
459
925
  /** Renders a CDP `RemoteObject` console arg to a stable display string. */
460
926
  function renderRemoteObject(arg) {
461
927
  if (arg.value !== void 0) {
@@ -510,12 +976,135 @@ function listPages(connection, tunnel) {
510
976
  * Builds a self-attaching dogfood deep link from an `ait deploy --scheme-only`
511
977
  * URL plus this session's live relay. Throws if the tunnel is not up yet (no
512
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).
513
986
  */
514
987
  function buildAttachUrl(schemeUrl, tunnel) {
515
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;
516
990
  return {
517
991
  attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
518
- 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
519
1108
  };
520
1109
  }
521
1110
  /** Returns the DOM tree of the attached page (`DOM.getDocument`). */
@@ -538,7 +1127,21 @@ async function takeScreenshot(connection) {
538
1127
  mimeType: "image/png"
539
1128
  };
540
1129
  }
541
- `
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 = `
542
1145
  (function() {
543
1146
  var el = document.createElement('div');
544
1147
  el.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;visibility:hidden;' +
@@ -577,6 +1180,157 @@ async function takeScreenshot(connection) {
577
1180
  });
578
1181
  })()
579
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
+ }
580
1334
  /** Set of tool names served by the AIT source rather than the CDP connection. */
581
1335
  const AIT_TOOL_NAMES = new Set([
582
1336
  "AIT.getSdkCallHistory",
@@ -683,10 +1437,10 @@ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
683
1437
  *
684
1438
  * On spawn, the debug server opens an accountless `*.trycloudflare.com` quick
685
1439
  * tunnel to the local Chii relay so the phone can attach over a public wss URL,
686
- * then prints an ASCII QR + attach instructions. When TOTP auth is enabled
687
- * (`AIT_DEBUG_TOTP_SECRET` is set), the QR encodes only the base relay URL —
688
- * the TOTP code (`at=`) is NOT included because it rotates every 30 s and
689
- * would be stale by the time a human scans. The in-app deep-link builder
1440
+ * then prints a unicode half-block QR + attach instructions. When TOTP auth is
1441
+ * enabled (`AIT_DEBUG_TOTP_SECRET` is set), the QR encodes only the base relay
1442
+ * URL — the TOTP code (`at=`) is NOT included because it rotates every 30 s
1443
+ * and would be stale by the time a human scans. The in-app deep-link builder
690
1444
  * splices the live code at attach time.
691
1445
  *
692
1446
  * SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
@@ -741,6 +1495,45 @@ async function startQuickTunnel(localPort) {
741
1495
  };
742
1496
  }
743
1497
  /**
1498
+ * Renders a pure unicode half-block QR string for the given text.
1499
+ *
1500
+ * Uses `qrcode` (Node full lib) to get the raw bit matrix, then encodes every
1501
+ * two vertical modules into a single half-block character:
1502
+ * - both dark → `█`
1503
+ * - top only → `▀`
1504
+ * - bottom only → `▄`
1505
+ * - both light → ` ` (space)
1506
+ *
1507
+ * The output contains **zero ANSI escape codes**, so it renders correctly in
1508
+ * every surface (terminal, VS Code, JetBrains, web) and can be scanned by a
1509
+ * phone camera when shown verbatim in an agent response.
1510
+ *
1511
+ * Shared by `renderAttachBanner` (relay wssUrl QR) and the `build_attach_url`
1512
+ * MCP tool response (attach deep-link QR).
1513
+ */
1514
+ async function renderQr(text) {
1515
+ const { default: QRCode } = await import("qrcode");
1516
+ const qr = QRCode.create(text, { errorCorrectionLevel: "M" });
1517
+ const size = qr.modules.size;
1518
+ const data = qr.modules.data;
1519
+ const isDark = (x, y) => {
1520
+ if (x < 0 || y < 0 || x >= size || y >= size) return false;
1521
+ return data[y * size + x] === 1;
1522
+ };
1523
+ const QUIET = 1;
1524
+ const lines = [];
1525
+ for (let y = -QUIET; y < size + QUIET; y += 2) {
1526
+ let line = "";
1527
+ for (let x = -QUIET; x < size + QUIET; x++) {
1528
+ const top = isDark(x, y);
1529
+ const bot = isDark(x, y + 1);
1530
+ line += top && bot ? "█" : top ? "▀" : bot ? "▄" : " ";
1531
+ }
1532
+ lines.push(line);
1533
+ }
1534
+ return `${lines.join("\n")}\n`;
1535
+ }
1536
+ /**
744
1537
  * Renders the attach banner (relay URL + ASCII QR) as a string.
745
1538
  *
746
1539
  * The QR encodes the base `wssUrl` only. When `totpEnabled` is true, a note
@@ -751,9 +1544,7 @@ async function startQuickTunnel(localPort) {
751
1544
  * included in this output.
752
1545
  */
753
1546
  async function renderAttachBanner(input) {
754
- const qr = await new Promise((resolve) => {
755
- qrcode.generate(input.wssUrl, { small: true }, (rendered) => resolve(rendered));
756
- });
1547
+ const qr = await renderQr(input.wssUrl);
757
1548
  const authNote = input.totpEnabled ? " auth: TOTP enabled — attach URLs include a rotating code (at=)." : " auth: none (set AIT_DEBUG_TOTP_SECRET to enable TOTP).";
758
1549
  return [
759
1550
  "",
@@ -782,31 +1573,64 @@ async function printAttachBanner(input) {
782
1573
  * Lets an AI coding agent attach to a running mini-app (real Toss WebView, or a
783
1574
  * browser in dev mode) and read its console/network/DOM/screenshot over CDP plus
784
1575
  * the AIT.* domain, without a human watching a phone. Transport is CDP-via-Chii:
785
- * a local Chii relay :9100 exposed through a cloudflared quick tunnel; the phone
786
- * attaches over the public wss URL.
1576
+ * a local Chii relay on an OS-assigned port (default 0) exposed through a
1577
+ * cloudflared quick tunnel; the phone attaches over the public wss URL.
787
1578
  *
788
- * AI host --stdio--> this server --CDP client WS--> Chii relay :9100
1579
+ * AI host --stdio--> this server --CDP client WS--> Chii relay :<OS-port>
789
1580
  * ^-- target WS -- phone
790
1581
  *
1582
+ * Port 0 (default): the OS picks a free ephemeral port on every startup.
1583
+ * This prevents EADDRINUSE when a stale cloudflared child (orphaned after
1584
+ * SIGKILL, PPID 1) still holds a fixed port — which previously caused the MCP
1585
+ * handshake to fail with -32000. With port 0 any orphaned cloudflared is
1586
+ * harmless; the new relay always gets a fresh port.
1587
+ *
1588
+ * Best-effort child cleanup: SIGINT/SIGTERM/SIGHUP handlers call shutdown() to
1589
+ * stop cloudflared and the relay. uncaughtException/unhandledRejection also
1590
+ * call shutdown() before exit. SIGKILL cannot be intercepted by Node, so
1591
+ * cloudflared orphans from SIGKILL remain (port 0 makes them harmless). Users
1592
+ * can clean up manually: `pkill -f 'cloudflared.*trycloudflare'`.
1593
+ *
791
1594
  * The tool layer reads from an injectable `CdpConnection` (CDP) and `AitSource`
792
1595
  * (AIT.*), so every tool is unit-testable with a fake (no phone). This module
793
1596
  * wires the live pieces (relay + tunnel + production connection); the phone
794
1597
  * roundtrip is fully wired and pending only on-device acceptance.
795
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
+ *
796
1611
  * Node-only.
797
1612
  */
798
1613
  /**
799
1614
  * Builds the debug-mode MCP server around an injected CDP connection + AIT
800
1615
  * source + tunnel status getter. Pure wiring — does not start a relay or
801
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.
802
1624
  */
803
1625
  function createDebugServer(deps) {
804
- const { connection, aitSource, getTunnelStatus } = deps;
1626
+ const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4 } = deps;
805
1627
  const server = new Server({
806
1628
  name: "ait-debug",
807
- version: "0.1.33"
808
- }, { capabilities: { tools: {} } });
809
- server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
1629
+ version: "0.1.35"
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
+ });
810
1634
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
811
1635
  const name = request.params.name;
812
1636
  if (!isDebugToolName(name)) return {
@@ -836,8 +1660,109 @@ function createDebugServer(deps) {
836
1660
  }],
837
1661
  isError: true
838
1662
  };
1663
+ const waitForAttach = request.params.arguments?.wait_for_attach === true;
1664
+ const openInBrowser = request.params.arguments?.open_in_browser !== false;
839
1665
  try {
840
- return jsonResult$1(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
+ }
1736
+ const qr = await renderQr(attachUrl);
1737
+ const baseText = `${warningPrefix}${header}\n${JSON.stringify({
1738
+ attachUrl,
1739
+ relayUrl
1740
+ }, null, 2)}\n\n${qr}`;
1741
+ if (!waitForAttach) return { content: [{
1742
+ type: "text",
1743
+ text: baseText
1744
+ }] };
1745
+ const POLL_INTERVAL_MS = 1e3;
1746
+ const TIMEOUT_MS = waitForAttachTimeoutMs;
1747
+ const deadline = Date.now() + TIMEOUT_MS;
1748
+ let attachedPages = [];
1749
+ while (Date.now() < deadline) {
1750
+ attachedPages = connection.listTargets();
1751
+ if (attachedPages.length > 0) break;
1752
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1753
+ }
1754
+ if (attachedPages.length === 0) return {
1755
+ content: [{
1756
+ type: "text",
1757
+ text: `${baseText}\n\nNo page attached within ${TIMEOUT_MS / 1e3}s — call list_pages to retry.`
1758
+ }],
1759
+ isError: true
1760
+ };
1761
+ const pagesResult = listPages(connection, getTunnelStatus());
1762
+ return { content: [{
1763
+ type: "text",
1764
+ text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
1765
+ }] };
841
1766
  } catch (err) {
842
1767
  return errorResult(err, name);
843
1768
  }
@@ -870,6 +1795,30 @@ function createDebugServer(deps) {
870
1795
  mimeType: shot.mimeType
871
1796
  }] };
872
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
+ }
873
1822
  default: return unknownTool(name);
874
1823
  }
875
1824
  } catch (err) {
@@ -903,6 +1852,35 @@ function errorResult(err, name) {
903
1852
  };
904
1853
  }
905
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
+ /**
906
1884
  * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
907
1885
  * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
908
1886
  *
@@ -927,13 +1905,14 @@ function buildRelayVerifyAuth() {
927
1905
  }
928
1906
  /**
929
1907
  * Boots the live debug stack and serves it over stdio:
930
- * 1. start the Chii relay (with TOTP auth if AIT_DEBUG_TOTP_SECRET is set),
931
- * 2. open a cloudflared quick tunnel to it,
1908
+ * 1. start the Chii relay on an OS-assigned port (with TOTP auth if
1909
+ * AIT_DEBUG_TOTP_SECRET is set),
1910
+ * 2. open a cloudflared quick tunnel to the relay's confirmed port,
932
1911
  * 3. print relay URL + attach instructions,
933
1912
  * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
934
1913
  */
935
1914
  async function runDebugServer(options = {}) {
936
- const relayPort = options.relayPort ?? 9100;
1915
+ const relayPort = options.relayPort ?? 0;
937
1916
  const verifyAuth = buildRelayVerifyAuth();
938
1917
  const totpEnabled = verifyAuth !== void 0;
939
1918
  const relay = await startChiiRelay({
@@ -947,7 +1926,7 @@ async function runDebugServer(options = {}) {
947
1926
  };
948
1927
  generateAttachToken();
949
1928
  try {
950
- tunnel = await startQuickTunnel(relayPort);
1929
+ tunnel = await startQuickTunnel(relay.port);
951
1930
  tunnelStatus = {
952
1931
  up: true,
953
1932
  wssUrl: tunnel.wssUrl
@@ -968,7 +1947,12 @@ async function runDebugServer(options = {}) {
968
1947
  getTunnelStatus: () => tunnelStatus
969
1948
  });
970
1949
  const transport = new StdioServerTransport();
1950
+ let closed = false;
1951
+ let attachWatcher = null;
971
1952
  const shutdown = () => {
1953
+ if (closed) return;
1954
+ closed = true;
1955
+ attachWatcher?.stop();
972
1956
  connection.close();
973
1957
  tunnel?.stop();
974
1958
  relay.close();
@@ -976,7 +1960,95 @@ async function runDebugServer(options = {}) {
976
1960
  };
977
1961
  process.once("SIGINT", shutdown);
978
1962
  process.once("SIGTERM", shutdown);
1963
+ process.once("SIGHUP", shutdown);
1964
+ process.on("exit", () => {
1965
+ if (!closed) {
1966
+ closed = true;
1967
+ attachWatcher?.stop();
1968
+ tunnel?.stop();
1969
+ }
1970
+ });
1971
+ process.on("uncaughtException", (err) => {
1972
+ process.stderr.write(`[ait-debug] uncaughtException: ${String(err)}\n`);
1973
+ shutdown();
1974
+ process.exit(1);
1975
+ });
1976
+ process.on("unhandledRejection", (reason) => {
1977
+ process.stderr.write(`[ait-debug] unhandledRejection: ${String(reason)}\n`);
1978
+ shutdown();
1979
+ process.exit(1);
1980
+ });
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
+ });
979
2050
  await server.connect(transport);
2051
+ attachWatcher = startAttachWatcher(connection, server);
980
2052
  }
981
2053
  //#endregion
982
2054
  //#region src/mcp/ait-http-source.ts
@@ -1097,7 +2169,7 @@ function createDevServer(deps = {}) {
1097
2169
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
1098
2170
  const server = new Server({
1099
2171
  name: "ait-devtools",
1100
- version: "0.1.33"
2172
+ version: "0.1.35"
1101
2173
  }, { capabilities: { tools: {} } });
1102
2174
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
1103
2175
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1159,11 +2231,18 @@ async function runDevServer() {
1159
2231
  /**
1160
2232
  * `devtools-mcp` bin entry.
1161
2233
  *
1162
- * Single bin, two transports selected by `--mode`:
1163
- * - (default, no flag) debug mode — CDP/Chii relay + cloudflared quick tunnel.
1164
- * Attach a running mini-app (real Toss WebView or a browser) and read its
1165
- * console + network over CDP without a human watching a phone.
1166
- * - `--mode=dev` dev mode reads the live browser mock state from a running
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
1167
2246
  * Vite dev server (the devtools#130 `devtools_get_mock_state` surface).
1168
2247
  *
1169
2248
  * Node-only stdio process.
@@ -1182,13 +2261,40 @@ function parseMode(argv) {
1182
2261
  }
1183
2262
  return "debug";
1184
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
+ }
1185
2284
  function normalizeMode(value) {
1186
2285
  if (value === "dev") return "dev";
1187
2286
  if (value === "debug") return "debug";
1188
2287
  throw new Error(`Unknown --mode '${value}'. Expected 'debug' (default) or 'dev'.`);
1189
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
+ }
1190
2294
  async function main() {
1191
- if (parseMode(process.argv.slice(2)) === "dev") await runDevServer();
2295
+ const args = process.argv.slice(2);
2296
+ if (parseMode(args) === "dev") await runDevServer();
2297
+ else if (parseTarget(args) === "local") await runLocalDebugServer();
1192
2298
  else await runDebugServer();
1193
2299
  }
1194
2300
  /** True when this file is the process entry (the bin), not an import. */
@@ -1207,6 +2313,6 @@ if (isEntrypoint()) main().catch((err) => {
1207
2313
  process.exitCode = 1;
1208
2314
  });
1209
2315
  //#endregion
1210
- export { parseMode };
2316
+ export { parseMode, parseTarget };
1211
2317
 
1212
2318
  //# sourceMappingURL=cli.js.map