@ait-co/devtools 0.1.44 → 0.1.46

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
@@ -848,8 +848,75 @@ var AutoDevtoolsOpener = class {
848
848
  }
849
849
  };
850
850
  //#endregion
851
+ //#region src/mcp/envelope.ts
852
+ /**
853
+ * Returns `true` when `AIT_MCP_COMPAT=chrome-devtools` is set, which bypasses
854
+ * envelope wrapping and returns raw payloads (0.1.x back-compat).
855
+ */
856
+ function isCompatMode() {
857
+ return process.env.AIT_MCP_COMPAT === "chrome-devtools";
858
+ }
859
+ /**
860
+ * Maps `McpEnvironment` to `EnvelopeEnv`. After #307 these are the same
861
+ * union (`mock | relay-dev | relay-live`), so this is identity — kept as a
862
+ * named export for surface stability if envelope env diverges in the future.
863
+ */
864
+ function toEnvelopeEnv(env) {
865
+ return env;
866
+ }
867
+ /**
868
+ * Wraps `data` in a `ToolEnvelope<T>` **unless** compat mode is active, in
869
+ * which case `data` is returned as-is.
870
+ *
871
+ * Use this at every tool call-site in `debug-server.ts` and `server.ts`.
872
+ *
873
+ * @example
874
+ * ```ts
875
+ * return jsonResult(wrapEnvelope(listPages(connection, tunnel), {
876
+ * tool: 'list_pages',
877
+ * env: resolveEnvironment(),
878
+ * attached: connection.listTargets().length > 0,
879
+ * }));
880
+ * ```
881
+ */
882
+ function wrapEnvelope(data, ctx) {
883
+ if (isCompatMode()) return data;
884
+ return {
885
+ ok: true,
886
+ data,
887
+ meta: {
888
+ tool: ctx.tool,
889
+ env: toEnvelopeEnv(ctx.env),
890
+ attached: ctx.attached,
891
+ contentType: ctx.contentType ?? "json"
892
+ }
893
+ };
894
+ }
895
+ //#endregion
851
896
  //#region src/mcp/environment.ts
852
897
  /**
898
+ * Returns `true` when the environment is any relay variant (`relay-dev` or
899
+ * `relay-live`). Use this instead of `env === 'relay'` for tier checks.
900
+ */
901
+ function isRelayEnv(env) {
902
+ return env === "relay-dev" || env === "relay-live";
903
+ }
904
+ /**
905
+ * Returns `true` when the environment is the LIVE relay (`relay-live`).
906
+ * This is the guard condition for side-effect tool protection.
907
+ */
908
+ function isLiveRelayEnv(env) {
909
+ return env === "relay-live";
910
+ }
911
+ /**
912
+ * Maps the new `McpEnvironment` union to the legacy two-value union
913
+ * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
914
+ */
915
+ function toLegacyEnv(env) {
916
+ if (env === "mock") return "mock";
917
+ return "relay";
918
+ }
919
+ /**
853
920
  * URL patterns that mark a CDP target as a real-device WebView relay.
854
921
  *
855
922
  * - `intoss-private://` is the Toss in-app private scheme — only ever observed
@@ -874,28 +941,43 @@ function isRelayUrl(url) {
874
941
  * regardless of env vars or connection state. Cleared with `null`.
875
942
  */
876
943
  let envOverride = null;
877
- /** Parses the `MCP_ENV` env var into a `McpEnvironment` if valid. */
944
+ /**
945
+ * Parses the `MCP_ENV` env var into a `McpEnvironment` if valid.
946
+ *
947
+ * Accepted values:
948
+ * - `mock` → `mock`
949
+ * - `relay-dev` → `relay-dev`
950
+ * - `relay-live` → `relay-live`
951
+ * - `relay` → `relay-dev` (backward-compat alias — resolves to relay-dev)
952
+ *
953
+ * Any other value is ignored and falls through to the next precedence step.
954
+ */
878
955
  function readEnvVar() {
879
956
  const raw = process.env.MCP_ENV;
880
- if (raw === "mock" || raw === "relay") return raw;
957
+ if (raw === "mock") return "mock";
958
+ if (raw === "relay-dev") return "relay-dev";
959
+ if (raw === "relay-live") return "relay-live";
960
+ if (raw === "relay") return "relay-dev";
881
961
  }
882
962
  /**
883
963
  * Returns the current MCP environment, applying the precedence rules:
884
964
  * 1. test override (if set)
885
965
  * 2. `MCP_ENV` env var
886
- * 3. CDP target URL pattern match
887
- * 4. default `mock`
966
+ * 3. CDP target URL pattern match → `relay-dev` (conservative — LIVE
967
+ * requires explicit MCP_ENV=relay-live opt-in)
968
+ * 4. caller-stated `defaultEnv` (intent hint from the CLI mode)
969
+ * 5. baked-in default `mock`
888
970
  */
889
971
  function getEnvironment(input = {}) {
890
972
  if (envOverride !== null) return envOverride;
891
973
  const fromEnv = readEnvVar();
892
974
  if (fromEnv !== void 0) return fromEnv;
893
- const { connection } = input;
975
+ const { connection, defaultEnv } = input;
894
976
  if (connection !== void 0) {
895
977
  const targets = connection.listTargets();
896
- for (const t of targets) if (isRelayUrl(t.url)) return "relay";
978
+ for (const t of targets) if (isRelayUrl(t.url)) return "relay-dev";
897
979
  }
898
- return "mock";
980
+ return defaultEnv ?? "mock";
899
981
  }
900
982
  /**
901
983
  * Returns the `EnvironmentReason` that drove the current `getEnvironment()`
@@ -904,15 +986,23 @@ function getEnvironment(input = {}) {
904
986
  * secret value is ever returned.
905
987
  */
906
988
  function getEnvironmentReason(input = {}) {
907
- if (envOverride !== null) return envOverride === "mock" ? "env-var-mock" : "env-var-relay";
989
+ if (envOverride !== null) {
990
+ if (envOverride === "mock") return "env-var-mock";
991
+ if (envOverride === "relay-live") return "env-var-relay-live";
992
+ return "env-var-relay-dev";
993
+ }
994
+ const rawVar = process.env.MCP_ENV;
908
995
  const fromEnv = readEnvVar();
909
996
  if (fromEnv === "mock") return "env-var-mock";
910
- if (fromEnv === "relay") return "env-var-relay";
911
- const { connection } = input;
997
+ if (fromEnv === "relay-live") return "env-var-relay-live";
998
+ if (fromEnv === "relay-dev") return rawVar === "relay" ? "env-var-relay-compat" : "env-var-relay-dev";
999
+ const { connection, defaultEnv } = input;
912
1000
  if (connection !== void 0) {
913
1001
  const targets = connection.listTargets();
914
1002
  for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
915
1003
  }
1004
+ if (defaultEnv === "relay-live") return "default-relay-live";
1005
+ if (defaultEnv === "relay-dev") return "default-relay-dev";
916
1006
  return "default-mock";
917
1007
  }
918
1008
  //#endregion
@@ -940,7 +1030,7 @@ function mcpError(message) {
940
1030
  * @param reason - 환경이 결정된 근거 (EnvironmentReason 문자열).
941
1031
  */
942
1032
  function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
943
- return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "build_attach_url → QR 스캔으로 실기기를 attach하세요." : "MCP_ENV=mock 또는 relay 환경변수를 확인하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
1033
+ return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 build_attach_url → QR 스캔으로 실기기를 attach하세요." : "mock으로 전환하려면 MCP_ENV=mock 설정 서버를 재시작하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
944
1034
  }
945
1035
  /**
946
1036
  * 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.
@@ -956,7 +1046,7 @@ function tunnelDownError() {
956
1046
  * enableDomains()가 "No mini-app page attached" 에러를 던질 때.
957
1047
  */
