@ait-co/devtools 0.1.45 → 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/README.en.md CHANGED
@@ -101,6 +101,10 @@ The page on the phone died (OOM, JS exception, or native bridge crash). Relaunch
101
101
 
102
102
  When `call_sdk` returns `ok: false, error: "window.__sdkCall is not available"`, a non-dogfood bundle is loaded. Redeploy through the dogfood channel with the `__DEBUG_BUILD__` flag enabled and try again. This error is the expected result in environment 2 (PWA). (Related: [#285](https://github.com/apps-in-toss-community/devtools/issues/285))
103
103
 
104
+ **"QR scanned but auth rejected" — TOTP code expired**
105
+
106
+ When `AIT_DEBUG_TOTP_SECRET` is set, `build_attach_url` automatically splices the current one-time TOTP code (`at=`) into the returned `attachUrl`. The code is valid for a 30-second step. Scanning after `totp.expiresAt` causes the relay to reject the request. Fix: call `build_attach_url` again to get a fresh URL and QR, then scan within 30 seconds.
107
+
104
108
  ---
105
109
 
106
110
  ## Install
package/README.md CHANGED
@@ -101,6 +101,10 @@ cloudflared quick tunnel은 수 시간 후 drop될 수 있습니다. `devtools-m
101
101
 
102
102
  `call_sdk` 호출 시 `ok: false, error: "window.__sdkCall is not available"` 에러가 뜨면 dogfood 빌드가 아닌 일반 번들이 로드된 것입니다. `__DEBUG_BUILD__` 플래그가 켜진 dogfood 채널로 재배포 후 다시 시도하세요. 환경 2(PWA)에서는 이 에러가 예상 결과입니다. (관련: [#285](https://github.com/apps-in-toss-community/devtools/issues/285))
103
103
 
104
+ **"QR 스캔했는데 인증 실패" — TOTP 만료**
105
+
106
+ `AIT_DEBUG_TOTP_SECRET` 설정 시 `build_attach_url`이 반환하는 attachUrl에는 30초 유효 TOTP 코드(`at=`)가 자동으로 포함됩니다. 응답의 `totp.expiresAt` 이후 스캔하면 relay가 인증을 거부합니다. 해결: `build_attach_url`을 재호출해 새 URL과 QR을 발급받은 뒤 30초 이내에 스캔하세요.
107
+
104
108
  ---
105
109
 
106
110
  ## 설치
package/dist/mcp/cli.js CHANGED
@@ -848,6 +848,51 @@ 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
  /**
853
898
  * Returns `true` when the environment is any relay variant (`relay-dev` or
@@ -2013,6 +2058,83 @@ function warnPassthrough(name) {
2013
2058
  }
2014
2059
  SIGNATURES.map((s) => s.name);
2015
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
2016
2138
  //#region src/mcp/tools.ts
2017
2139
  /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
2018
2140
  const DEBUG_TOOL_DEFINITIONS = [
@@ -2048,7 +2170,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2048
2170
  },
2049
2171
  {
2050
2172
  name: "build_attach_url",
2051
- 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 (set automatically when a relay tunnel is detected).",
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.",
2052
2174
  inputSchema: {
2053
2175
  type: "object",
2054
2176
  properties: {
@@ -2364,20 +2486,50 @@ function listPages(connection, tunnel) {
2364
2486
  * URL plus this session's live relay. Throws if the tunnel is not up yet (no
2365
2487
  * relay URL to splice in) — the caller surfaces that as a tool error.
2366
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
+ *
2367
2495
  * Also validates the scheme URL's authority. A suspicious authority (empty,
2368
2496
  * "web", "localhost", etc.) is surfaced as a non-fatal `authorityWarning` on
2369
2497
  * the result so the caller can show a helpful hint without blocking the link
2370
2498
  * generation (the warning is consistent with how other validation in
2371
2499
  * `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for
2372
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>`.
2373
2511
  */
2374
- function buildAttachUrl(schemeUrl, tunnel) {
2512
+ function buildAttachUrl(schemeUrl, tunnel, totpSecret) {
2375
2513
  if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
2376
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
+ }
2377
2528
  return {
2378
- attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
2529
+ attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl, totpCode),
2379
2530
  relayUrl: tunnel.wssUrl,
2380
- ...authorityWarning !== void 0 ? { authorityWarning } : {}
2531
+ ...authorityWarning !== void 0 ? { authorityWarning } : {},
2532
+ ...totpMeta !== void 0 ? { totp: totpMeta } : {}
2381
2533
  };
2382
2534
  }
2383
2535
  /**
@@ -3069,83 +3221,6 @@ async function getDiagnostics(input) {
3069
3221
  };
3070
3222
  }
3071
3223
  //#endregion
3072
- //#region src/mcp/totp.ts
3073
- /**
3074
- * RFC 6238 TOTP implementation (Node.js, node:crypto only).
3075
- *
3076
- * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
3077
- * to keep the dependency surface minimal. This hand-roll is ~30 lines and
3078
- * covers exactly what relay-side auth needs.
3079
- *
3080
- * Algorithm summary (RFC 6238 + RFC 4226):
3081
- * T = floor(now / 30) — 30-second time step counter
3082
- * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
3083
- * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
3084
- * offset = MAC[19] & 0x0f
3085
- * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
3086
- *
3087
- * Security note (keep this comment accurate):
3088
- * The baked-in secret in a dogfood build is extractable from the bundle by a
3089
- * determined reverse engineer. This mechanism raises the bar from
3090
- * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
3091
- * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
3092
- * blocked; deliberate reverse engineering is not. See threat model in
3093
- * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
3094
- *
3095
- * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
3096
- * log, error message, or string visible outside this module. Only boolean
3097
- * pass/fail and reason enum values are safe to surface.
3098
- */
3099
- /** Time step window in seconds (RFC 6238 default). */
3100
- const TIME_STEP = 30;
3101
- /** Number of digits in the generated code. */
3102
- const DIGITS = 6;
3103
- /**
3104
- * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
3105
- * clock time.
3106
- *
3107
- * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
3108
- * bytes). Must be the output of `generateAttachToken()` or compatible.
3109
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
3110
- * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
3111
- */
3112
- function generateTotp(secret, when = Date.now()) {
3113
- const key = Buffer.from(secret, "hex");
3114
- const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
3115
- const counterBuf = Buffer.alloc(8);
3116
- const hi = Math.floor(counter / 4294967296);
3117
- const lo = counter >>> 0;
3118
- counterBuf.writeUInt32BE(hi, 0);
3119
- counterBuf.writeUInt32BE(lo, 4);
3120
- const mac = createHmac("sha1", key).update(counterBuf).digest();
3121
- const offset = mac[19] & 15;
3122
- 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");
3123
- }
3124
- /**
3125
- * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
3126
- * tolerate clock drift between the relay host and the client device.
3127
- *
3128
- * Uses `timingSafeEqual` for constant-time comparison to prevent timing
3129
- * side-channel attacks.
3130
- *
3131
- * @param secret - Hex-encoded shared secret.
3132
- * @param code - The 6-digit code to verify (string or numeric).
3133
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
3134
- * @param skew - Number of adjacent steps to accept on either side. Default 1
3135
- * (accepts T-1, T, T+1 — a 90-second acceptance window).
3136
- * @returns `true` if the code matches any accepted step, `false` otherwise.
3137
- */
3138
- function verifyTotp(secret, code, when = Date.now(), skew = 1) {
3139
- const normalised = String(code).padStart(DIGITS, "0");
3140
- if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
3141
- const candidateBuf = Buffer.from(normalised, "utf8");
3142
- for (let delta = -skew; delta <= skew; delta++) {
3143
- const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
3144
- if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
3145
- }
3146
- return false;
3147
- }
3148
- //#endregion
3149
3224
  //#region src/mcp/tunnel.ts
3150
3225
  /**
3151
3226
  * cloudflared quick tunnel + attach banner for the debug-mode MCP server.
@@ -3518,7 +3593,7 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
3518
3593
  * naturally via `enableDomains`). The tier only controls visibility.
3519
3594
  */
3520
3595
  function createDebugServer(deps) {
3521
- const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, defaultEnv } = deps;
3596
+ const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, defaultEnv, totpSecret } = deps;
3522
3597
  const resolveEnvironment = getEnvDep ?? (() => getEnvironment({
3523
3598
  connection,
3524
3599
  defaultEnv
@@ -3530,7 +3605,7 @@ function createDebugServer(deps) {
3530
3605
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
3531
3606
  const server = new Server({
3532
3607
  name: "ait-debug",
3533
- version: "0.1.45"
3608
+ version: "0.1.46"
3534
3609
  }, { capabilities: { tools: { listChanged: true } } });
3535
3610
  server.setRequestHandler(ListToolsRequestSchema, () => {
3536
3611
  const env = resolveEnvironment();
@@ -3574,7 +3649,7 @@ function createDebugServer(deps) {
3574
3649
  if (name === "get_diagnostics") try {
3575
3650
  const rawLimit = request.params.arguments?.recent_errors_limit;
3576
3651
  const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
3577
- return jsonResult$1(await getDiagnostics({
3652
+ const result = await getDiagnostics({
3578
3653
  tunnel: getTunnelStatus(),
3579
3654
  connection,
3580
3655
  env: resolveEnvironment(),
@@ -3582,7 +3657,9 @@ function createDebugServer(deps) {
3582
3657
  collector,
3583
3658
  readLock: readServerLock,
3584
3659
  recentErrorsLimit
3585
- }));
3660
+ });
3661
+ const attached = connection.listTargets().length > 0;
3662
+ return envelopeResult(result, name, resolveEnvironment(), attached);
3586
3663
  } catch (err) {
3587
3664
  return errorResult(err, name);
3588
3665
  }
@@ -3609,7 +3686,7 @@ function createDebugServer(deps) {
3609
3686
  return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
3610
3687
  };
3611
3688
  try {
3612
- const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
3689
+ const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), totpSecret);
3613
3690
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
3614
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).";
3615
3692
  const guiAvailable = canOpenBrowser();
@@ -3618,7 +3695,8 @@ function createDebugServer(deps) {
3618
3695
  const qrHeadless = await renderQr(attachUrl);
3619
3696
  const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
3620
3697
  attachUrl,
3621
- relayUrl
3698
+ relayUrl,
3699
+ ...totp ? { totp } : {}
3622
3700
  }, null, 2)}\n\n${qrHeadless}`;
3623
3701
  if (!waitForAttach) return { content: [{
3624
3702
  type: "text",
@@ -3654,7 +3732,8 @@ function createDebugServer(deps) {
3654
3732
  };
3655
3733
  const shortText = `${warningPrefix}${header}\n${JSON.stringify({
3656
3734
  relayUrl,
3657
- openResult
3735
+ openResult,
3736
+ ...totp ? { totp } : {}
3658
3737
  }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
3659
3738
  if (!waitForAttach) return { content: [{
3660
3739
  type: "text",
@@ -3693,7 +3772,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3693
3772
  const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
3694
3773
  attachUrl,
3695
3774
  relayUrl,
3696
- openResult
3775
+ openResult,
3776
+ ...totp ? { totp } : {}
3697
3777
  }, null, 2)}\n\n${qr}`;
3698
3778
  if (!waitForAttach) return { content: [{
3699
3779
  type: "text",
@@ -3721,7 +3801,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3721
3801
  const qr = await renderQr(attachUrl);
3722
3802
  const baseText = `${warningPrefix}${header}\n${JSON.stringify({
3723
3803
  attachUrl,
3724
- relayUrl
3804
+ relayUrl,
3805
+ ...totp ? { totp } : {}
3725
3806
  }, null, 2)}\n\n${qr}`;
3726
3807
  if (!waitForAttach) return { content: [{
3727
3808
  type: "text",
@@ -3756,7 +3837,9 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3756
3837
  if (connection instanceof ChiiCdpConnection) try {
3757
3838
  await connection.refreshTargets();
3758
3839
  } catch {}
3759
- 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);
3760
3843
  }
3761
3844
  return classifyEnableDomainError(err, name);
3762
3845
  }
@@ -3768,11 +3851,14 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3768
3851
  return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
3769
3852
  }
3770
3853
  case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
3771
- case "list_pages":
3854
+ case "list_pages": {
3772
3855
  if (connection instanceof ChiiCdpConnection) try {
3773
3856
  await connection.refreshTargets();
3774
3857
  } catch {}
3775
- 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
+ }
3776
3862
  case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
3777
3863
  case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
3778
3864
  case "take_screenshot": {
@@ -3783,7 +3869,11 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3783
3869
  mimeType: shot.mimeType
3784
3870
  }] };
3785
3871
  }
3786
- 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
+ }
3787
3877
  case "evaluate": {
3788
3878
  const expression = request.params.arguments?.expression;
3789
3879
  if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
@@ -3798,7 +3888,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3798
3888
  if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
3799
3889
  const sdkResult = await callSdk(connection, sdkName, sdkArgs);
3800
3890
  if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
3801
- return jsonResult$1(sdkResult);
3891
+ const callSdkAttached = connection.listTargets().length > 0;
3892
+ return envelopeResult(sdkResult, name, resolveEnvironment(), callSdkAttached);
3802
3893
  }
3803
3894
  default: return unknownTool(name);
3804
3895
  }
@@ -3814,6 +3905,22 @@ function jsonResult$1(value) {
3814
3905
  text: JSON.stringify(value, null, 2)
3815
3906
  }] };
3816
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
+ }
3817
3924
  function unknownTool(name) {
3818
3925
  return mcpError(`알 수 없는 tool: ${name}`);
3819
3926
  }
@@ -3975,7 +4082,8 @@ async function runDebugServer(options = {}) {
3975
4082
  return qrServer;
3976
4083
  },
3977
4084
  diagnosticsCollector,
3978
- defaultEnv: "relay-dev"
4085
+ defaultEnv: "relay-dev",
4086
+ ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
3979
4087
  });
3980
4088
  const transport = new StdioServerTransport();
3981
4089
  let closed = false;
@@ -4515,7 +4623,7 @@ function createDevServer(deps = {}) {
4515
4623
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
4516
4624
  const server = new Server({
4517
4625
  name: "ait-devtools",
4518
- version: "0.1.45"
4626
+ version: "0.1.46"
4519
4627
  }, { capabilities: { tools: {} } });
4520
4628
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
4521
4629
  server.setRequestHandler(CallToolRequestSchema, async (request) => {