@ait-co/devtools 0.1.22 → 0.1.23

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.
@@ -0,0 +1,930 @@
1
+ #!/usr/bin/env node
2
+ #!/usr/bin/env node
3
+ import { createRequire } from "node:module";
4
+ import { argv } from "node:process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
+ import { EventEmitter } from "node:events";
10
+ import { WebSocket } from "ws";
11
+ import { createServer } from "node:http";
12
+ import { randomBytes } from "node:crypto";
13
+ import { Tunnel, bin, install } from "cloudflared";
14
+ import qrcode from "qrcode-terminal";
15
+ //#region src/mcp/ait-chii-source.ts
16
+ function isObject$2(value) {
17
+ return typeof value === "object" && value !== null;
18
+ }
19
+ /** Narrows an `AIT.getSdkCallHistory` response, tolerating a missing array. */
20
+ function asSdkCallHistory(raw) {
21
+ if (isObject$2(raw) && Array.isArray(raw.calls)) return { calls: raw.calls };
22
+ return { calls: [] };
23
+ }
24
+ /** Narrows an `AIT.getMockState` response to an opaque record. */
25
+ function asMockState(raw) {
26
+ return isObject$2(raw) ? raw : {};
27
+ }
28
+ /** Narrows an `AIT.getOperationalEnvironment` response. */
29
+ function asOperationalEnvironment(raw) {
30
+ return {
31
+ environment: isObject$2(raw) && typeof raw.environment === "string" ? raw.environment : "unknown",
32
+ sdkVersion: isObject$2(raw) && typeof raw.sdkVersion === "string" ? raw.sdkVersion : null
33
+ };
34
+ }
35
+ var ChiiAitSource = class {
36
+ constructor(sender) {
37
+ this.sender = sender;
38
+ }
39
+ async get(method) {
40
+ const raw = await this.sender.sendCommand(method);
41
+ switch (method) {
42
+ case "AIT.getSdkCallHistory": return asSdkCallHistory(raw);
43
+ case "AIT.getMockState": return asMockState(raw);
44
+ case "AIT.getOperationalEnvironment": return asOperationalEnvironment(raw);
45
+ default: throw new Error(`Unknown AIT method: ${String(method)}`);
46
+ }
47
+ }
48
+ };
49
+ //#endregion
50
+ //#region src/mcp/chii-connection.ts
51
+ /**
52
+ * Production `CdpConnection` backed by the local Chii relay.
53
+ *
54
+ * Topology (debug mode):
55
+ * phone target.js --WS--> Chii relay :9100 <--WS-- this connection
56
+ *
57
+ * The phone connects to the relay as a `target`; this module connects as a
58
+ * `client` (the role a CDP frontend would take) so CDP events the page emits
59
+ * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
60
+ * events in ring buffers the tool layer reads via `getBufferedEvents`.
61
+ *
62
+ * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
63
+ */
64
+ /** Max events retained per domain ring buffer. */
65
+ const DEFAULT_BUFFER_SIZE = 500;
66
+ function isObject$1(value) {
67
+ return typeof value === "object" && value !== null;
68
+ }
69
+ function parseInbound(raw) {
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (!isObject$1(parsed)) return null;
77
+ const message = {};
78
+ if (typeof parsed.id === "number") message.id = parsed.id;
79
+ if (typeof parsed.method === "string") message.method = parsed.method;
80
+ if ("params" in parsed) message.params = parsed.params;
81
+ if ("result" in parsed) message.result = parsed.result;
82
+ if (isObject$1(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
83
+ return message;
84
+ }
85
+ const PHASE_1_EVENTS = [
86
+ "Runtime.consoleAPICalled",
87
+ "Network.requestWillBeSent",
88
+ "Network.responseReceived"
89
+ ];
90
+ /**
91
+ * Production CDP connection. Polls the relay for the first attached target,
92
+ * opens a client websocket to it, enables Phase 1 domains, and buffers events.
93
+ */
94
+ var ChiiCdpConnection = class {
95
+ relayBaseUrl;
96
+ bufferSize;
97
+ emitter = new EventEmitter();
98
+ buffers = /* @__PURE__ */ new Map();
99
+ targets = /* @__PURE__ */ new Map();
100
+ ws = null;
101
+ nextCommandId = 1;
102
+ /** In-flight enableDomains() promise — concurrent callers share it. */
103
+ enablingPromise = null;
104
+ /** Pending request→response commands keyed by CDP message id. */
105
+ pending = /* @__PURE__ */ new Map();
106
+ constructor(options) {
107
+ this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
108
+ this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
109
+ for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
110
+ this.emitter.setMaxListeners(0);
111
+ }
112
+ /** Refresh the attached-target list from the relay's `GET /targets`. */
113
+ async refreshTargets() {
114
+ const res = await fetch(`${this.relayBaseUrl}/targets`);
115
+ if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
116
+ const body = await res.json();
117
+ const list = isObject$1(body) && Array.isArray(body.targets) ? body.targets : [];
118
+ this.targets.clear();
119
+ for (const item of list) {
120
+ if (!isObject$1(item) || typeof item.id !== "string") continue;
121
+ this.targets.set(item.id, {
122
+ id: item.id,
123
+ title: typeof item.title === "string" ? item.title : "",
124
+ url: typeof item.url === "string" ? item.url : ""
125
+ });
126
+ }
127
+ return [...this.targets.values()];
128
+ }
129
+ listTargets() {
130
+ return [...this.targets.values()];
131
+ }
132
+ /**
133
+ * Connect a client websocket to the first attached target and enable Phase 1
134
+ * domains. Resolves once the socket is open and enable commands are sent.
135
+ */
136
+ async enableDomains() {
137
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
138
+ if (this.enablingPromise) return this.enablingPromise;
139
+ this.enablingPromise = this._doEnableDomains().finally(() => {
140
+ this.enablingPromise = null;
141
+ });
142
+ return this.enablingPromise;
143
+ }
144
+ async _doEnableDomains() {
145
+ const target = (await this.refreshTargets())[0];
146
+ if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
147
+ const ws = new WebSocket(`${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`);
148
+ this.ws = ws;
149
+ await new Promise((resolve, reject) => {
150
+ ws.once("open", () => resolve());
151
+ ws.once("error", (err) => reject(err));
152
+ });
153
+ ws.on("message", (data) => this.handleMessage(data.toString()));
154
+ this.sendFireAndForget("Runtime.enable");
155
+ this.sendFireAndForget("Network.enable");
156
+ this.sendFireAndForget("DOM.enable");
157
+ this.sendFireAndForget("Page.enable");
158
+ }
159
+ /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
160
+ sendFireAndForget(method, params = {}) {
161
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
162
+ const id = this.nextCommandId++;
163
+ this.ws.send(JSON.stringify({
164
+ id,
165
+ method,
166
+ params
167
+ }));
168
+ }
169
+ /**
170
+ * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
171
+ * error frame or when no websocket is open (no page attached yet).
172
+ */
173
+ send(method, params) {
174
+ return this.sendCommand(method, params ?? {});
175
+ }
176
+ /**
177
+ * Issue an arbitrary request→response command over the relay and resolve with
178
+ * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
179
+ * `AIT.*` methods, forwarded over the same Chii channel) build on this.
180
+ */
181
+ sendCommand(method, params = {}) {
182
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
183
+ const id = this.nextCommandId++;
184
+ const ws = this.ws;
185
+ return new Promise((resolve, reject) => {
186
+ this.pending.set(id, {
187
+ resolve,
188
+ reject
189
+ });
190
+ ws.send(JSON.stringify({
191
+ id,
192
+ method,
193
+ params
194
+ }));
195
+ });
196
+ }
197
+ handleMessage(raw) {
198
+ const message = parseInbound(raw);
199
+ if (!message) return;
200
+ if (typeof message.id === "number" && this.pending.has(message.id)) {
201
+ const waiter = this.pending.get(message.id);
202
+ this.pending.delete(message.id);
203
+ if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
204
+ else waiter.resolve(message.result);
205
+ return;
206
+ }
207
+ if (typeof message.method !== "string") return;
208
+ if (!this.buffers.has(message.method)) return;
209
+ const event = message.method;
210
+ const buffer = this.buffers.get(event);
211
+ if (!buffer) return;
212
+ buffer.push(message.params);
213
+ if (buffer.length > this.bufferSize) buffer.shift();
214
+ this.emitter.emit(event, message.params);
215
+ }
216
+ getBufferedEvents(event) {
217
+ return this.buffers.get(event) ?? [];
218
+ }
219
+ on(event, listener) {
220
+ this.emitter.on(event, listener);
221
+ return () => this.emitter.off(event, listener);
222
+ }
223
+ /** Close the relay client websocket and reject any in-flight commands. */
224
+ close() {
225
+ this.ws?.close();
226
+ this.ws = null;
227
+ for (const waiter of this.pending.values()) waiter.reject(/* @__PURE__ */ new Error("Chii relay connection closed."));
228
+ this.pending.clear();
229
+ }
230
+ };
231
+ //#endregion
232
+ //#region src/mcp/chii-relay.ts
233
+ /**
234
+ * Boots the local Chii relay server.
235
+ *
236
+ * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome
237
+ * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.
238
+ * The relay accepts a `target` websocket from the phone's injected `target.js`
239
+ * and `client` websockets from CDP frontends (our MCP connection).
240
+ *
241
+ * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
242
+ * entries.
243
+ */
244
+ const require = createRequire(import.meta.url);
245
+ function loadChiiServer() {
246
+ const mod = require("chii");
247
+ if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
248
+ throw new Error("chii server module did not expose start()");
249
+ }
250
+ /** Starts the Chii relay on the given port and resolves once listening. */
251
+ async function startChiiRelay(options = {}) {
252
+ const port = options.port ?? 9100;
253
+ const host = options.host ?? "127.0.0.1";
254
+ const httpServer = createServer();
255
+ await loadChiiServer().start({
256
+ server: httpServer,
257
+ domain: `${host}:${port}`,
258
+ port
259
+ });
260
+ await new Promise((resolve, reject) => {
261
+ httpServer.once("error", reject);
262
+ httpServer.listen(port, host, () => {
263
+ httpServer.off("error", reject);
264
+ resolve();
265
+ });
266
+ });
267
+ return {
268
+ port,
269
+ baseUrl: `http://${host}:${port}`,
270
+ close: () => new Promise((resolve) => {
271
+ httpServer.close(() => resolve());
272
+ })
273
+ };
274
+ }
275
+ //#endregion
276
+ //#region src/mcp/tools.ts
277
+ /** Static MCP tool descriptors (name + JSONSchema) for the Phase 1 surface. */
278
+ const DEBUG_TOOL_DEFINITIONS = [
279
+ {
280
+ name: "list_console_messages",
281
+ description: "Lists recent console messages (console.log/warn/error/info) captured from the attached mini-app page over CDP (Runtime.consoleAPICalled). Read-only. Returns level, text, timestamp, and stringified args, oldest-first.",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {},
285
+ required: []
286
+ }
287
+ },
288
+ {
289
+ name: "list_network_requests",
290
+ description: "Lists recent network requests (XHR/fetch) captured from the attached mini-app page over CDP (Network.requestWillBeSent + Network.responseReceived). Read-only. Returns url, method, status, and timing, oldest-first.",
291
+ inputSchema: {
292
+ type: "object",
293
+ properties: {},
294
+ required: []
295
+ }
296
+ },
297
+ {
298
+ name: "list_pages",
299
+ description: "Lists the mini-app page(s) the Chii relay currently sees attached, plus whether the cloudflared tunnel is up and the public wss relay URL the phone uses to attach. Call this first to confirm a page is attached before reading console/network.",
300
+ inputSchema: {
301
+ type: "object",
302
+ properties: {},
303
+ required: []
304
+ }
305
+ },
306
+ {
307
+ name: "get_dom_document",
308
+ description: "Returns the DOM tree of the attached mini-app page over CDP (DOM.getDocument). Read-only. Use for structural/layout regression diagnosis (e.g. confirming an element exists, inspecting attributes). Returns the document root node with children.",
309
+ inputSchema: {
310
+ type: "object",
311
+ properties: {},
312
+ required: []
313
+ }
314
+ },
315
+ {
316
+ name: "take_snapshot",
317
+ description: "Captures a serialized snapshot of the attached page over CDP (DOMSnapshot.captureSnapshot). Read-only. Returns the documents + interned strings table for visual-regression diagnosis (e.g. checking computed CSS custom properties like --sat against the live layout).",
318
+ inputSchema: {
319
+ type: "object",
320
+ properties: {},
321
+ required: []
322
+ }
323
+ },
324
+ {
325
+ name: "take_screenshot",
326
+ description: "Captures a PNG screenshot of the attached mini-app page over CDP (Page.captureScreenshot) so the agent can see the phone screen directly. Read-only. Returns an image content block.",
327
+ inputSchema: {
328
+ type: "object",
329
+ properties: {},
330
+ required: []
331
+ }
332
+ },
333
+ {
334
+ name: "AIT.getSdkCallHistory",
335
+ 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).",
336
+ inputSchema: {
337
+ type: "object",
338
+ properties: {},
339
+ required: []
340
+ }
341
+ },
342
+ {
343
+ name: "AIT.getMockState",
344
+ description: "Returns the devtools mock state snapshot (window.__ait) — environment, permissions, location, auth, network, IAP, and more. Read-only. In dev mode this is the live browser mock state; in debug mode the in-app side reports it over the AIT domain.",
345
+ inputSchema: {
346
+ type: "object",
347
+ properties: {},
348
+ required: []
349
+ }
350
+ },
351
+ {
352
+ name: "AIT.getOperationalEnvironment",
353
+ description: "Returns getOperationalEnvironment() plus the resolved SDK version — metadata raw CDP cannot observe. Read-only.",
354
+ inputSchema: {
355
+ type: "object",
356
+ properties: {},
357
+ required: []
358
+ }
359
+ }
360
+ ];
361
+ const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
362
+ function isDebugToolName(name) {
363
+ return DEBUG_TOOL_NAMES.has(name);
364
+ }
365
+ /** Renders a CDP `RemoteObject` console arg to a stable display string. */
366
+ function renderRemoteObject(arg) {
367
+ if (arg.value !== void 0) {
368
+ if (typeof arg.value === "string") return arg.value;
369
+ try {
370
+ return JSON.stringify(arg.value);
371
+ } catch {
372
+ return String(arg.value);
373
+ }
374
+ }
375
+ if (arg.description !== void 0) return arg.description;
376
+ if (arg.className !== void 0) return arg.className;
377
+ return arg.subtype ?? arg.type;
378
+ }
379
+ function normalizeConsoleMessage(event) {
380
+ const args = event.args.map(renderRemoteObject);
381
+ return {
382
+ level: event.type,
383
+ text: args.join(" "),
384
+ timestamp: event.timestamp,
385
+ args
386
+ };
387
+ }
388
+ function listConsoleMessages(connection) {
389
+ return connection.getBufferedEvents("Runtime.consoleAPICalled").map((event) => normalizeConsoleMessage(event));
390
+ }
391
+ function listNetworkRequests(connection) {
392
+ const requests = connection.getBufferedEvents("Network.requestWillBeSent");
393
+ const responses = connection.getBufferedEvents("Network.responseReceived");
394
+ const responseByRequestId = /* @__PURE__ */ new Map();
395
+ for (const response of responses) responseByRequestId.set(response.requestId, response);
396
+ return requests.map((request) => {
397
+ const response = responseByRequestId.get(request.requestId);
398
+ return {
399
+ requestId: request.requestId,
400
+ url: request.request.url,
401
+ method: request.request.method,
402
+ status: response ? response.response.status : null,
403
+ statusText: response ? response.response.statusText : null,
404
+ startTime: request.timestamp,
405
+ endTime: response ? response.timestamp : null
406
+ };
407
+ });
408
+ }
409
+ function listPages(connection, tunnel) {
410
+ return {
411
+ pages: connection.listTargets(),
412
+ tunnel
413
+ };
414
+ }
415
+ /** Returns the DOM tree of the attached page (`DOM.getDocument`). */
416
+ function getDomDocument(connection) {
417
+ return connection.send("DOM.getDocument", {
418
+ depth: -1,
419
+ pierce: true
420
+ });
421
+ }
422
+ /** Returns a serialized page snapshot (`DOMSnapshot.captureSnapshot`). */
423
+ function takeSnapshot(connection) {
424
+ return connection.send("DOMSnapshot.captureSnapshot", {});
425
+ }
426
+ /** Captures a PNG screenshot of the attached page (`Page.captureScreenshot`). */
427
+ async function takeScreenshot(connection) {
428
+ const { data } = await connection.send("Page.captureScreenshot", { format: "png" });
429
+ return {
430
+ data,
431
+ dataUri: `data:image/png;base64,${data}`,
432
+ mimeType: "image/png"
433
+ };
434
+ }
435
+ /** Set of tool names served by the AIT source rather than the CDP connection. */
436
+ const AIT_TOOL_NAMES = new Set([
437
+ "AIT.getSdkCallHistory",
438
+ "AIT.getMockState",
439
+ "AIT.getOperationalEnvironment"
440
+ ]);
441
+ /** True for the Phase 3 AIT.* tools (served by an `AitSource`, not CDP). */
442
+ function isAitToolName(name) {
443
+ return AIT_TOOL_NAMES.has(name);
444
+ }
445
+ /** Returns the recent SDK call trace (`AIT.getSdkCallHistory`). */
446
+ function getSdkCallHistory(source) {
447
+ return source.get("AIT.getSdkCallHistory");
448
+ }
449
+ /** Returns the devtools mock-state snapshot (`AIT.getMockState`). */
450
+ function getMockState(source) {
451
+ return source.get("AIT.getMockState");
452
+ }
453
+ /** Returns the operational environment + SDK version (`AIT.getOperationalEnvironment`). */
454
+ function getOperationalEnvironment(source) {
455
+ return source.get("AIT.getOperationalEnvironment");
456
+ }
457
+ //#endregion
458
+ //#region src/mcp/tunnel.ts
459
+ /**
460
+ * cloudflared quick tunnel + attach banner for the debug-mode MCP server.
461
+ *
462
+ * On spawn, the debug server opens an accountless `*.trycloudflare.com` quick
463
+ * tunnel to the local Chii relay so the phone can attach over a public wss URL,
464
+ * then prints that URL + a secret token + an ASCII QR to the terminal. The
465
+ * phone scans the QR (or pastes the URL) to attach; the in-app side passes the
466
+ * token back. Phase 1 only generates + displays the token and makes it
467
+ * available — full ACL enforcement is a later phase.
468
+ *
469
+ * Node-only: spawns the cloudflared binary and writes to stdout/stderr.
470
+ */
471
+ /** Generates a 32-byte hex secret token used to gate attach. */
472
+ function generateAttachToken() {
473
+ return randomBytes(32).toString("hex");
474
+ }
475
+ /** Ensures the cloudflared binary is installed (downloads + caches on first run). */
476
+ async function ensureCloudflaredBin() {
477
+ const { existsSync } = await import("node:fs");
478
+ if (!existsSync(bin)) await install(bin);
479
+ }
480
+ /**
481
+ * Opens a cloudflared quick tunnel to the local relay port and resolves once
482
+ * the public URL is assigned.
483
+ */
484
+ async function startQuickTunnel(localPort) {
485
+ await ensureCloudflaredBin();
486
+ const tunnel = Tunnel.quick(`http://127.0.0.1:${localPort}`);
487
+ const url = await new Promise((resolve, reject) => {
488
+ const onUrl = (assigned) => {
489
+ cleanup();
490
+ resolve(assigned);
491
+ };
492
+ const onError = (err) => {
493
+ cleanup();
494
+ reject(err);
495
+ };
496
+ const onExit = (code) => {
497
+ cleanup();
498
+ reject(/* @__PURE__ */ new Error(`cloudflared exited before assigning a URL (code ${code})`));
499
+ };
500
+ const cleanup = () => {
501
+ tunnel.off("url", onUrl);
502
+ tunnel.off("error", onError);
503
+ tunnel.off("exit", onExit);
504
+ };
505
+ tunnel.once("url", onUrl);
506
+ tunnel.once("error", onError);
507
+ tunnel.once("exit", onExit);
508
+ });
509
+ return {
510
+ url,
511
+ wssUrl: url.replace(/^https/, "wss"),
512
+ stop: () => {
513
+ tunnel.stop();
514
+ }
515
+ };
516
+ }
517
+ /** Renders the attach banner (URL + token + ASCII QR) as a string. */
518
+ async function renderAttachBanner(input) {
519
+ const payload = `${input.wssUrl}?token=${input.token}`;
520
+ const qr = await new Promise((resolve) => {
521
+ qrcode.generate(payload, { small: true }, (rendered) => resolve(rendered));
522
+ });
523
+ return [
524
+ "",
525
+ "AIT debug — attach a mini-app to this session",
526
+ "",
527
+ ` relay (wss): ${input.wssUrl}`,
528
+ ` token: ${input.token}`,
529
+ "",
530
+ " Open the dogfood mini-app with ?debug=1, then scan the QR",
531
+ " (or paste the relay URL + token in the in-app attach form):",
532
+ "",
533
+ qr
534
+ ].join("\n");
535
+ }
536
+ /** Prints the attach banner to stderr (stdout is the MCP stdio channel). */
537
+ async function printAttachBanner(input) {
538
+ const banner = await renderAttachBanner(input);
539
+ process.stderr.write(`${banner}\n`);
540
+ }
541
+ //#endregion
542
+ //#region src/mcp/debug-server.ts
543
+ /**
544
+ * @ait-co/devtools debug-mode MCP server (stdio) — Phase 1–3.
545
+ *
546
+ * Lets an AI coding agent attach to a running mini-app (real Toss WebView, or a
547
+ * browser in dev mode) and read its console/network/DOM/screenshot over CDP plus
548
+ * the AIT.* domain, without a human watching a phone. Transport is CDP-via-Chii:
549
+ * a local Chii relay :9100 exposed through a cloudflared quick tunnel; the phone
550
+ * attaches over the public wss URL.
551
+ *
552
+ * AI host --stdio--> this server --CDP client WS--> Chii relay :9100
553
+ * ^-- target WS -- phone
554
+ *
555
+ * The tool layer reads from an injectable `CdpConnection` (CDP) and `AitSource`
556
+ * (AIT.*), so every tool is unit-testable with a fake (no phone). This module
557
+ * wires the live pieces (relay + tunnel + production connection); the phone
558
+ * roundtrip itself is phone-gated and deferred.
559
+ *
560
+ * Node-only.
561
+ */
562
+ /**
563
+ * Builds the debug-mode MCP server around an injected CDP connection + AIT
564
+ * source + tunnel status getter. Pure wiring — does not start a relay or
565
+ * tunnel, which is what makes the tool surface unit-testable.
566
+ */
567
+ function createDebugServer(deps) {
568
+ const { connection, aitSource, getTunnelStatus } = deps;
569
+ const server = new Server({
570
+ name: "ait-debug",
571
+ version: "0.1.23"
572
+ }, { capabilities: { tools: {} } });
573
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEBUG_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
574
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
575
+ const name = request.params.name;
576
+ if (!isDebugToolName(name)) return {
577
+ content: [{
578
+ type: "text",
579
+ text: `Unknown tool: ${name}`
580
+ }],
581
+ isError: true
582
+ };
583
+ if (isAitToolName(name)) try {
584
+ await connection.enableDomains();
585
+ switch (name) {
586
+ case "AIT.getSdkCallHistory": return jsonResult$1(await getSdkCallHistory(aitSource));
587
+ case "AIT.getMockState": return jsonResult$1(await getMockState(aitSource));
588
+ case "AIT.getOperationalEnvironment": return jsonResult$1(await getOperationalEnvironment(aitSource));
589
+ default: return unknownTool(name);
590
+ }
591
+ } catch (err) {
592
+ return errorResult(err, name);
593
+ }
594
+ try {
595
+ await connection.enableDomains();
596
+ } catch (err) {
597
+ const message = err instanceof Error ? err.message : String(err);
598
+ if (name === "list_pages") return jsonResult$1(listPages(connection, getTunnelStatus()));
599
+ return {
600
+ content: [{
601
+ type: "text",
602
+ text: `${message}\nCall list_pages to confirm a mini-app has attached over the relay.`
603
+ }],
604
+ isError: true
605
+ };
606
+ }
607
+ try {
608
+ switch (name) {
609
+ case "list_console_messages": return jsonResult$1(listConsoleMessages(connection));
610
+ case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
611
+ case "list_pages": return jsonResult$1(listPages(connection, getTunnelStatus()));
612
+ case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
613
+ case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
614
+ case "take_screenshot": {
615
+ const shot = await takeScreenshot(connection);
616
+ return { content: [{
617
+ type: "image",
618
+ data: shot.data,
619
+ mimeType: shot.mimeType
620
+ }] };
621
+ }
622
+ default: return unknownTool(name);
623
+ }
624
+ } catch (err) {
625
+ return errorResult(err, name);
626
+ }
627
+ });
628
+ return server;
629
+ }
630
+ function jsonResult$1(value) {
631
+ return { content: [{
632
+ type: "text",
633
+ text: JSON.stringify(value, null, 2)
634
+ }] };
635
+ }
636
+ function unknownTool(name) {
637
+ return {
638
+ content: [{
639
+ type: "text",
640
+ text: `Unknown tool: ${name}`
641
+ }],
642
+ isError: true
643
+ };
644
+ }
645
+ function errorResult(err, name) {
646
+ return {
647
+ content: [{
648
+ type: "text",
649
+ text: `${name} failed: ${err instanceof Error ? err.message : String(err)}\nCall list_pages to confirm a mini-app has attached over the relay.`
650
+ }],
651
+ isError: true
652
+ };
653
+ }
654
+ /**
655
+ * Boots the live debug stack and serves it over stdio:
656
+ * 1. start the Chii relay,
657
+ * 2. open a cloudflared quick tunnel to it,
658
+ * 3. print QR + secret token,
659
+ * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
660
+ */
661
+ async function runDebugServer(options = {}) {
662
+ const relayPort = options.relayPort ?? 9100;
663
+ const relay = await startChiiRelay({ port: relayPort });
664
+ let tunnel = null;
665
+ let tunnelStatus = {
666
+ up: false,
667
+ wssUrl: null
668
+ };
669
+ const token = generateAttachToken();
670
+ try {
671
+ tunnel = await startQuickTunnel(relayPort);
672
+ tunnelStatus = {
673
+ up: true,
674
+ wssUrl: tunnel.wssUrl
675
+ };
676
+ await printAttachBanner({
677
+ wssUrl: tunnel.wssUrl,
678
+ token
679
+ });
680
+ } catch (err) {
681
+ const message = err instanceof Error ? err.message : String(err);
682
+ process.stderr.write(`[ait-debug] Failed to open cloudflared quick tunnel: ${message}\n[ait-debug] The relay is up locally; attach over the public URL is unavailable until the tunnel starts.
683
+ `);
684
+ }
685
+ const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
686
+ const server = createDebugServer({
687
+ connection,
688
+ aitSource: new ChiiAitSource(connection),
689
+ getTunnelStatus: () => tunnelStatus
690
+ });
691
+ const transport = new StdioServerTransport();
692
+ const shutdown = () => {
693
+ connection.close();
694
+ tunnel?.stop();
695
+ relay.close();
696
+ server.close();
697
+ };
698
+ process.once("SIGINT", shutdown);
699
+ process.once("SIGTERM", shutdown);
700
+ await server.connect(transport);
701
+ }
702
+ //#endregion
703
+ //#region src/mcp/ait-http-source.ts
704
+ function isObject(value) {
705
+ return typeof value === "object" && value !== null;
706
+ }
707
+ var HttpAitSource = class {
708
+ stateEndpoint;
709
+ fetchImpl;
710
+ constructor(options) {
711
+ this.stateEndpoint = options.stateEndpoint;
712
+ this.fetchImpl = options.fetchImpl ?? ((url) => fetch(url));
713
+ }
714
+ async fetchState() {
715
+ const res = await this.fetchImpl(this.stateEndpoint);
716
+ if (!res.ok) throw new Error(`Failed to fetch mock state from ${this.stateEndpoint}: HTTP ${res.status} ${res.statusText}. Ensure the Vite dev server is running with the @ait-co/devtools unplugin option \`mcp: true\`.`);
717
+ const body = await res.json();
718
+ return isObject(body) ? body : {};
719
+ }
720
+ async get(method) {
721
+ switch (method) {
722
+ case "AIT.getMockState": return await this.fetchState();
723
+ case "AIT.getOperationalEnvironment": {
724
+ const state = await this.fetchState();
725
+ return {
726
+ environment: typeof state.environment === "string" ? state.environment : "unknown",
727
+ sdkVersion: typeof state.appVersion === "string" ? state.appVersion : null
728
+ };
729
+ }
730
+ case "AIT.getSdkCallHistory": return { calls: [] };
731
+ default: throw new Error(`Unknown AIT method: ${String(method)}`);
732
+ }
733
+ }
734
+ };
735
+ //#endregion
736
+ //#region src/mcp/server.ts
737
+ /**
738
+ * @ait-co/devtools dev-mode MCP server (stdio).
739
+ *
740
+ * Exposes the live browser mock state from a running Vite dev server to AI
741
+ * coding agents via the Model Context Protocol (MCP).
742
+ *
743
+ * Architecture:
744
+ * Browser (aitState) → Vite dev server endpoint (/api/ait-devtools/state)
745
+ * ← HTTP GET ← this stdio MCP server ← AI agent
746
+ *
747
+ * The Vite endpoint is registered by the unplugin when `mcp: true` is set in
748
+ * the plugin options (see `src/unplugin/index.ts`).
749
+ *
750
+ * Phase 3 tool-surface alignment: dev mode and debug mode now expose the same
751
+ * `AIT.*` tools (`AIT.getMockState`, `AIT.getOperationalEnvironment`,
752
+ * `AIT.getSdkCallHistory`). In dev mode they are backed by the HTTP mock-state
753
+ * endpoint (see `HttpAitSource`); in debug mode by the Chii channel. So an AI
754
+ * sees a coherent tool whether attached to a phone (debug) or a dev browser
755
+ * (dev). `devtools_get_mock_state` (the original devtools#130 name) is kept as a
756
+ * backward-compatible alias of `AIT.getMockState`.
757
+ *
758
+ * This module is reached via the `devtools-mcp --mode=dev` CLI entry (see
759
+ * `cli.ts`); the default (no flag) bin mode is the debug-mode CDP/Chii server.
760
+ *
761
+ * Usage (in your MCP client config, e.g. Claude Desktop):
762
+ * {
763
+ * "mcpServers": {
764
+ * "ait-devtools": {
765
+ * "command": "pnpm",
766
+ * "args": ["exec", "devtools-mcp", "--mode=dev"],
767
+ * "env": { "AIT_DEVTOOLS_URL": "http://localhost:5173" }
768
+ * }
769
+ * }
770
+ * }
771
+ */
772
+ /** Tool descriptors served by the dev-mode server. */
773
+ const DEV_TOOL_DEFINITIONS = [
774
+ {
775
+ name: "AIT.getMockState",
776
+ description: "Returns the devtools mock state snapshot (window.__ait) from the running browser session — environment, permissions, location, auth, network, IAP, and more. Read-only. Requires the Vite dev server running with the @ait-co/devtools unplugin option `mcp: true`. Same tool as in debug mode, where the in-app side reports it over the AIT domain.",
777
+ inputSchema: {
778
+ type: "object",
779
+ properties: {},
780
+ required: []
781
+ }
782
+ },
783
+ {
784
+ name: "AIT.getOperationalEnvironment",
785
+ description: "Returns the operational environment + SDK/app version derived from the dev mock state. Read-only.",
786
+ inputSchema: {
787
+ type: "object",
788
+ properties: {},
789
+ required: []
790
+ }
791
+ },
792
+ {
793
+ name: "AIT.getSdkCallHistory",
794
+ description: "Returns the SDK call trace. In dev mode the HTTP mock-state endpoint records no trace, so this returns an empty list; in debug mode it is populated over the AIT domain. Read-only.",
795
+ inputSchema: {
796
+ type: "object",
797
+ properties: {},
798
+ required: []
799
+ }
800
+ },
801
+ {
802
+ name: "devtools_get_mock_state",
803
+ description: "Backward-compatible alias of AIT.getMockState (the original devtools#130 name). Returns the current AIT DevTools mock state snapshot. Read-only. Prefer AIT.getMockState in new configs.",
804
+ inputSchema: {
805
+ type: "object",
806
+ properties: {},
807
+ required: []
808
+ }
809
+ }
810
+ ];
811
+ const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
812
+ /** Builds the dev-mode MCP server (does not connect a transport). */
813
+ function createDevServer(deps = {}) {
814
+ const stateEndpoint = `${process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"}/api/ait-devtools/state`;
815
+ const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
816
+ const server = new Server({
817
+ name: "ait-devtools",
818
+ version: "0.1.23"
819
+ }, { capabilities: { tools: {} } });
820
+ server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
821
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
822
+ const name = request.params.name;
823
+ if (!DEV_TOOL_NAMES.has(name)) return {
824
+ content: [{
825
+ type: "text",
826
+ text: `Unknown tool: ${name}`
827
+ }],
828
+ isError: true
829
+ };
830
+ try {
831
+ const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
832
+ if (!isAitToolName(effective)) return {
833
+ content: [{
834
+ type: "text",
835
+ text: `Unknown tool: ${name}`
836
+ }],
837
+ isError: true
838
+ };
839
+ switch (effective) {
840
+ case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
841
+ case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
842
+ case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
843
+ default: return {
844
+ content: [{
845
+ type: "text",
846
+ text: `Unknown tool: ${name}`
847
+ }],
848
+ isError: true
849
+ };
850
+ }
851
+ } catch (err) {
852
+ return {
853
+ content: [{
854
+ type: "text",
855
+ text: `${err instanceof Error ? err.message : String(err)}\nIs the Vite dev server running with the @ait-co/devtools unplugin option \`mcp: true\`? Is AIT_DEVTOOLS_URL set correctly?`
856
+ }],
857
+ isError: true
858
+ };
859
+ }
860
+ });
861
+ return server;
862
+ }
863
+ function jsonResult(value) {
864
+ return { content: [{
865
+ type: "text",
866
+ text: JSON.stringify(value, null, 2)
867
+ }] };
868
+ }
869
+ /** Builds the dev-mode server and connects it over stdio. */
870
+ async function runDevServer() {
871
+ const server = createDevServer();
872
+ const transport = new StdioServerTransport();
873
+ await server.connect(transport);
874
+ }
875
+ //#endregion
876
+ //#region src/mcp/cli.ts
877
+ /**
878
+ * `devtools-mcp` bin entry.
879
+ *
880
+ * Single bin, two transports selected by `--mode`:
881
+ * - (default, no flag) debug mode — CDP/Chii relay + cloudflared quick tunnel.
882
+ * Attach a running mini-app (real Toss WebView or a browser) and read its
883
+ * console + network over CDP without a human watching a phone.
884
+ * - `--mode=dev` — dev mode — reads the live browser mock state from a running
885
+ * Vite dev server (the devtools#130 `devtools_get_mock_state` surface).
886
+ *
887
+ * Node-only stdio process.
888
+ */
889
+ /** Parses `--mode=<value>` / `--mode <value>` from argv; default `debug`. */
890
+ function parseMode(argv) {
891
+ for (let i = 0; i < argv.length; i++) {
892
+ const arg = argv[i];
893
+ if (arg === void 0) continue;
894
+ if (arg.startsWith("--mode=")) return normalizeMode(arg.slice(7));
895
+ if (arg === "--mode") {
896
+ const next = argv[i + 1];
897
+ if (next === void 0) throw new Error("--mode requires a value: 'debug' (default) or 'dev'.");
898
+ return normalizeMode(next);
899
+ }
900
+ }
901
+ return "debug";
902
+ }
903
+ function normalizeMode(value) {
904
+ if (value === "dev") return "dev";
905
+ if (value === "debug") return "debug";
906
+ throw new Error(`Unknown --mode '${value}'. Expected 'debug' (default) or 'dev'.`);
907
+ }
908
+ async function main() {
909
+ if (parseMode(process.argv.slice(2)) === "dev") await runDevServer();
910
+ else await runDebugServer();
911
+ }
912
+ /** True when this file is the process entry (the bin), not an import. */
913
+ function isEntrypoint() {
914
+ const entry = argv[1];
915
+ if (entry === void 0) return false;
916
+ try {
917
+ return fileURLToPath(import.meta.url) === entry;
918
+ } catch {
919
+ return false;
920
+ }
921
+ }
922
+ if (isEntrypoint()) main().catch((err) => {
923
+ const message = err instanceof Error ? err.message : String(err);
924
+ process.stderr.write(`[devtools-mcp] fatal: ${message}\n`);
925
+ process.exitCode = 1;
926
+ });
927
+ //#endregion
928
+ export { parseMode };
929
+
930
+ //# sourceMappingURL=cli.js.map