958
1048
  function pageMissingError(toolName) {
959
- return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. build_attach_url로 deep link를 생성하고 QR을 스캔해 미니앱을 attach하세요.`);
1049
+ return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. dogfood 번들 배포 build_attach_url을 호출해 QR을 생성하세요: \`ait deploy --scheme-only\` → \`build_attach_url(scheme_url)\` → QR 스캔.`);
960
1050
  }
961
1051
  /**
962
1052
  * 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.
@@ -973,7 +1063,24 @@ function pageCrashError(toolName) {
973
1063
  * call_sdk 호출 시 브리지가 없을 때.
974
1064
  */
975
1065
  function sdkAbsentError(toolName) {
976
- return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 번들을 재배포한 재시도하세요.`);
1066
+ return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
1067
+ }
1068
+ /**
1069
+ * relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
1070
+ * 없이 호출했을 때 반환하는 거부 메시지.
1071
+ *
1072
+ * 다음 행동을 두 가지로 제시한다:
1073
+ * 1. 같은 호출에 `confirm: true` 인자를 추가해 재시도.
1074
+ * 2. 읽기 전용 환경(relay-dev, mock)으로 전환.
1075
+ */
1076
+ function liveGuardError(toolName) {
1077
+ return mcpError(`[LIVE relay guard] ${toolName}은 현재 relay-live(실 출시 런타임) 세션에서 side-effect 호출입니다. 실유저에게 영향을 줄 수 있어 명시적 동의가 필요합니다.
1078
+
1079
+ 다음 중 하나를 선택하세요:
1080
+ 1. \`confirm: true\` 인자를 추가해 재호출: ${toolName}(…, confirm: true)\n 2. 읽기 전용 도구(list_pages, list_console_messages, take_screenshot 등)를 사용하세요.
1081
+ 3. dogfood 빌드(relay-dev 환경)에서 먼저 검증 후 live에 적용하세요.
1082
+
1083
+ live-guard: MCP_ENV=relay-live + confirm: true missing`);
977
1084
  }
978
1085
  /**
979
1086
  * relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.
@@ -1541,7 +1648,9 @@ var ServerLockConflictError = class extends Error {
1541
1648
  existingPid;
1542
1649
  /** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
1543
1650
  existingWssUrl;
1544
- constructor(existingPid, existingWssUrl) {
1651
+ /** ISO timestamp from the existing lock — when that session started. */
1652
+ existingStartedAt;
1653
+ constructor(existingPid, existingWssUrl, existingStartedAt) {
1545
1654
  const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
1546
1655
  super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
1547
1656
  If it is already stopped but this error persists, remove the lock file:
@@ -1549,6 +1658,7 @@ If it is already stopped but this error persists, remove the lock file:
1549
1658
  this.name = "ServerLockConflictError";
1550
1659
  this.existingPid = existingPid;
1551
1660
  this.existingWssUrl = existingWssUrl;
1661
+ this.existingStartedAt = existingStartedAt;
1552
1662
  }
1553
1663
  };
1554
1664
  /** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
@@ -1601,6 +1711,29 @@ function removeLock(lockPath) {
1601
1711
  } catch {}
1602
1712
  }
1603
1713
  /**
1714
+ * Sends SIGTERM to `pid` and waits up to `graceMs` (default 2 000 ms) for it
1715
+ * to exit; then falls back to SIGKILL. Synchronous — uses a busy-wait loop so
1716
+ * it is usable in the top-level startup path without async plumbing.
1717
+ *
1718
+ * Ignores errors from `process.kill` so that a race where the target exits
1719
+ * between the alive check and the kill call does not crash the caller.
1720
+ */
1721
+ function killAndWait(pid, graceMs = 2e3) {
1722
+ try {
1723
+ process.kill(pid, "SIGTERM");
1724
+ } catch {
1725
+ return;
1726
+ }
1727
+ const deadline = Date.now() + graceMs;
1728
+ while (isPidAlive(pid) && Date.now() < deadline) {
1729
+ const end = Date.now() + 100;
1730
+ while (Date.now() < end);
1731
+ }
1732
+ if (isPidAlive(pid)) try {
1733
+ process.kill(pid, "SIGKILL");
1734
+ } catch {}
1735
+ }
1736
+ /**
1604
1737
  * Reads the current lock file without acquiring it. Returns the parsed
1605
1738
  * `LockData` when the file exists and is valid, otherwise `null`. Used by
1606
1739
  * `get_diagnostics` to surface the `serverLockHolder` field without
@@ -1614,18 +1747,28 @@ function readServerLock() {
1614
1747
  *
1615
1748
  * - If no lock exists (or the lock is stale): writes a new lock and returns a
1616
1749
  * `LockHandle` with `updateWssUrl` + `release`.
1617
- * - If a live process holds the lock: throws `ServerLockConflictError`.
1750
+ * - If a live process holds the lock and `force` is `false` (default): writes
1751
+ * a clear recovery message to stderr and throws `ServerLockConflictError`.
1752
+ * - If a live process holds the lock and `force` is `true`: sends SIGTERM to
1753
+ * that process (waiting up to 2 s then SIGKILL) and takes over the lock.
1618
1754
  *
1619
1755
  * The initial `wssUrl` in the lock file is `null` — call
1620
1756
  * `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
1621
1757
  */
1622
- function acquireLock() {
1758
+ function acquireLock(options = {}) {
1759
+ const { force = false } = options;
1623
1760
  const lockPath = lockFilePath();
1624
1761
  const existing = readLock(lockPath);
1625
- if (existing !== null) {
1626
- if (isPidAlive(existing.pid)) throw new ServerLockConflictError(existing.pid, existing.wssUrl);
1627
- process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
1762
+ if (existing !== null) if (isPidAlive(existing.pid)) if (force) {
1763
+ process.stderr.write(`[ait-debug] --force: terminating existing session PID=${existing.pid} …\n`);
1764
+ killAndWait(existing.pid);
1765
+ process.stderr.write(`[ait-debug] --force: PID=${existing.pid} stopped, taking over.\n`);
1766
+ } else {
1767
+ const urlPart = existing.wssUrl != null ? `wssUrl=${existing.wssUrl}` : "wssUrl=(tunnel starting)";
1768
+ process.stderr.write(`[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\n[ait-debug] 회복: \`kill ${existing.pid}\` 또는 \`npx @ait-co/devtools devtools-mcp --force\`\n`);
1769
+ throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);
1628
1770
  }
1771
+ else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
1629
1772
  const data = {
1630
1773
  pid: process.pid,
1631
1774
  wssUrl: null,
@@ -1915,6 +2058,83 @@ function warnPassthrough(name) {
1915
2058
  }
1916
2059
  SIGNATURES.map((s) => s.name);
1917
2060
  //#endregion
2061
+ //#region src/mcp/totp.ts
2062
+ /**
2063
+ * RFC 6238 TOTP implementation (Node.js, node:crypto only).
2064
+ *
2065
+ * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
2066
+ * to keep the dependency surface minimal. This hand-roll is ~30 lines and
2067
+ * covers exactly what relay-side auth needs.
2068
+ *
2069
+ * Algorithm summary (RFC 6238 + RFC 4226):
2070
+ * T = floor(now / 30) — 30-second time step counter
2071
+ * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
2072
+ * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
2073
+ * offset = MAC[19] & 0x0f
2074
+ * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
2075
+ *
2076
+ * Security note (keep this comment accurate):
2077
+ * The baked-in secret in a dogfood build is extractable from the bundle by a
2078
+ * determined reverse engineer. This mechanism raises the bar from
2079
+ * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
2080
+ * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
2081
+ * blocked; deliberate reverse engineering is not. See threat model in
2082
+ * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
2083
+ *
2084
+ * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
2085
+ * log, error message, or string visible outside this module. Only boolean
2086
+ * pass/fail and reason enum values are safe to surface.
2087
+ */
2088
+ /** Time step window in seconds (RFC 6238 default). */
2089
+ const TIME_STEP = 30;
2090
+ /** Number of digits in the generated code. */
2091
+ const DIGITS = 6;
2092
+ /**
2093
+ * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
2094
+ * clock time.
2095
+ *
2096
+ * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
2097
+ * bytes). Must be the output of `generateAttachToken()` or compatible.
2098
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2099
+ * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
2100
+ */
2101
+ function generateTotp(secret, when = Date.now()) {
2102
+ const key = Buffer.from(secret, "hex");
2103
+ const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
2104
+ const counterBuf = Buffer.alloc(8);
2105
+ const hi = Math.floor(counter / 4294967296);
2106
+ const lo = counter >>> 0;
2107
+ counterBuf.writeUInt32BE(hi, 0);
2108
+ counterBuf.writeUInt32BE(lo, 4);
2109
+ const mac = createHmac("sha1", key).update(counterBuf).digest();
2110
+ const offset = mac[19] & 15;
2111
+ return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
2112
+ }
2113
+ /**
2114
+ * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
2115
+ * tolerate clock drift between the relay host and the client device.
2116
+ *
2117
+ * Uses `timingSafeEqual` for constant-time comparison to prevent timing
2118
+ * side-channel attacks.
2119
+ *
2120
+ * @param secret - Hex-encoded shared secret.
2121
+ * @param code - The 6-digit code to verify (string or numeric).
2122
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2123
+ * @param skew - Number of adjacent steps to accept on either side. Default 1
2124
+ * (accepts T-1, T, T+1 — a 90-second acceptance window).
2125
+ * @returns `true` if the code matches any accepted step, `false` otherwise.
2126
+ */
2127
+ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
2128
+ const normalised = String(code).padStart(DIGITS, "0");
2129
+ if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
2130
+ const candidateBuf = Buffer.from(normalised, "utf8");
2131
+ for (let delta = -skew; delta <= skew; delta++) {
2132
+ const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
2133
+ if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
2134
+ }
2135
+ return false;
2136
+ }
2137
+ //#endregion
1918
2138
  //#region src/mcp/tools.ts
1919
2139
  /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
1920
2140
  const DEBUG_TOOL_DEFINITIONS = [
@@ -1940,7 +2160,7 @@ const DEBUG_TOOL_DEFINITIONS = [
1940
2160
  },
1941
2161
  {
1942
2162
  name: "list_pages",
1943
- description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug server with `npx @ait-co/devtools devtools-mcp`. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network.",
2163
+ description: "Returns the single active page (at most one) the relay sees attached. When a second page attaches, the previous one is evicted (last-attach wins — single-attach model). The result includes `singleAttachModel: true` so the agent knows the array is always 0 or 1 entries. Also returns whether the cloudflared tunnel is up and the public wss relay URL. The `tunnel` field includes `droppedAt` (ISO timestamp or null/undefined): when non-null the tunnel has permanently dropped after 3 failed reissue attempts — restart the debug server with `npx @ait-co/devtools devtools-mcp`. Each page entry includes a `lastSeenAt` ISO timestamp (last inbound CDP message from that target — useful to detect stale entries when the phone app backgrounded). The result also includes `crashDetectedAt` (ISO timestamp or null): when non-null, a page crash was detected via Inspector.targetCrashed / Target.targetDestroyed since the last attach, the pages list will be empty, and `crashWarning` shows a Korean hint to re-attach. Call this first to confirm a page is attached before reading console/network. When a page attaches or detaches the server emits notifications/tools/list_changed — call tools/list again to get the full updated tool surface.",
1944
2164
  inputSchema: {
1945
2165
  type: "object",
1946
2166
  properties: {},
@@ -1950,7 +2170,7 @@ const DEBUG_TOOL_DEFINITIONS = [
1950
2170
  },
1951
2171
  {
1952
2172
  name: "build_attach_url",
1953
- 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).",
2173
+ 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. If the tunnel is not up, restart the MCP server: `npx @ait-co/devtools devtools-mcp`. Set wait_for_attach=true to block until the phone scans and a page attaches (polls listTargets up to 30 s by default), then returns the attached page info too. On timeout, call build_attach_url again to resume polling. 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). Requires MCP_ENV=relay-dev or relay-live (set automatically in debug-mode default).\n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>) — the URL is single-use for that 30-second step. The response includes a `totp` field with `expiresAt` (ISO timestamp). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh one-time URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.",
1954
2174
  inputSchema: {
1955
2175
  type: "object",
1956
2176
  properties: {
@@ -1960,7 +2180,7 @@ const DEBUG_TOOL_DEFINITIONS = [
1960
2180
  },
1961
2181
  wait_for_attach: {
1962
2182
  type: "boolean",
1963
- 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."
2183
+ description: "If true, block after returning the QR until a page attaches to the relay (polls listTargets ~1 s interval, timeout 30 s). On attach, the response includes the attached page list. On timeout, call build_attach_url again to resume polling."
1964
2184
  },
1965
2185
  open_in_browser: {
1966
2186
  type: "boolean",
@@ -1993,7 +2213,7 @@ const DEBUG_TOOL_DEFINITIONS = [
1993
2213
  },
1994
2214
  {
1995
2215
  name: "take_screenshot",
1996
- 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.",
2216
+ 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 — this is the only debug tool that returns an image; all other debug tools return text (JSON).",
1997
2217
  inputSchema: {
1998
2218
  type: "object",
1999
2219
  properties: {},
@@ -2013,13 +2233,19 @@ const DEBUG_TOOL_DEFINITIONS = [
2013
2233
  },
2014
2234
  {
2015
2235
  name: "evaluate",
2016
- 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.",
2236
+ 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.\n\nSECURITY: expression and result are not redacted — never include secrets or auth tokens in the expression.\n\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the expression may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.",
2017
2237
  inputSchema: {
2018
2238
  type: "object",
2019
- properties: { expression: {
2020
- type: "string",
2021
- description: "JavaScript expression to evaluate in the page context."
2022
- } },
2239
+ properties: {
2240
+ expression: {
2241
+ type: "string",
2242
+ description: "JavaScript expression to evaluate in the page context."
2243
+ },
2244
+ confirm: {
2245
+ type: "boolean",
2246
+ description: "Required when MCP_ENV=relay-live. Set to `true` to explicitly acknowledge that this expression may have side effects on real/live users. Omitting this in a relay-live session results in a structured rejection error. Has no effect in mock or relay-dev sessions."
2247
+ }
2248
+ },
2023
2249
  required: ["expression"]
2024
2250
  },
2025
2251
  availableIn: "both"
@@ -2039,7 +2265,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2039
2265
  },
2040
2266
  {
2041
2267
  name: "call_sdk",
2042
- 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. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle).\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
2268
+ 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. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle) — redeploy via dogfood channel: `ait build && aitcc app deploy`.\n\nSECURITY: method name, args, and result value are not redacted — never include secrets.\n\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the SDK call may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
2043
2269
  inputSchema: {
2044
2270
  type: "object",
2045
2271
  properties: {
@@ -2051,6 +2277,10 @@ const DEBUG_TOOL_DEFINITIONS = [
2051
2277
  type: "array",
2052
2278
  description: "Arguments to pass to the SDK method (optional, default []).",
2053
2279
  items: {}
2280
+ },
2281
+ confirm: {
2282
+ type: "boolean",
2283
+ description: "Required when MCP_ENV=relay-live. Set to `true` to explicitly acknowledge that this SDK call may have side effects on real/live users. Omitting this in a relay-live session results in a structured rejection error. Has no effect in mock or relay-dev sessions."
2054
2284
  }
2055
2285
  },
2056
2286
  required: ["name"]
@@ -2089,7 +2319,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2089
2319
  },
2090
2320
  {
2091
2321
  name: "get_diagnostics",
2092
- description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (getEnvironment() result + reason), serverLockHolder (pid + startedAt from the lock file, or null). All fields are nullable — missing data is null, not an error. Tier C (both mock and relay). Call this first when debugging session state.",
2322
+ description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (kind: mock|relay-dev|relay-live, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay). Call this first when debugging session state.",
2093
2323
  inputSchema: {
2094
2324
  type: "object",
2095
2325
  properties: { recent_errors_limit: {
@@ -2118,20 +2348,26 @@ function getToolAvailability(name) {
2118
2348
  * Returns true when the named tool is available in the given environment.
2119
2349
  * Unknown tools return `false` — callers should reject them as unknown rather
2120
2350
  * than as env-mismatched.
2351
+ *
2352
+ * Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'`
2353
+ * availability tier — `isRelayEnv()` is used for the check.
2121
2354
  */
2122
2355
  function isToolAvailableIn(name, env) {
2123
2356
  const availability = getToolAvailability(name);
2124
2357
  if (availability === void 0) return false;
2125
2358
  if (availability === "both") return true;
2359
+ if (availability === "relay") return isRelayEnv(env);
2126
2360
  return availability === env;
2127
2361
  }
2128
2362
  /**
2129
2363
  * Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
2130
2364
  * matches the given env. Pure — preserves order; both Tier C ("both") and the
2131
2365
  * matching single-env tier pass through.
2366
+ *
2367
+ * Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'` tier.
2132
2368
  */
2133
2369
  function filterToolsByEnvironment(tools, env) {
2134
- return tools.filter((t) => t.availableIn === "both" || t.availableIn === env);
2370
+ return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
2135
2371
  }
2136
2372
  /**
2137
2373
  * Tool names that are available before any page attaches (bootstrap tier).
@@ -2250,20 +2486,50 @@ function listPages(connection, tunnel) {
2250
2486
  * URL plus this session's live relay. Throws if the tunnel is not up yet (no
2251
2487
  * relay URL to splice in) — the caller surfaces that as a tool error.
2252
2488
  *
2489
+ * When `AIT_DEBUG_TOTP_SECRET` is set, generates the current TOTP code and
2490
+ * splices it as `at=<code>` into the attach URL. The code is valid for one
2491
+ * 30-second time step (±1 skew accepted by the relay, so the effective window
2492
+ * is up to 90 s). If the scan happens after `totp.expiresAt`, call
2493
+ * `build_attach_url` again to get a fresh code.
2494
+ *
2253
2495
  * Also validates the scheme URL's authority. A suspicious authority (empty,
2254
2496
  * "web", "localhost", etc.) is surfaced as a non-fatal `authorityWarning` on
2255
2497
  * the result so the caller can show a helpful hint without blocking the link
2256
2498
  * generation (the warning is consistent with how other validation in
2257
2499
  * `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for
2258
2500
  * the scheme authority which is in the caller's input, not ours to own).
2501
+ *
2502
+ * SECRET-HANDLING: `totpSecret` (if provided) is used only to compute a code
2503
+ * and must never appear in any log, error message, or output outside of the
2504
+ * spliced `at=` param in `attachUrl`.
2505
+ *
2506
+ * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL.
2507
+ * @param tunnel - Current tunnel status from the running debug server.
2508
+ * @param totpSecret - Optional hex-encoded TOTP secret (from
2509
+ * `AIT_DEBUG_TOTP_SECRET`). When provided, the current code is spliced into
2510
+ * the attach URL as `at=<code>`.
2259
2511
  */
2260
- function buildAttachUrl(schemeUrl, tunnel) {
2512
+ function buildAttachUrl(schemeUrl, tunnel, totpSecret) {
2261
2513
  if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
2262
2514
  const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
2515
+ let totpCode;
2516
+ let totpMeta;
2517
+ if (totpSecret !== void 0 && totpSecret !== "") {
2518
+ const now = Date.now();
2519
+ totpCode = generateTotp(totpSecret, now);
2520
+ const STEP_SECONDS = 30;
2521
+ const expiresAtMs = (Math.floor(now / 1e3 / STEP_SECONDS) + 1) * STEP_SECONDS * 1e3;
2522
+ totpMeta = {
2523
+ enabled: true,
2524
+ ttlSeconds: STEP_SECONDS,
2525
+ expiresAt: new Date(expiresAtMs).toISOString()
2526
+ };
2527
+ }
2263
2528
  return {
2264
- attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
2529
+ attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl, totpCode),
2265
2530
  relayUrl: tunnel.wssUrl,
2266
- ...authorityWarning !== void 0 ? { authorityWarning } : {}
2531
+ ...authorityWarning !== void 0 ? { authorityWarning } : {},
2532
+ ...totpMeta !== void 0 ? { totp: totpMeta } : {}
2267
2533
  };
2268
2534
  }
2269
2535
  /**
@@ -2879,6 +3145,32 @@ function readDevtoolsVersion() {
2879
3145
  }
2880
3146
  }
2881
3147
  /**
3148
+ * Derives the next recommended action from a completed diagnostics snapshot.
3149
+ *
3150
+ * Branch rules (evaluated in priority order):
3151
+ * 1. tunnel.up === false → restart
3152
+ * 2. tunnel.up, pages empty, env === relay → build_attach_url (start attach)
3153
+ * 3. pages has entry + crashDetectedAt non-null → build_attach_url (re-attach after crash)
3154
+ * 4. otherwise → null (session looks healthy)
3155
+ *
3156
+ * Pure — does not throw; receives the final assembled snapshot fields.
3157
+ */
3158
+ function computeNextRecommendedAction(tunnel, pages, env) {
3159
+ if (!tunnel.up) return {
3160
+ tool: "restart",
3161
+ reason: "tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart"
3162
+ };
3163
+ if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
3164
+ tool: "build_attach_url",
3165
+ reason: "tunnel ready, no pages attached — call build_attach_url to generate the attach QR"
3166
+ };
3167
+ if (pages !== null && pages.crashDetectedAt !== null) return {
3168
+ tool: "build_attach_url",
3169
+ reason: `page crashed at ${pages.crashDetectedAt} — call build_attach_url to re-attach`
3170
+ };
3171
+ return null;
3172
+ }
3173
+ /**
2882
3174
  * Builds the `get_diagnostics` response. Pure — does not throw; missing data
2883
3175
  * fields are `null`. Async because `readMcpSdkVersion` needs `import()`.
2884
3176
  *
@@ -2909,6 +3201,7 @@ async function getDiagnostics(input) {
2909
3201
  } catch {}
2910
3202
  const limit = Math.min(Math.max(1, recentErrorsLimit), 50);
2911
3203
  const recentErrors = collector.getRecentErrors(limit);
3204
+ const nextRecommendedAction = computeNextRecommendedAction(tunnelInfo, pages, env);
2912
3205
  return {
2913
3206
  mcpVersion,
2914
3207
  devtoolsVersion,
@@ -2918,90 +3211,16 @@ async function getDiagnostics(input) {
2918
3211
  lastDetachAt: collector.getLastDetachAt(),
2919
3212
  recentErrors,
2920
3213
  environment: {
2921
- env,
2922
- reason: envReason
3214
+ kind: env,
3215
+ env: toLegacyEnv(env),
3216
+ reason: envReason,
3217
+ liveGuardActive: isLiveRelayEnv(env)
2923
3218
  },
2924
- serverLockHolder
3219
+ serverLockHolder,
3220
+ nextRecommendedAction
2925
3221
  };
2926
3222
  }
2927
3223
  //#endregion
2928
- //#region src/mcp/totp.ts
2929
- /**
2930
- * RFC 6238 TOTP implementation (Node.js, node:crypto only).
2931
- *
2932
- * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
2933
- * to keep the dependency surface minimal. This hand-roll is ~30 lines and
2934
- * covers exactly what relay-side auth needs.
2935
- *
2936
- * Algorithm summary (RFC 6238 + RFC 4226):
2937
- * T = floor(now / 30) — 30-second time step counter
2938
- * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
2939
- * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
2940
- * offset = MAC[19] & 0x0f
2941
- * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
2942
- *
2943
- * Security note (keep this comment accurate):
2944
- * The baked-in secret in a dogfood build is extractable from the bundle by a
2945
- * determined reverse engineer. This mechanism raises the bar from
2946
- * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
2947
- * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
2948
- * blocked; deliberate reverse engineering is not. See threat model in
2949
- * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
2950
- *
2951
- * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
2952
- * log, error message, or string visible outside this module. Only boolean
2953
- * pass/fail and reason enum values are safe to surface.
2954
- */
2955
- /** Time step window in seconds (RFC 6238 default). */
2956
- const TIME_STEP = 30;
2957
- /** Number of digits in the generated code. */
2958
- const DIGITS = 6;
2959
- /**
2960
- * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
2961
- * clock time.
2962
- *
2963
- * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
2964
- * bytes). Must be the output of `generateAttachToken()` or compatible.
2965
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2966
- * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
2967
- */
2968
- function generateTotp(secret, when = Date.now()) {
2969
- const key = Buffer.from(secret, "hex");
2970
- const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
2971
- const counterBuf = Buffer.alloc(8);
2972
- const hi = Math.floor(counter / 4294967296);
2973
- const lo = counter >>> 0;
2974
- counterBuf.writeUInt32BE(hi, 0);
2975
- counterBuf.writeUInt32BE(lo, 4);
2976
- const mac = createHmac("sha1", key).update(counterBuf).digest();
2977
- const offset = mac[19] & 15;
2978
- return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
2979
- }
2980
- /**
2981
- * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
2982
- * tolerate clock drift between the relay host and the client device.
2983
- *
2984
- * Uses `timingSafeEqual` for constant-time comparison to prevent timing
2985
- * side-channel attacks.
2986
- *
2987
- * @param secret - Hex-encoded shared secret.
2988
- * @param code - The 6-digit code to verify (string or numeric).
2989
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2990
- * @param skew - Number of adjacent steps to accept on either side. Default 1
2991
- * (accepts T-1, T, T+1 — a 90-second acceptance window).
2992
- * @returns `true` if the code matches any accepted step, `false` otherwise.
2993
- */
2994
- function verifyTotp(secret, code, when = Date.now(), skew = 1) {
2995
- const normalised = String(code).padStart(DIGITS, "0");
2996
- if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
2997
- const candidateBuf = Buffer.from(normalised, "utf8");
2998
- for (let delta = -skew; delta <= skew; delta++) {
2999
- const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
3000
- if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
3001
- }
3002
- return false;
3003
- }
3004
- //#endregion
3005
3224
  //#region src/mcp/tunnel.ts
3006
3225
  /**
3007
3226
  * cloudflared quick tunnel + attach banner for the debug-mode MCP server.
@@ -3374,13 +3593,19 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
3374
3593
  * naturally via `enableDomains`). The tier only controls visibility.
3375
3594
  */
3376
3595
  function createDebugServer(deps) {
3377
- const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep } = deps;
3378
- const resolveEnvironment = getEnvDep ?? (() => getEnvironment({ connection }));
3379
- const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({ connection }));
3596
+ const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, defaultEnv, totpSecret } = deps;
3597
+ const resolveEnvironment = getEnvDep ?? (() => getEnvironment({
3598
+ connection,
3599
+ defaultEnv
3600
+ }));
3601
+ const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({
3602
+ connection,
3603
+ defaultEnv
3604
+ }));
3380
3605
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
3381
3606
  const server = new Server({
3382
3607
  name: "ait-debug",
3383
- version: "0.1.44"
3608
+ version: "0.1.46"
3384
3609
  }, { capabilities: { tools: { listChanged: true } } });
3385
3610
  server.setRequestHandler(ListToolsRequestSchema, () => {
3386
3611
  const env = resolveEnvironment();
@@ -3424,7 +3649,7 @@ function createDebugServer(deps) {
3424
3649
  if (name === "get_diagnostics") try {
3425
3650
  const rawLimit = request.params.arguments?.recent_errors_limit;
3426
3651
  const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
3427
- return jsonResult$1(await getDiagnostics({
3652
+ const result = await getDiagnostics({
3428
3653
  tunnel: getTunnelStatus(),
3429
3654
  connection,
3430
3655
  env: resolveEnvironment(),
@@ -3432,7 +3657,9 @@ function createDebugServer(deps) {
3432
3657
  collector,
3433
3658
  readLock: readServerLock,
3434
3659
  recentErrorsLimit
3435
- }));
3660
+ });
3661
+ const attached = connection.listTargets().length > 0;
3662
+ return envelopeResult(result, name, resolveEnvironment(), attached);
3436
3663
  } catch (err) {
3437
3664
  return errorResult(err, name);
3438
3665
  }
@@ -3459,7 +3686,7 @@ function createDebugServer(deps) {
3459
3686
  return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
3460
3687
  };
3461
3688
  try {
3462
- const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
3689
+ const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), totpSecret);
3463
3690
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
3464
3691
  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).";
3465
3692
  const guiAvailable = canOpenBrowser();
@@ -3468,7 +3695,8 @@ function createDebugServer(deps) {
3468
3695
  const qrHeadless = await renderQr(attachUrl);
3469
3696
  const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
3470
3697
  attachUrl,
3471
- relayUrl
3698
+ relayUrl,
3699
+ ...totp ? { totp } : {}
3472
3700
  }, null, 2)}\n\n${qrHeadless}`;
3473
3701
  if (!waitForAttach) return { content: [{
3474
3702
  type: "text",
@@ -3504,7 +3732,8 @@ function createDebugServer(deps) {
3504
3732
  };
3505
3733
  const shortText = `${warningPrefix}${header}\n${JSON.stringify({
3506
3734
  relayUrl,
3507
- openResult
3735
+ openResult,
3736
+ ...totp ? { totp } : {}
3508
3737
  }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
3509
3738
  if (!waitForAttach) return { content: [{
3510
3739
  type: "text",
@@ -3543,7 +3772,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3543
3772
  const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
3544
3773
  attachUrl,
3545
3774
  relayUrl,
3546
- openResult
3775
+ openResult,
3776
+ ...totp ? { totp } : {}
3547
3777
  }, null, 2)}\n\n${qr}`;
3548
3778
  if (!waitForAttach) return { content: [{
3549
3779
  type: "text",
@@ -3571,7 +3801,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3571
3801
  const qr = await renderQr(attachUrl);
3572
3802
  const baseText = `${warningPrefix}${header}\n${JSON.stringify({
3573
3803
  attachUrl,
3574
- relayUrl
3804
+ relayUrl,
3805
+ ...totp ? { totp } : {}
3575
3806
  }, null, 2)}\n\n${qr}`;
3576
3807
  if (!waitForAttach) return { content: [{
3577
3808
  type: "text",
@@ -3606,7 +3837,9 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3606
3837
  if (connection instanceof ChiiCdpConnection) try {
3607
3838
  await connection.refreshTargets();
3608
3839
  } catch {}
3609
- return jsonResult$1(listPages(connection, getTunnelStatus()));
3840
+ const pagesData = listPages(connection, getTunnelStatus());
3841
+ const attached = connection.listTargets().length > 0;
3842
+ return envelopeResult(pagesData, name, resolveEnvironment(), attached);
3610
3843
  }
3611
3844
  return classifyEnableDomainError(err, name);
3612
3845
  }
@@ -3618,11 +3851,14 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3618
3851
  return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
3619
3852
  }
3620
3853
  case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
3621
- case "list_pages":
3854
+ case "list_pages": {
3622
3855
  if (connection instanceof ChiiCdpConnection) try {
3623
3856
  await connection.refreshTargets();
3624
3857
  } catch {}
3625
- return jsonResult$1(listPages(connection, getTunnelStatus()));
3858
+ const listPagesData = listPages(connection, getTunnelStatus());
3859
+ const listPagesAttached = connection.listTargets().length > 0;
3860
+ return envelopeResult(listPagesData, name, resolveEnvironment(), listPagesAttached);
3861
+ }
3626
3862
  case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
3627
3863
  case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
3628
3864
  case "take_screenshot": {
@@ -3633,19 +3869,27 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3633
3869
  mimeType: shot.mimeType
3634
3870
  }] };
3635
3871
  }
3636
- case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
3872
+ case "measure_safe_area": {
3873
+ const safeAreaData = await measureSafeArea(connection, resolveEnvironment());
3874
+ const safeAreaAttached = connection.listTargets().length > 0;
3875
+ return envelopeResult(safeAreaData, name, resolveEnvironment(), safeAreaAttached);
3876
+ }
3637
3877
  case "evaluate": {
3638
3878
  const expression = request.params.arguments?.expression;
3639
3879
  if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
3880
+ if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("evaluate");
3640
3881
  return jsonResult$1(await evaluate(connection, expression));
3641
3882
  }
3642
3883
  case "call_sdk": {
3643
3884
  const sdkName = request.params.arguments?.name;
3644
3885
  if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
3645
3886
  const rawArgs = request.params.arguments?.args;
3646
- const sdkResult = await callSdk(connection, sdkName, Array.isArray(rawArgs) ? rawArgs : []);
3887
+ const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
3888
+ if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
3889
+ const sdkResult = await callSdk(connection, sdkName, sdkArgs);
3647
3890
  if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
3648
- return jsonResult$1(sdkResult);
3891
+ const callSdkAttached = connection.listTargets().length > 0;
3892
+ return envelopeResult(sdkResult, name, resolveEnvironment(), callSdkAttached);
3649
3893
  }
3650
3894
  default: return unknownTool(name);
3651
3895
  }
@@ -3661,6 +3905,22 @@ function jsonResult$1(value) {
3661
3905
  text: JSON.stringify(value, null, 2)
3662
3906
  }] };
3663
3907
  }
3908
+ /**
3909
+ * Wraps `value` in a `ToolEnvelope` (when compat mode is off) and returns it
3910
+ * as a text content block. When `AIT_MCP_COMPAT=chrome-devtools` is set the
3911
+ * envelope is skipped and the raw value is returned — identical to `jsonResult`.
3912
+ */
3913
+ function envelopeResult(value, tool, env, attached) {
3914
+ const wrapped = wrapEnvelope(value, {
3915
+ tool,
3916
+ env,
3917
+ attached
3918
+ });
3919
+ return { content: [{
3920
+ type: "text",
3921
+ text: JSON.stringify(wrapped, null, 2)
3922
+ }] };
3923
+ }
3664
3924
  function unknownTool(name) {
3665
3925
  return mcpError(`알 수 없는 tool: ${name}`);
3666
3926
  }
@@ -3756,7 +4016,7 @@ function buildRelayVerifyAuth() {
3756
4016
  * 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
3757
4017
  */
3758
4018
  async function runDebugServer(options = {}) {
3759
- const lockHandle = acquireLock();
4019
+ const lockHandle = acquireLock({ force: options.force ?? false });
3760
4020
  const relayPort = options.relayPort ?? 0;
3761
4021
  const verifyAuth = buildRelayVerifyAuth();
3762
4022
  const totpEnabled = verifyAuth !== void 0;
@@ -3821,7 +4081,9 @@ async function runDebugServer(options = {}) {
3821
4081
  get qrHttpServer() {
3822
4082
  return qrServer;
3823
4083
  },
3824
- diagnosticsCollector
4084
+ diagnosticsCollector,
4085
+ defaultEnv: "relay-dev",
4086
+ ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
3825
4087
  });
3826
4088
  const transport = new StdioServerTransport();
3827
4089
  let closed = false;
@@ -3869,7 +4131,10 @@ async function runDebugServer(options = {}) {
3869
4131
  await server.connect(transport);
3870
4132
  attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
3871
4133
  diagnosticsCollector.recordAttach();
3872
- devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({ connection }));
4134
+ devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({
4135
+ connection,
4136
+ defaultEnv: "relay-dev"
4137
+ }));
3873
4138
  });
3874
4139
  }
3875
4140
  /**
@@ -3891,7 +4156,7 @@ async function runDebugServer(options = {}) {
3891
4156
  * expected and noted in the PR as an explicit out-of-scope follow-up.
3892
4157
  */
3893
4158
  async function runLocalDebugServer(options = {}) {
3894
- const lockHandle = acquireLock();
4159
+ const lockHandle = acquireLock({ force: options.force ?? false });
3895
4160
  const chromium = await launchChromium({
3896
4161
  port: options.cdpPort ?? 0,
3897
4162
  devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
@@ -3906,7 +4171,8 @@ async function runLocalDebugServer(options = {}) {
3906
4171
  const server = createDebugServer({
3907
4172
  connection,
3908
4173
  aitSource,
3909
- getTunnelStatus: () => tunnelStatus
4174
+ getTunnelStatus: () => tunnelStatus,
4175
+ defaultEnv: "mock"
3910
4176
  });
3911
4177
  const transport = new StdioServerTransport();
3912
4178
  let closed = false;
@@ -4011,6 +4277,27 @@ var HttpAitSource = class {
4011
4277
  * (dev). `devtools_get_mock_state` (the original devtools#130 name) is kept as a
4012
4278
  * backward-compatible alias of `AIT.getMockState`.
4013
4279
  *
4280
+ * Issue #305 (M2-1) — dev/debug tool-surface unification:
4281
+ * dev-mode now also exposes `list_pages`, `get_diagnostics`, `measure_safe_area`,
4282
+ * and `call_sdk` so the docs/qa/scenarios.md acceptance sequence
4283
+ * `list_pages → measure_safe_area → call_sdk` works in dev mode without
4284
+ * "Unknown tool" failures.
4285
+ *
4286
+ * - `list_pages` — shim: returns the Vite dev URL as a single-entry array.
4287
+ * - `get_diagnostics` — dumps dev-mode server state (endpoint URL, last fetch
4288
+ * error, reachability, mode/environment metadata).
4289
+ * - `measure_safe_area`— reads safeAreaInsets from the mock state snapshot
4290
+ * (source: 'mock-vite').
4291
+ * - `call_sdk` — reads mock state and builds a mock-equivalent result
4292
+ * using window.__ait.state for supported methods; returns
4293
+ * an explicit tier-filter error for methods that require
4294
+ * a live CDP bridge.
4295
+ * - CDP-only tools (`evaluate`, `take_screenshot`, `get_dom_document`,
4296
+ * `take_snapshot`, `list_console_messages`,
4297
+ * `list_network_requests`, `list_exceptions`) — return an
4298
+ * explicit tier-filter error explaining that CDP is unavailable
4299
+ * in dev-mode and pointing to `--mode=local` or `--mode=debug`.
4300
+ *
4014
4301
  * This module is reached via the `devtools-mcp --mode=dev` CLI entry (see
4015
4302
  * `cli.ts`); the default (no flag) bin mode is the debug-mode CDP/Chii server.
4016
4303
  *
@@ -4025,12 +4312,18 @@ var HttpAitSource = class {
4025
4312
  * }
4026
4313
  * }
4027
4314
  */
4315
+ /** Error message prefix for CDP-dependent tools called in dev-mode. */
4316
+ const CDP_UNAVAILABLE_IN_DEV_MODE = "dev-mode에서는 CDP 연결이 없어 이 도구를 사용할 수 없습니다. 실기기 또는 로컬 Chromium에 붙이려면 `devtools-mcp --mode=local` 또는 `devtools-mcp` (debug 모드 기본)로 전환하세요.";
4028
4317
  /**
4029
4318
  * Tool descriptors served by the dev-mode server.
4030
4319
  *
4031
4320
  * All dev-mode tools are Tier C (both envs) per RFC #277 — the dev-mode server
4032
4321
  * itself is the mock-side embodiment of those Tier C tools. `availableIn` is
4033
4322
  * declared so the surface stays consistent with the debug-mode registry.
4323
+ *
4324
+ * Issue #305: CDP-only tools are also listed with explicit descriptions so
4325
+ * agents do not get "Unknown tool" failures — they get a clear tier-filter
4326
+ * error message instead.
4034
4327
  */
4035
4328
  const DEV_TOOL_DEFINITIONS = [
4036
4329
  {
@@ -4072,30 +4365,290 @@ const DEV_TOOL_DEFINITIONS = [
4072
4365
  required: []
4073
4366
  },
4074
4367
  availableIn: "both"
4368
+ },
4369
+ {
4370
+ name: "list_pages",
4371
+ description: "dev-mode: returns the Vite dev server URL as a single-entry page list. No CDP relay is involved — `tunnel.up` is always false and `devMode: true` marks this as a shim result. Call this first to confirm the dev server is reachable. In debug mode (`devtools-mcp` / `--mode=local`) this returns real attached pages.",
4372
+ inputSchema: {
4373
+ type: "object",
4374
+ properties: {},
4375
+ required: []
4376
+ },
4377
+ availableIn: "both"
4378
+ },
4379
+ {
4380
+ name: "get_diagnostics",
4381
+ description: "dev-mode: returns server diagnostics — Vite endpoint URL, last fetch timestamp/error, mock state endpoint reachability, mode (\"dev\"), and environment metadata. Call this when the dev server connection is suspect. In debug mode this returns tunnel/relay/attach status instead.",
4382
+ inputSchema: {
4383
+ type: "object",
4384
+ properties: { recent_errors_limit: {
4385
+ type: "number",
4386
+ description: "Ignored in dev-mode (no error ring buffer). Present for schema parity."
4387
+ } },
4388
+ required: []
4389
+ },
4390
+ availableIn: "both"
4391
+ },
4392
+ {
4393
+ name: "measure_safe_area",
4394
+ description: "dev-mode: reads safe-area insets from the mock state snapshot via the Vite endpoint. Returns `{ source: \"mock-vite\", sdkInsets, sdkInsetsSource: \"window.__ait\", ... }`. Values reflect what the DevTools panel reports at the time of the last state push. In debug mode this runs a Runtime.evaluate CDP probe on the attached page.",
4395
+ inputSchema: {
4396
+ type: "object",
4397
+ properties: {},
4398
+ required: []
4399
+ },
4400
+ availableIn: "both"
4401
+ },
4402
+ {
4403
+ name: "call_sdk",
4404
+ description: "dev-mode: calls a mock SDK method via the Vite mock state endpoint. Supported methods read from window.__ait mock state (e.g. getOperationalEnvironment). Returns the same `{ok, value}` / `{ok, error}` envelope as debug mode. In debug mode this calls the real SDK via window.__sdkCall over CDP.",
4405
+ inputSchema: {
4406
+ type: "object",
4407
+ properties: {
4408
+ name: {
4409
+ type: "string",
4410
+ description: "Mock SDK method name to call (e.g. \"getOperationalEnvironment\")."
4411
+ },
4412
+ args: {
4413
+ type: "array",
4414
+ description: "Arguments (ignored in dev-mode mock path; present for schema parity).",
4415
+ items: {}
4416
+ }
4417
+ },
4418
+ required: ["name"]
4419
+ },
4420
+ availableIn: "both"
4421
+ },
4422
+ {
4423
+ name: "evaluate",
4424
+ description: "Evaluates an arbitrary JavaScript expression via CDP Runtime.evaluate. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug` for CDP access.",
4425
+ inputSchema: {
4426
+ type: "object",
4427
+ properties: { expression: {
4428
+ type: "string",
4429
+ description: "JavaScript expression to evaluate."
4430
+ } },
4431
+ required: ["expression"]
4432
+ },
4433
+ availableIn: "both"
4434
+ },
4435
+ {
4436
+ name: "take_screenshot",
4437
+ description: "Captures a PNG screenshot via CDP Page.captureScreenshot. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
4438
+ inputSchema: {
4439
+ type: "object",
4440
+ properties: {},
4441
+ required: []
4442
+ },
4443
+ availableIn: "both"
4444
+ },
4445
+ {
4446
+ name: "get_dom_document",
4447
+ description: "Returns the DOM tree via CDP DOM.getDocument. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
4448
+ inputSchema: {
4449
+ type: "object",
4450
+ properties: {},
4451
+ required: []
4452
+ },
4453
+ availableIn: "both"
4454
+ },
4455
+ {
4456
+ name: "take_snapshot",
4457
+ description: "Captures a serialized page snapshot via CDP DOMSnapshot.captureSnapshot. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
4458
+ inputSchema: {
4459
+ type: "object",
4460
+ properties: {},
4461
+ required: []
4462
+ },
4463
+ availableIn: "both"
4464
+ },
4465
+ {
4466
+ name: "list_console_messages",
4467
+ description: "Lists console messages captured via CDP Runtime.consoleAPICalled. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
4468
+ inputSchema: {
4469
+ type: "object",
4470
+ properties: {},
4471
+ required: []
4472
+ },
4473
+ availableIn: "both"
4474
+ },
4475
+ {
4476
+ name: "list_network_requests",
4477
+ description: "Lists network requests captured via CDP Network events. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
4478
+ inputSchema: {
4479
+ type: "object",
4480
+ properties: {},
4481
+ required: []
4482
+ },
4483
+ availableIn: "both"
4484
+ },
4485
+ {
4486
+ name: "list_exceptions",
4487
+ description: "Lists JS exceptions captured via CDP Runtime.exceptionThrown. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
4488
+ inputSchema: {
4489
+ type: "object",
4490
+ properties: { limit: {
4491
+ type: "number",
4492
+ description: "Maximum exceptions to return."
4493
+ } },
4494
+ required: []
4495
+ },
4496
+ availableIn: "both"
4075
4497
  }
4076
4498
  ];
4499
+ /** All tool names served in dev-mode (including tier-filter stubs). */
4077
4500
  const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
4501
+ /** CDP-only tools — return a tier-filter error in dev-mode. */
4502
+ const CDP_ONLY_TOOL_NAMES = new Set([
4503
+ "evaluate",
4504
+ "take_screenshot",
4505
+ "get_dom_document",
4506
+ "take_snapshot",
4507
+ "list_console_messages",
4508
+ "list_network_requests",
4509
+ "list_exceptions"
4510
+ ]);
4511
+ /**
4512
+ * Builds the `list_pages` dev-mode shim response.
4513
+ * Returns the Vite dev URL as a single-entry page list with `devMode: true`.
4514
+ */
4515
+ function buildDevListPagesResult(devtoolsUrl) {
4516
+ return {
4517
+ pages: [{
4518
+ url: devtoolsUrl,
4519
+ title: "dev fixture",
4520
+ attached: true
4521
+ }],
4522
+ tunnel: { up: false },
4523
+ devMode: true,
4524
+ singleAttachModel: true
4525
+ };
4526
+ }
4527
+ /**
4528
+ * Builds the `get_diagnostics` dev-mode response.
4529
+ * Probes the mock state endpoint reachability and returns server metadata.
4530
+ */
4531
+ async function buildDevDiagnostics(devtoolsUrl, stateEndpoint, fetchImpl) {
4532
+ let reachable = false;
4533
+ let lastFetchError = null;
4534
+ let lastFetchAt = null;
4535
+ try {
4536
+ const res = await fetchImpl(stateEndpoint);
4537
+ reachable = res.ok;
4538
+ lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
4539
+ if (!res.ok) lastFetchError = `HTTP ${res.status} ${res.statusText}`;
4540
+ } catch (err) {
4541
+ lastFetchError = err instanceof Error ? err.message : String(err);
4542
+ lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
4543
+ }
4544
+ return {
4545
+ mode: "dev",
4546
+ devtoolsUrl,
4547
+ mcpStateEndpoint: stateEndpoint,
4548
+ mockStateEndpointReachable: reachable,
4549
+ lastFetchAt,
4550
+ lastFetchError,
4551
+ environment: {
4552
+ kind: "mock",
4553
+ reason: "dev-mode — Vite HTTP endpoint, no CDP connection"
4554
+ },
4555
+ nextRecommendedAction: reachable ? null : "mock state endpoint가 응답하지 않습니다. Vite dev 서버가 `mcp: true` 옵션으로 실행 중인지 확인하고, 필요하면 dev 서버를 재시작하세요."
4556
+ };
4557
+ }
4558
+ /**
4559
+ * Builds the `measure_safe_area` dev-mode response from mock state.
4560
+ * Reads `safeAreaInsets` from the AIT mock state and returns a parity-schema
4561
+ * result with `source: 'mock-vite'`.
4562
+ */
4563
+ async function buildDevMeasureSafeArea(aitSource) {
4564
+ const rawInsets = (await aitSource.get("AIT.getMockState")).safeAreaInsets;
4565
+ let sdkInsets = null;
4566
+ if (rawInsets !== null && typeof rawInsets === "object" && !Array.isArray(rawInsets)) {
4567
+ const r = rawInsets;
4568
+ sdkInsets = {
4569
+ top: typeof r.top === "number" ? r.top : 0,
4570
+ right: typeof r.right === "number" ? r.right : 0,
4571
+ bottom: typeof r.bottom === "number" ? r.bottom : 0,
4572
+ left: typeof r.left === "number" ? r.left : 0
4573
+ };
4574
+ }
4575
+ return {
4576
+ source: "mock-vite",
4577
+ cssEnv: {
4578
+ top: 0,
4579
+ right: 0,
4580
+ bottom: 0,
4581
+ left: 0
4582
+ },
4583
+ sdkInsets,
4584
+ sdkInsetsSource: sdkInsets !== null ? "window.__ait" : null,
4585
+ ...sdkInsets === null ? { sdkInsetsError: "window.__ait.state.safeAreaInsets not found in mock state snapshot" } : {},
4586
+ innerWidth: null,
4587
+ innerHeight: null,
4588
+ devicePixelRatio: null,
4589
+ userAgent: null,
4590
+ navBarHeight: null,
4591
+ navBarHeightSource: "not-available-in-dev-mode"
4592
+ };
4593
+ }
4594
+ /**
4595
+ * Builds the `call_sdk` dev-mode response.
4596
+ *
4597
+ * Supported methods are served from the mock state snapshot. Unsupported
4598
+ * methods return `{ ok: false, error: 'dev-mode-unsupported: ...' }` so the
4599
+ * agent gets an informative message rather than a generic failure.
4600
+ */
4601
+ async function buildDevCallSdk(methodName, aitSource) {
4602
+ switch (methodName) {
4603
+ case "getOperationalEnvironment": {
4604
+ const env = await aitSource.get("AIT.getOperationalEnvironment");
4605
+ return {
4606
+ ok: true,
4607
+ value: {
4608
+ environment: env.environment,
4609
+ sdkVersion: env.sdkVersion
4610
+ }
4611
+ };
4612
+ }
4613
+ default: return {
4614
+ ok: false,
4615
+ error: `dev-mode-unsupported: "${methodName}"은 dev-mode에서 직접 호출할 수 없습니다. CDP bridge(window.__sdkCall)가 없으므로 실제 SDK 호출은 \`--mode=local\` 또는 debug 모드에서만 가능합니다. 지원 메서드: getOperationalEnvironment (mock state에서 읽음).`
4616
+ };
4617
+ }
4618
+ }
4078
4619
  /** Builds the dev-mode MCP server (does not connect a transport). */
4079
4620
  function createDevServer(deps = {}) {
4080
- const stateEndpoint = `${process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"}/api/ait-devtools/state`;
4621
+ const devtoolsUrl = process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
4622
+ const stateEndpoint = `${devtoolsUrl}/api/ait-devtools/state`;
4081
4623
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
4082
4624
  const server = new Server({
4083
4625
  name: "ait-devtools",
4084
- version: "0.1.44"
4626
+ version: "0.1.46"
4085
4627
  }, { capabilities: { tools: {} } });
4086
4628
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
4087
4629
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
4088
4630
  const name = request.params.name;
4089
4631
  if (!DEV_TOOL_NAMES.has(name)) return mcpError(`알 수 없는 tool: ${name}`);
4632
+ if (CDP_ONLY_TOOL_NAMES.has(name)) return mcpError(`${name}: ${CDP_UNAVAILABLE_IN_DEV_MODE}`);
4090
4633
  try {
4091
4634
  const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
4092
- if (!isAitToolName(effective)) return mcpError(`알 수 없는 tool: ${name}`);
4093
- switch (effective) {
4635
+ if (isAitToolName(effective)) switch (effective) {
4094
4636
  case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
4095
4637
  case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
4096
4638
  case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
4097
4639
  default: return mcpError(`알 수 없는 tool: ${name}`);
4098
4640
  }
4641
+ switch (name) {
4642
+ case "list_pages": return jsonResult(buildDevListPagesResult(devtoolsUrl));
4643
+ case "get_diagnostics": return jsonResult(await buildDevDiagnostics(devtoolsUrl, stateEndpoint, (url) => fetch(url)));
4644
+ case "measure_safe_area": return jsonResult(await buildDevMeasureSafeArea(aitSource));
4645
+ case "call_sdk": {
4646
+ const sdkName = request.params.arguments?.name;
4647
+ if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 메서드 이름을 전달하세요.");
4648
+ return jsonResult(await buildDevCallSdk(sdkName, aitSource));
4649
+ }
4650
+ default: return mcpError(`알 수 없는 tool: ${name}`);
4651
+ }
4099
4652
  } catch (err) {
4100
4653
  return mcpError(`${name} 실패: ${err instanceof Error ? err.message : String(err)}\nVite dev 서버가 @ait-co/devtools unplugin \`mcp: true\` 옵션으로 실행 중인지 확인하세요. AIT_DEVTOOLS_URL 환경변수가 올바르게 설정됐는지도 확인하세요.`);
4101
4654
  }
@@ -4135,6 +4688,15 @@ async function runDevServer() {
4135
4688
  *
4136
4689
  * Node-only stdio process.
4137
4690
  */
4691
+ /**
4692
+ * Returns `true` when `--force` or `--takeover` is present in argv.
4693
+ *
4694
+ * Both flags are accepted as aliases — `--force` is the short form listed in
4695
+ * the `--help` output; `--takeover` is a longer synonym.
4696
+ */
4697
+ function parseForce(argv) {
4698
+ return argv.includes("--force") || argv.includes("--takeover");
4699
+ }
4138
4700
  /** Parses `--mode=<value>` / `--mode <value>` from argv; default `debug`. */
4139
4701
  function parseMode(argv) {
4140
4702
  for (let i = 0; i < argv.length; i++) {
@@ -4182,8 +4744,12 @@ function normalizeTarget(value) {
4182
4744
  async function main() {
4183
4745
  const args = process.argv.slice(2);
4184
4746
  if (parseMode(args) === "dev") await runDevServer();
4185
- else if (parseTarget(args) === "local") await runLocalDebugServer();
4186
- else await runDebugServer();
4747
+ else {
4748
+ const target = parseTarget(args);
4749
+ const force = parseForce(args);
4750
+ if (target === "local") await runLocalDebugServer({ force });
4751
+ else await runDebugServer({ force });
4752
+ }
4187
4753
  }
4188
4754
  /**
4189
4755
  * True when this file is the process entry (the bin), not an import.
@@ -4210,6 +4776,6 @@ if (isEntrypoint()) main().catch((err) => {
4210
4776
  process.exitCode = 1;
4211
4777
  });
4212
4778
  //#endregion
4213
- export { parseMode, parseTarget };
4779
+ export { parseForce, parseMode, parseTarget };
4214
4780
 
4215
4781
  //# sourceMappingURL=cli.js.map