@ait-co/devtools 0.1.43 → 0.1.45
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 +97 -7
- package/README.md +115 -7
- package/dist/mcp/cli.d.ts +8 -1
- package/dist/mcp/cli.d.ts.map +1 -1
- package/dist/mcp/cli.js +1163 -176
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +343 -41
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.js +2 -2
- package/package.json +1 -1
package/dist/mcp/cli.js
CHANGED
|
@@ -53,6 +53,98 @@ var ChiiAitSource = class {
|
|
|
53
53
|
}
|
|
54
54
|
};
|
|
55
55
|
//#endregion
|
|
56
|
+
//#region src/mcp/log.ts
|
|
57
|
+
/**
|
|
58
|
+
* Allowed field keys that may pass through to a log line.
|
|
59
|
+
* Unknown keys are dropped. Values are still redact-scanned.
|
|
60
|
+
*/
|
|
61
|
+
const ALLOWED_KEYS = new Set([
|
|
62
|
+
"ts",
|
|
63
|
+
"level",
|
|
64
|
+
"event",
|
|
65
|
+
"msg",
|
|
66
|
+
"port",
|
|
67
|
+
"totpEnabled",
|
|
68
|
+
"env",
|
|
69
|
+
"tool",
|
|
70
|
+
"deploymentId",
|
|
71
|
+
"errorKind",
|
|
72
|
+
"reason",
|
|
73
|
+
"prevTargetId",
|
|
74
|
+
"mode"
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* Patterns that match secret values.
|
|
78
|
+
* Match order matters — more-specific patterns first.
|
|
79
|
+
*
|
|
80
|
+
* #268 redact script covers: relay=wss://…, at=<TOTP>, _deploymentId=<uuid>.
|
|
81
|
+
* Here we extend to in-process value-level patterns used in server logs.
|
|
82
|
+
*/
|
|
83
|
+
const SECRET_PATTERNS = [
|
|
84
|
+
/^\d{6}$/,
|
|
85
|
+
/^(aitcc_|AITCC_)/i,
|
|
86
|
+
/^[A-Za-z0-9_-]+=.{4,}/,
|
|
87
|
+
/^wss:\/\//,
|
|
88
|
+
/(?:^|[?&])at=[A-Z0-9]{6}/i
|
|
89
|
+
];
|
|
90
|
+
/**
|
|
91
|
+
* Returns `true` when the string value matches any known-secret pattern.
|
|
92
|
+
* Only string values are tested — numbers/booleans are always safe.
|
|
93
|
+
*/
|
|
94
|
+
function isSecretValue(value) {
|
|
95
|
+
return SECRET_PATTERNS.some((re) => re.test(value));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Redacts a single scalar value.
|
|
99
|
+
* - strings: return "***" if the value matches a secret pattern.
|
|
100
|
+
* - other: return as-is.
|
|
101
|
+
*/
|
|
102
|
+
function redactValue(value) {
|
|
103
|
+
if (typeof value === "string" && isSecretValue(value)) return "***";
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Builds a safe log payload from raw fields.
|
|
108
|
+
*
|
|
109
|
+
* - Only keys in `ALLOWED_KEYS` are included.
|
|
110
|
+
* - String values are scanned for secret patterns and replaced with "***".
|
|
111
|
+
* - `ts` and `level` and `event` are always included (they are injected by the
|
|
112
|
+
* logger functions below, not by callers).
|
|
113
|
+
*/
|
|
114
|
+
function buildPayload(level, event, fields) {
|
|
115
|
+
const out = {
|
|
116
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
117
|
+
level,
|
|
118
|
+
event
|
|
119
|
+
};
|
|
120
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
121
|
+
if (!ALLOWED_KEYS.has(key)) continue;
|
|
122
|
+
if (key === "ts" || key === "level" || key === "event") continue;
|
|
123
|
+
out[key] = redactValue(value);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Writes a single JSON log line to stderr.
|
|
129
|
+
* MCP stdio transport uses stdout; all diagnostics go to stderr.
|
|
130
|
+
*/
|
|
131
|
+
function writeLog(level, event, fields = {}) {
|
|
132
|
+
const payload = buildPayload(level, event, fields);
|
|
133
|
+
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
|
134
|
+
}
|
|
135
|
+
/** Log an informational structured event. */
|
|
136
|
+
function logInfo(event, fields = {}) {
|
|
137
|
+
writeLog("info", event, fields);
|
|
138
|
+
}
|
|
139
|
+
/** Log a warning structured event. */
|
|
140
|
+
function logWarn(event, fields = {}) {
|
|
141
|
+
writeLog("warn", event, fields);
|
|
142
|
+
}
|
|
143
|
+
/** Log an error structured event. */
|
|
144
|
+
function logError(event, fields = {}) {
|
|
145
|
+
writeLog("error", event, fields);
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
56
148
|
//#region src/mcp/chii-connection.ts
|
|
57
149
|
/**
|
|
58
150
|
* Production `CdpConnection` backed by the local Chii relay.
|
|
@@ -176,7 +268,7 @@ var ChiiCdpConnection = class {
|
|
|
176
268
|
}
|
|
177
269
|
if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
|
|
178
270
|
const prevId = this.activeTargetId;
|
|
179
|
-
|
|
271
|
+
logInfo("page.detached", { prevTargetId: prevId });
|
|
180
272
|
this.evictTarget(prevId);
|
|
181
273
|
}
|
|
182
274
|
this.targets.clear();
|
|
@@ -758,6 +850,28 @@ var AutoDevtoolsOpener = class {
|
|
|
758
850
|
//#endregion
|
|
759
851
|
//#region src/mcp/environment.ts
|
|
760
852
|
/**
|
|
853
|
+
* Returns `true` when the environment is any relay variant (`relay-dev` or
|
|
854
|
+
* `relay-live`). Use this instead of `env === 'relay'` for tier checks.
|
|
855
|
+
*/
|
|
856
|
+
function isRelayEnv(env) {
|
|
857
|
+
return env === "relay-dev" || env === "relay-live";
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Returns `true` when the environment is the LIVE relay (`relay-live`).
|
|
861
|
+
* This is the guard condition for side-effect tool protection.
|
|
862
|
+
*/
|
|
863
|
+
function isLiveRelayEnv(env) {
|
|
864
|
+
return env === "relay-live";
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Maps the new `McpEnvironment` union to the legacy two-value union
|
|
868
|
+
* (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
|
|
869
|
+
*/
|
|
870
|
+
function toLegacyEnv(env) {
|
|
871
|
+
if (env === "mock") return "mock";
|
|
872
|
+
return "relay";
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
761
875
|
* URL patterns that mark a CDP target as a real-device WebView relay.
|
|
762
876
|
*
|
|
763
877
|
* - `intoss-private://` is the Toss in-app private scheme — only ever observed
|
|
@@ -782,28 +896,43 @@ function isRelayUrl(url) {
|
|
|
782
896
|
* regardless of env vars or connection state. Cleared with `null`.
|
|
783
897
|
*/
|
|
784
898
|
let envOverride = null;
|
|
785
|
-
/**
|
|
899
|
+
/**
|
|
900
|
+
* Parses the `MCP_ENV` env var into a `McpEnvironment` if valid.
|
|
901
|
+
*
|
|
902
|
+
* Accepted values:
|
|
903
|
+
* - `mock` → `mock`
|
|
904
|
+
* - `relay-dev` → `relay-dev`
|
|
905
|
+
* - `relay-live` → `relay-live`
|
|
906
|
+
* - `relay` → `relay-dev` (backward-compat alias — resolves to relay-dev)
|
|
907
|
+
*
|
|
908
|
+
* Any other value is ignored and falls through to the next precedence step.
|
|
909
|
+
*/
|
|
786
910
|
function readEnvVar() {
|
|
787
911
|
const raw = process.env.MCP_ENV;
|
|
788
|
-
if (raw === "mock"
|
|
912
|
+
if (raw === "mock") return "mock";
|
|
913
|
+
if (raw === "relay-dev") return "relay-dev";
|
|
914
|
+
if (raw === "relay-live") return "relay-live";
|
|
915
|
+
if (raw === "relay") return "relay-dev";
|
|
789
916
|
}
|
|
790
917
|
/**
|
|
791
918
|
* Returns the current MCP environment, applying the precedence rules:
|
|
792
919
|
* 1. test override (if set)
|
|
793
920
|
* 2. `MCP_ENV` env var
|
|
794
|
-
* 3. CDP target URL pattern match
|
|
795
|
-
*
|
|
921
|
+
* 3. CDP target URL pattern match → `relay-dev` (conservative — LIVE
|
|
922
|
+
* requires explicit MCP_ENV=relay-live opt-in)
|
|
923
|
+
* 4. caller-stated `defaultEnv` (intent hint from the CLI mode)
|
|
924
|
+
* 5. baked-in default `mock`
|
|
796
925
|
*/
|
|
797
926
|
function getEnvironment(input = {}) {
|
|
798
927
|
if (envOverride !== null) return envOverride;
|
|
799
928
|
const fromEnv = readEnvVar();
|
|
800
929
|
if (fromEnv !== void 0) return fromEnv;
|
|
801
|
-
const { connection } = input;
|
|
930
|
+
const { connection, defaultEnv } = input;
|
|
802
931
|
if (connection !== void 0) {
|
|
803
932
|
const targets = connection.listTargets();
|
|
804
|
-
for (const t of targets) if (isRelayUrl(t.url)) return "relay";
|
|
933
|
+
for (const t of targets) if (isRelayUrl(t.url)) return "relay-dev";
|
|
805
934
|
}
|
|
806
|
-
return "mock";
|
|
935
|
+
return defaultEnv ?? "mock";
|
|
807
936
|
}
|
|
808
937
|
/**
|
|
809
938
|
* Returns the `EnvironmentReason` that drove the current `getEnvironment()`
|
|
@@ -812,18 +941,125 @@ function getEnvironment(input = {}) {
|
|
|
812
941
|
* secret value is ever returned.
|
|
813
942
|
*/
|
|
814
943
|
function getEnvironmentReason(input = {}) {
|
|
815
|
-
if (envOverride !== null)
|
|
944
|
+
if (envOverride !== null) {
|
|
945
|
+
if (envOverride === "mock") return "env-var-mock";
|
|
946
|
+
if (envOverride === "relay-live") return "env-var-relay-live";
|
|
947
|
+
return "env-var-relay-dev";
|
|
948
|
+
}
|
|
949
|
+
const rawVar = process.env.MCP_ENV;
|
|
816
950
|
const fromEnv = readEnvVar();
|
|
817
951
|
if (fromEnv === "mock") return "env-var-mock";
|
|
818
|
-
if (fromEnv === "relay") return "env-var-relay";
|
|
819
|
-
|
|
952
|
+
if (fromEnv === "relay-live") return "env-var-relay-live";
|
|
953
|
+
if (fromEnv === "relay-dev") return rawVar === "relay" ? "env-var-relay-compat" : "env-var-relay-dev";
|
|
954
|
+
const { connection, defaultEnv } = input;
|
|
820
955
|
if (connection !== void 0) {
|
|
821
956
|
const targets = connection.listTargets();
|
|
822
957
|
for (const t of targets) if (isRelayUrl(t.url)) return "cdp-target-url-relay-pattern";
|
|
823
958
|
}
|
|
959
|
+
if (defaultEnv === "relay-live") return "default-relay-live";
|
|
960
|
+
if (defaultEnv === "relay-dev") return "default-relay-dev";
|
|
824
961
|
return "default-mock";
|
|
825
962
|
}
|
|
826
963
|
//#endregion
|
|
964
|
+
//#region src/mcp/errors.ts
|
|
965
|
+
/**
|
|
966
|
+
* 한국어 한 줄 "원인 + 다음 행동" 포맷으로 에러 결과를 빌드한다.
|
|
967
|
+
*
|
|
968
|
+
* @param message - 사용자에게 보여줄 에러 본문 (원인 + 다음 행동 포함).
|
|
969
|
+
*/
|
|
970
|
+
function mcpError(message) {
|
|
971
|
+
return {
|
|
972
|
+
content: [{
|
|
973
|
+
type: "text",
|
|
974
|
+
text: message
|
|
975
|
+
}],
|
|
976
|
+
isError: true
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Tier A/B 환경 불일치 거부 메시지.
|
|
981
|
+
*
|
|
982
|
+
* @param toolName - 거부된 tool 이름.
|
|
983
|
+
* @param requiredEnv - 해당 tool이 요구하는 환경 ('mock' | 'relay').
|
|
984
|
+
* @param currentEnv - 현재 세션 환경.
|
|
985
|
+
* @param reason - 환경이 결정된 근거 (EnvironmentReason 문자열).
|
|
986
|
+
*/
|
|
987
|
+
function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
|
|
988
|
+
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}).`}`);
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.
|
|
992
|
+
*
|
|
993
|
+
* `build_attach_url` 호출 시 tunnel.up === false 인 경우.
|
|
994
|
+
*/
|
|
995
|
+
function tunnelDownError() {
|
|
996
|
+
return mcpError("cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* 상태 2: page 미attach — 터널은 살아 있으나 아직 페이지가 연결되지 않았다.
|
|
1000
|
+
*
|
|
1001
|
+
* enableDomains()가 "No mini-app page attached" 에러를 던질 때.
|
|
1002
|
+
*/
|
|
1003
|
+
function pageMissingError(toolName) {
|
|
1004
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. dogfood 번들 배포 후 build_attach_url을 호출해 QR을 생성하세요: \`ait deploy --scheme-only\` → \`build_attach_url(scheme_url)\` → QR 스캔.`);
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.
|
|
1008
|
+
*
|
|
1009
|
+
* chii-connection 이 'replaced-by-new-attach' / 'targetCrashed' / 'targetDestroyed' 를
|
|
1010
|
+
* 던질 때 이 메시지를 사용한다.
|
|
1011
|
+
*/
|
|
1012
|
+
function pageCrashError(toolName) {
|
|
1013
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다 (dogfood 빌드가 아님).
|
|
1017
|
+
*
|
|
1018
|
+
* call_sdk 호출 시 브리지가 없을 때.
|
|
1019
|
+
*/
|
|
1020
|
+
function sdkAbsentError(toolName) {
|
|
1021
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
|
|
1025
|
+
* 없이 호출했을 때 반환하는 거부 메시지.
|
|
1026
|
+
*
|
|
1027
|
+
* 다음 행동을 두 가지로 제시한다:
|
|
1028
|
+
* 1. 같은 호출에 `confirm: true` 인자를 추가해 재시도.
|
|
1029
|
+
* 2. 읽기 전용 환경(relay-dev, mock)으로 전환.
|
|
1030
|
+
*/
|
|
1031
|
+
function liveGuardError(toolName) {
|
|
1032
|
+
return mcpError(`[LIVE relay guard] ${toolName}은 현재 relay-live(실 출시 런타임) 세션에서 side-effect 호출입니다. 실유저에게 영향을 줄 수 있어 명시적 동의가 필요합니다.
|
|
1033
|
+
|
|
1034
|
+
다음 중 하나를 선택하세요:
|
|
1035
|
+
1. \`confirm: true\` 인자를 추가해 재호출: ${toolName}(…, confirm: true)\n 2. 읽기 전용 도구(list_pages, list_console_messages, take_screenshot 등)를 사용하세요.
|
|
1036
|
+
3. dogfood 빌드(relay-dev 환경)에서 먼저 검증 후 live에 적용하세요.
|
|
1037
|
+
|
|
1038
|
+
live-guard: MCP_ENV=relay-live + confirm: true missing`);
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.
|
|
1042
|
+
*/
|
|
1043
|
+
function relayDisconnectError(toolName) {
|
|
1044
|
+
return mcpError(`${toolName ? `${toolName}: ` : ""}relay 연결이 끊겼습니다. list_pages로 상태를 확인하고, 필요하면 앱을 재실행 후 재attach하세요.`);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* CDP/AIT 명령 중 발생한 예외를 4상태로 분류해 적절한 에러 결과를 반환한다.
|
|
1048
|
+
*
|
|
1049
|
+
* - SDK 부재 패턴 (`window.__sdkCall is not available`) → sdkAbsentError
|
|
1050
|
+
* - crash 패턴 (`replaced-by-new-attach`, `targetCrashed`, `targetDestroyed`) → pageCrashError
|
|
1051
|
+
* - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError
|
|
1052
|
+
* - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError
|
|
1053
|
+
*/
|
|
1054
|
+
function classifyToolError(err, toolName) {
|
|
1055
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1056
|
+
if (message.startsWith("tunnel-down:") || message.includes("터널이 안 떠 있습니다")) return tunnelDownError();
|
|
1057
|
+
if (message.startsWith("sdk-absent:") || message.includes("__sdkCall이 주입되지 않았습니다") || message.includes("window.__sdkCall is not available") || message.includes("__sdkCall") && message.includes("not available")) return sdkAbsentError(toolName);
|
|
1058
|
+
if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
|
|
1059
|
+
if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket")) return relayDisconnectError(toolName);
|
|
1060
|
+
return mcpError(`${toolName} 실패: ${message}\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`);
|
|
1061
|
+
}
|
|
1062
|
+
//#endregion
|
|
827
1063
|
//#region src/mcp/local-connection.ts
|
|
828
1064
|
/**
|
|
829
1065
|
* Local-browser `CdpConnection` — attaches directly to a Chromium instance
|
|
@@ -1367,7 +1603,9 @@ var ServerLockConflictError = class extends Error {
|
|
|
1367
1603
|
existingPid;
|
|
1368
1604
|
/** wssUrl from the existing lock — may be `null` if the tunnel is still starting. */
|
|
1369
1605
|
existingWssUrl;
|
|
1370
|
-
|
|
1606
|
+
/** ISO timestamp from the existing lock — when that session started. */
|
|
1607
|
+
existingStartedAt;
|
|
1608
|
+
constructor(existingPid, existingWssUrl, existingStartedAt) {
|
|
1371
1609
|
const urlNote = existingWssUrl != null ? ` relay URL: ${existingWssUrl}\n` : " relay URL: (tunnel still starting — retry in a moment)\n";
|
|
1372
1610
|
super(`A debug server is already running (PID ${existingPid}).\n` + urlNote + `Stop the existing session before starting a new one.
|
|
1373
1611
|
If it is already stopped but this error persists, remove the lock file:
|
|
@@ -1375,6 +1613,7 @@ If it is already stopped but this error persists, remove the lock file:
|
|
|
1375
1613
|
this.name = "ServerLockConflictError";
|
|
1376
1614
|
this.existingPid = existingPid;
|
|
1377
1615
|
this.existingWssUrl = existingWssUrl;
|
|
1616
|
+
this.existingStartedAt = existingStartedAt;
|
|
1378
1617
|
}
|
|
1379
1618
|
};
|
|
1380
1619
|
/** Returns `~/.ait-devtools/server.lock` (or `AIT_DEVTOOLS_LOCK_DIR` override for tests). */
|
|
@@ -1427,22 +1666,64 @@ function removeLock(lockPath) {
|
|
|
1427
1666
|
} catch {}
|
|
1428
1667
|
}
|
|
1429
1668
|
/**
|
|
1669
|
+
* Sends SIGTERM to `pid` and waits up to `graceMs` (default 2 000 ms) for it
|
|
1670
|
+
* to exit; then falls back to SIGKILL. Synchronous — uses a busy-wait loop so
|
|
1671
|
+
* it is usable in the top-level startup path without async plumbing.
|
|
1672
|
+
*
|
|
1673
|
+
* Ignores errors from `process.kill` so that a race where the target exits
|
|
1674
|
+
* between the alive check and the kill call does not crash the caller.
|
|
1675
|
+
*/
|
|
1676
|
+
function killAndWait(pid, graceMs = 2e3) {
|
|
1677
|
+
try {
|
|
1678
|
+
process.kill(pid, "SIGTERM");
|
|
1679
|
+
} catch {
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
const deadline = Date.now() + graceMs;
|
|
1683
|
+
while (isPidAlive(pid) && Date.now() < deadline) {
|
|
1684
|
+
const end = Date.now() + 100;
|
|
1685
|
+
while (Date.now() < end);
|
|
1686
|
+
}
|
|
1687
|
+
if (isPidAlive(pid)) try {
|
|
1688
|
+
process.kill(pid, "SIGKILL");
|
|
1689
|
+
} catch {}
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Reads the current lock file without acquiring it. Returns the parsed
|
|
1693
|
+
* `LockData` when the file exists and is valid, otherwise `null`. Used by
|
|
1694
|
+
* `get_diagnostics` to surface the `serverLockHolder` field without
|
|
1695
|
+
* interfering with the running lock owner.
|
|
1696
|
+
*/
|
|
1697
|
+
function readServerLock() {
|
|
1698
|
+
return readLock(lockFilePath());
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1430
1701
|
* Attempts to acquire the server lock.
|
|
1431
1702
|
*
|
|
1432
1703
|
* - If no lock exists (or the lock is stale): writes a new lock and returns a
|
|
1433
1704
|
* `LockHandle` with `updateWssUrl` + `release`.
|
|
1434
|
-
* - If a live process holds the lock
|
|
1705
|
+
* - If a live process holds the lock and `force` is `false` (default): writes
|
|
1706
|
+
* a clear recovery message to stderr and throws `ServerLockConflictError`.
|
|
1707
|
+
* - If a live process holds the lock and `force` is `true`: sends SIGTERM to
|
|
1708
|
+
* that process (waiting up to 2 s then SIGKILL) and takes over the lock.
|
|
1435
1709
|
*
|
|
1436
1710
|
* The initial `wssUrl` in the lock file is `null` — call
|
|
1437
1711
|
* `handle.updateWssUrl(url)` once the cloudflared tunnel is ready.
|
|
1438
1712
|
*/
|
|
1439
|
-
function acquireLock() {
|
|
1713
|
+
function acquireLock(options = {}) {
|
|
1714
|
+
const { force = false } = options;
|
|
1440
1715
|
const lockPath = lockFilePath();
|
|
1441
1716
|
const existing = readLock(lockPath);
|
|
1442
|
-
if (existing !== null) {
|
|
1443
|
-
|
|
1444
|
-
|
|
1717
|
+
if (existing !== null) if (isPidAlive(existing.pid)) if (force) {
|
|
1718
|
+
process.stderr.write(`[ait-debug] --force: terminating existing session PID=${existing.pid} …\n`);
|
|
1719
|
+
killAndWait(existing.pid);
|
|
1720
|
+
process.stderr.write(`[ait-debug] --force: PID=${existing.pid} stopped, taking over.\n`);
|
|
1721
|
+
} else {
|
|
1722
|
+
const urlPart = existing.wssUrl != null ? `wssUrl=${existing.wssUrl}` : "wssUrl=(tunnel starting)";
|
|
1723
|
+
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`);
|
|
1724
|
+
throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);
|
|
1445
1725
|
}
|
|
1726
|
+
else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
|
|
1446
1727
|
const data = {
|
|
1447
1728
|
pid: process.pid,
|
|
1448
1729
|
wssUrl: null,
|
|
@@ -1757,7 +2038,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1757
2038
|
},
|
|
1758
2039
|
{
|
|
1759
2040
|
name: "list_pages",
|
|
1760
|
-
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. 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.",
|
|
2041
|
+
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.",
|
|
1761
2042
|
inputSchema: {
|
|
1762
2043
|
type: "object",
|
|
1763
2044
|
properties: {},
|
|
@@ -1767,7 +2048,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1767
2048
|
},
|
|
1768
2049
|
{
|
|
1769
2050
|
name: "build_attach_url",
|
|
1770
|
-
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
|
|
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).",
|
|
1771
2052
|
inputSchema: {
|
|
1772
2053
|
type: "object",
|
|
1773
2054
|
properties: {
|
|
@@ -1777,7 +2058,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1777
2058
|
},
|
|
1778
2059
|
wait_for_attach: {
|
|
1779
2060
|
type: "boolean",
|
|
1780
|
-
description: "If true, block after returning the QR until a page attaches to the relay (polls listTargets ~1 s interval, timeout
|
|
2061
|
+
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."
|
|
1781
2062
|
},
|
|
1782
2063
|
open_in_browser: {
|
|
1783
2064
|
type: "boolean",
|
|
@@ -1810,7 +2091,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1810
2091
|
},
|
|
1811
2092
|
{
|
|
1812
2093
|
name: "take_screenshot",
|
|
1813
|
-
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.",
|
|
2094
|
+
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).",
|
|
1814
2095
|
inputSchema: {
|
|
1815
2096
|
type: "object",
|
|
1816
2097
|
properties: {},
|
|
@@ -1830,13 +2111,19 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1830
2111
|
},
|
|
1831
2112
|
{
|
|
1832
2113
|
name: "evaluate",
|
|
1833
|
-
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.",
|
|
2114
|
+
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.",
|
|
1834
2115
|
inputSchema: {
|
|
1835
2116
|
type: "object",
|
|
1836
|
-
properties: {
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
2117
|
+
properties: {
|
|
2118
|
+
expression: {
|
|
2119
|
+
type: "string",
|
|
2120
|
+
description: "JavaScript expression to evaluate in the page context."
|
|
2121
|
+
},
|
|
2122
|
+
confirm: {
|
|
2123
|
+
type: "boolean",
|
|
2124
|
+
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."
|
|
2125
|
+
}
|
|
2126
|
+
},
|
|
1840
2127
|
required: ["expression"]
|
|
1841
2128
|
},
|
|
1842
2129
|
availableIn: "both"
|
|
@@ -1856,7 +2143,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1856
2143
|
},
|
|
1857
2144
|
{
|
|
1858
2145
|
name: "call_sdk",
|
|
1859
|
-
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\", [])",
|
|
2146
|
+
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\", [])",
|
|
1860
2147
|
inputSchema: {
|
|
1861
2148
|
type: "object",
|
|
1862
2149
|
properties: {
|
|
@@ -1868,6 +2155,10 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1868
2155
|
type: "array",
|
|
1869
2156
|
description: "Arguments to pass to the SDK method (optional, default []).",
|
|
1870
2157
|
items: {}
|
|
2158
|
+
},
|
|
2159
|
+
confirm: {
|
|
2160
|
+
type: "boolean",
|
|
2161
|
+
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."
|
|
1871
2162
|
}
|
|
1872
2163
|
},
|
|
1873
2164
|
required: ["name"]
|
|
@@ -1903,6 +2194,19 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
1903
2194
|
required: []
|
|
1904
2195
|
},
|
|
1905
2196
|
availableIn: "both"
|
|
2197
|
+
},
|
|
2198
|
+
{
|
|
2199
|
+
name: "get_diagnostics",
|
|
2200
|
+
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.",
|
|
2201
|
+
inputSchema: {
|
|
2202
|
+
type: "object",
|
|
2203
|
+
properties: { recent_errors_limit: {
|
|
2204
|
+
type: "number",
|
|
2205
|
+
description: "Maximum number of recent server-side errors to include (default 10, max 50)."
|
|
2206
|
+
} },
|
|
2207
|
+
required: []
|
|
2208
|
+
},
|
|
2209
|
+
availableIn: "both"
|
|
1906
2210
|
}
|
|
1907
2211
|
];
|
|
1908
2212
|
const DEBUG_TOOL_NAMES = new Set(DEBUG_TOOL_DEFINITIONS.map((t) => t.name));
|
|
@@ -1922,20 +2226,26 @@ function getToolAvailability(name) {
|
|
|
1922
2226
|
* Returns true when the named tool is available in the given environment.
|
|
1923
2227
|
* Unknown tools return `false` — callers should reject them as unknown rather
|
|
1924
2228
|
* than as env-mismatched.
|
|
2229
|
+
*
|
|
2230
|
+
* Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'`
|
|
2231
|
+
* availability tier — `isRelayEnv()` is used for the check.
|
|
1925
2232
|
*/
|
|
1926
2233
|
function isToolAvailableIn(name, env) {
|
|
1927
2234
|
const availability = getToolAvailability(name);
|
|
1928
2235
|
if (availability === void 0) return false;
|
|
1929
2236
|
if (availability === "both") return true;
|
|
2237
|
+
if (availability === "relay") return isRelayEnv(env);
|
|
1930
2238
|
return availability === env;
|
|
1931
2239
|
}
|
|
1932
2240
|
/**
|
|
1933
2241
|
* Filters a `DEBUG_TOOL_DEFINITIONS`-shaped list to those whose `availableIn`
|
|
1934
2242
|
* matches the given env. Pure — preserves order; both Tier C ("both") and the
|
|
1935
2243
|
* matching single-env tier pass through.
|
|
2244
|
+
*
|
|
2245
|
+
* Relay variants (`relay-dev`, `relay-live`) both satisfy the `'relay'` tier.
|
|
1936
2246
|
*/
|
|
1937
2247
|
function filterToolsByEnvironment(tools, env) {
|
|
1938
|
-
return tools.filter((t) => t.availableIn === "both" || t.availableIn === env);
|
|
2248
|
+
return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
|
|
1939
2249
|
}
|
|
1940
2250
|
/**
|
|
1941
2251
|
* Tool names that are available before any page attaches (bootstrap tier).
|
|
@@ -1946,7 +2256,11 @@ function filterToolsByEnvironment(tools, env) {
|
|
|
1946
2256
|
* All other tools require an attached page (`enableDomains` must succeed) and
|
|
1947
2257
|
* are only advertised in `tools/list` once a target appears.
|
|
1948
2258
|
*/
|
|
1949
|
-
const BOOTSTRAP_TOOL_NAMES = new Set([
|
|
2259
|
+
const BOOTSTRAP_TOOL_NAMES = new Set([
|
|
2260
|
+
"build_attach_url",
|
|
2261
|
+
"get_diagnostics",
|
|
2262
|
+
"list_pages"
|
|
2263
|
+
]);
|
|
1950
2264
|
/** Renders a CDP `RemoteObject` console arg to a stable display string. */
|
|
1951
2265
|
function renderRemoteObject(arg) {
|
|
1952
2266
|
if (arg.value !== void 0) {
|
|
@@ -2058,7 +2372,7 @@ function listPages(connection, tunnel) {
|
|
|
2058
2372
|
* the scheme authority which is in the caller's input, not ours to own).
|
|
2059
2373
|
*/
|
|
2060
2374
|
function buildAttachUrl(schemeUrl, tunnel) {
|
|
2061
|
-
if (!tunnel.up || tunnel.wssUrl === null) throw new Error("
|
|
2375
|
+
if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
|
|
2062
2376
|
const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
|
|
2063
2377
|
return {
|
|
2064
2378
|
attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
|
|
@@ -2173,8 +2487,9 @@ function isLaunchFailureStderr(stderr) {
|
|
|
2173
2487
|
/**
|
|
2174
2488
|
* 로컬 HTTP 서버 URL(`http://127.0.0.1:<port>/attach?u=...`)을 OS 기본 브라우저로 연다.
|
|
2175
2489
|
*
|
|
2176
|
-
* platform별 fallback chain으로 시도하며, 모두
|
|
2177
|
-
*
|
|
2490
|
+
* platform별 fallback chain으로 시도하며, 모두 실패하면 1회 retry를 수행한다
|
|
2491
|
+
* (ephemeral process launch 타이밍 문제 대응). retry까지 실패해도 `opened: false` +
|
|
2492
|
+
* `httpUrl`을 반환해 사용자가 직접 브라우저에 붙여넣을 수 있게 한다.
|
|
2178
2493
|
*
|
|
2179
2494
|
* SECRET-HANDLING:
|
|
2180
2495
|
* - tmp 파일을 만들지 않는다 (HTML/PNG는 HTTP 서버가 메모리에서 응답).
|
|
@@ -2187,25 +2502,39 @@ function isLaunchFailureStderr(stderr) {
|
|
|
2187
2502
|
*/
|
|
2188
2503
|
async function openQrInBrowser(httpUrl, pngUrl) {
|
|
2189
2504
|
const { spawnSync } = await import("node:child_process");
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
})
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2505
|
+
/**
|
|
2506
|
+
* 한 번의 fallback chain 시도. 성공하면 열린 후보 cmd를 반환, 실패하면 null.
|
|
2507
|
+
* stderrLines에 각 후보의 stderr를 누적한다.
|
|
2508
|
+
*/
|
|
2509
|
+
function tryOnce(stderrLines) {
|
|
2510
|
+
const candidates = getBrowserCandidates(httpUrl);
|
|
2511
|
+
for (const { cmd, args } of candidates) {
|
|
2512
|
+
const result = spawnSync(cmd, args, {
|
|
2513
|
+
encoding: "utf8",
|
|
2514
|
+
timeout: 5e3
|
|
2515
|
+
});
|
|
2516
|
+
if (result.error) {
|
|
2517
|
+
stderrLines.push(`${cmd}: ${result.error.message}`);
|
|
2518
|
+
continue;
|
|
2519
|
+
}
|
|
2520
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
2521
|
+
if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
|
|
2522
|
+
if (result.status === 0 && !isLaunchFailureStderr(stderr)) return true;
|
|
2200
2523
|
}
|
|
2201
|
-
|
|
2202
|
-
if (stderr) stderrLines.push(`${cmd}: ${redactSecrets(stderr.trim())}`);
|
|
2203
|
-
if (result.status === 0 && !isLaunchFailureStderr(stderr)) return {
|
|
2204
|
-
opened: true,
|
|
2205
|
-
httpUrl,
|
|
2206
|
-
pngUrl
|
|
2207
|
-
};
|
|
2524
|
+
return false;
|
|
2208
2525
|
}
|
|
2526
|
+
const stderrLines = [];
|
|
2527
|
+
if (tryOnce(stderrLines)) return {
|
|
2528
|
+
opened: true,
|
|
2529
|
+
httpUrl,
|
|
2530
|
+
pngUrl
|
|
2531
|
+
};
|
|
2532
|
+
if (tryOnce(stderrLines)) return {
|
|
2533
|
+
opened: true,
|
|
2534
|
+
httpUrl,
|
|
2535
|
+
pngUrl,
|
|
2536
|
+
retried: true
|
|
2537
|
+
};
|
|
2209
2538
|
return {
|
|
2210
2539
|
opened: false,
|
|
2211
2540
|
httpUrl,
|
|
@@ -2451,7 +2780,7 @@ async function evaluate(connection, expression) {
|
|
|
2451
2780
|
* any log or stderr by the caller.
|
|
2452
2781
|
*/
|
|
2453
2782
|
function buildCallSdkExpression(name, args) {
|
|
2454
|
-
return `(async () => { if (typeof window.__sdkCall !== 'function') { return JSON.stringify({ok:false,error:'window.__sdkCall
|
|
2783
|
+
return `(async () => { if (typeof window.__sdkCall !== 'function') { return JSON.stringify({ok:false,error:'sdk-absent: window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널로 재배포하세요.'}); } try { const r = await window.__sdkCall(${JSON.stringify(name)}, ...${JSON.stringify(args)}); return JSON.stringify({ok:true,value:r}); } catch(e) { return JSON.stringify({ok:false,error:String(e && e.message || e)}); }})()`;
|
|
2455
2784
|
}
|
|
2456
2785
|
/**
|
|
2457
2786
|
* Parses the JSON envelope string returned by the `call_sdk` expression.
|
|
@@ -2570,6 +2899,175 @@ function getMockState(source) {
|
|
|
2570
2899
|
function getOperationalEnvironment(source) {
|
|
2571
2900
|
return source.get("AIT.getOperationalEnvironment");
|
|
2572
2901
|
}
|
|
2902
|
+
/** Secret-redaction patterns applied before error messages enter the buffer. */
|
|
2903
|
+
const SECRET_REDACT_PATTERNS = [
|
|
2904
|
+
[/\bat=([^&\s"']+)/g, "at=<redacted>"],
|
|
2905
|
+
[/((?:set-)?cookie)\s*:\s*.+/gi, "$1: <redacted>"],
|
|
2906
|
+
[/AITCC_API_KEY\s*=\s*\S+/gi, "AITCC_API_KEY=<redacted>"],
|
|
2907
|
+
[/Authorization\s*:\s*.+/gi, "Authorization: <redacted>"],
|
|
2908
|
+
[/\bBearer\s+\S+/g, "Bearer <redacted>"]
|
|
2909
|
+
];
|
|
2910
|
+
/**
|
|
2911
|
+
* Applies all secret-redaction patterns to an error message string.
|
|
2912
|
+
* Used before storing errors in the `DiagnosticsCollector` ring buffer.
|
|
2913
|
+
*
|
|
2914
|
+
* SECRET-HANDLING: this is the single bottleneck for redaction — all error
|
|
2915
|
+
* strings must pass through here before reaching the buffer.
|
|
2916
|
+
*/
|
|
2917
|
+
function redactErrorMessage(message) {
|
|
2918
|
+
let result = message;
|
|
2919
|
+
for (const [pattern, replacement] of SECRET_REDACT_PATTERNS) result = result.replace(pattern, replacement);
|
|
2920
|
+
return result;
|
|
2921
|
+
}
|
|
2922
|
+
/** Default max buffer size for the error ring buffer. */
|
|
2923
|
+
const DEFAULT_ERROR_BUFFER_SIZE = 50;
|
|
2924
|
+
/**
|
|
2925
|
+
* In-memory implementation of `DiagnosticsCollector`. Thread-safe in the
|
|
2926
|
+
* single-threaded Node.js sense (synchronous mutations only).
|
|
2927
|
+
*/
|
|
2928
|
+
var InMemoryDiagnosticsCollector = class {
|
|
2929
|
+
buffer = [];
|
|
2930
|
+
maxSize;
|
|
2931
|
+
lastAttachAt = null;
|
|
2932
|
+
lastDetachAt = null;
|
|
2933
|
+
constructor(maxSize = DEFAULT_ERROR_BUFFER_SIZE) {
|
|
2934
|
+
this.maxSize = maxSize;
|
|
2935
|
+
}
|
|
2936
|
+
recordError(message, category) {
|
|
2937
|
+
const entry = {
|
|
2938
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2939
|
+
message: redactErrorMessage(message),
|
|
2940
|
+
...category !== void 0 ? { category } : {}
|
|
2941
|
+
};
|
|
2942
|
+
this.buffer.push(entry);
|
|
2943
|
+
if (this.buffer.length > this.maxSize) this.buffer.shift();
|
|
2944
|
+
}
|
|
2945
|
+
getRecentErrors(limit) {
|
|
2946
|
+
const cap = Math.min(Math.max(1, limit), DEFAULT_ERROR_BUFFER_SIZE);
|
|
2947
|
+
return this.buffer.length > cap ? this.buffer.slice(this.buffer.length - cap) : [...this.buffer];
|
|
2948
|
+
}
|
|
2949
|
+
recordAttach() {
|
|
2950
|
+
this.lastAttachAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2951
|
+
}
|
|
2952
|
+
recordDetach() {
|
|
2953
|
+
this.lastDetachAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2954
|
+
}
|
|
2955
|
+
getLastAttachAt() {
|
|
2956
|
+
return this.lastAttachAt;
|
|
2957
|
+
}
|
|
2958
|
+
getLastDetachAt() {
|
|
2959
|
+
return this.lastDetachAt;
|
|
2960
|
+
}
|
|
2961
|
+
};
|
|
2962
|
+
/**
|
|
2963
|
+
* Reads the `@modelcontextprotocol/sdk` package version from the installed
|
|
2964
|
+
* package's `package.json`. Returns `null` on any error (missing file, JSON
|
|
2965
|
+
* parse failure, etc.) — diagnostics must never throw.
|
|
2966
|
+
*
|
|
2967
|
+
* Node-only — uses dynamic `import()` so it does not pollute the browser
|
|
2968
|
+
* module graph.
|
|
2969
|
+
*/
|
|
2970
|
+
async function readMcpSdkVersion() {
|
|
2971
|
+
try {
|
|
2972
|
+
const { createRequire } = await import("node:module");
|
|
2973
|
+
const pkgPath = createRequire(import.meta.url).resolve("@modelcontextprotocol/sdk/package.json");
|
|
2974
|
+
const { readFileSync } = await import("node:fs");
|
|
2975
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
2976
|
+
const parsed = JSON.parse(raw);
|
|
2977
|
+
return typeof parsed.version === "string" ? parsed.version : null;
|
|
2978
|
+
} catch {
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
/**
|
|
2983
|
+
* Returns the `@ait-co/devtools` package version injected at build time via
|
|
2984
|
+
* the `__VERSION__` define. Returns `null` when the global is absent (e.g. in
|
|
2985
|
+
* some test environments that skip the build step).
|
|
2986
|
+
*/
|
|
2987
|
+
function readDevtoolsVersion() {
|
|
2988
|
+
try {
|
|
2989
|
+
const v = globalThis.__VERSION__;
|
|
2990
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
2991
|
+
} catch {
|
|
2992
|
+
return null;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Derives the next recommended action from a completed diagnostics snapshot.
|
|
2997
|
+
*
|
|
2998
|
+
* Branch rules (evaluated in priority order):
|
|
2999
|
+
* 1. tunnel.up === false → restart
|
|
3000
|
+
* 2. tunnel.up, pages empty, env === relay → build_attach_url (start attach)
|
|
3001
|
+
* 3. pages has entry + crashDetectedAt non-null → build_attach_url (re-attach after crash)
|
|
3002
|
+
* 4. otherwise → null (session looks healthy)
|
|
3003
|
+
*
|
|
3004
|
+
* Pure — does not throw; receives the final assembled snapshot fields.
|
|
3005
|
+
*/
|
|
3006
|
+
function computeNextRecommendedAction(tunnel, pages, env) {
|
|
3007
|
+
if (!tunnel.up) return {
|
|
3008
|
+
tool: "restart",
|
|
3009
|
+
reason: "tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart"
|
|
3010
|
+
};
|
|
3011
|
+
if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
|
|
3012
|
+
tool: "build_attach_url",
|
|
3013
|
+
reason: "tunnel ready, no pages attached — call build_attach_url to generate the attach QR"
|
|
3014
|
+
};
|
|
3015
|
+
if (pages !== null && pages.crashDetectedAt !== null) return {
|
|
3016
|
+
tool: "build_attach_url",
|
|
3017
|
+
reason: `page crashed at ${pages.crashDetectedAt} — call build_attach_url to re-attach`
|
|
3018
|
+
};
|
|
3019
|
+
return null;
|
|
3020
|
+
}
|
|
3021
|
+
/**
|
|
3022
|
+
* Builds the `get_diagnostics` response. Pure — does not throw; missing data
|
|
3023
|
+
* fields are `null`. Async because `readMcpSdkVersion` needs `import()`.
|
|
3024
|
+
*
|
|
3025
|
+
* SECRET-HANDLING:
|
|
3026
|
+
* - `recentErrors` messages are already redacted by `recordError` (via
|
|
3027
|
+
* `redactErrorMessage`). No additional redaction needed here.
|
|
3028
|
+
* - `tunnel.wssUrl` is a public cloudflared hostname — not a secret.
|
|
3029
|
+
* - Lock file data contains only pid + startedAt + wssUrl — no secrets.
|
|
3030
|
+
*/
|
|
3031
|
+
async function getDiagnostics(input) {
|
|
3032
|
+
const { tunnel, connection, env, envReason, collector, readLock: readLockFn, recentErrorsLimit = 10, getMcpVersion = readMcpSdkVersion } = input;
|
|
3033
|
+
const [mcpVersion, devtoolsVersion] = await Promise.all([getMcpVersion(), Promise.resolve(readDevtoolsVersion())]);
|
|
3034
|
+
const lockData = readLockFn();
|
|
3035
|
+
const serverLockHolder = lockData ? {
|
|
3036
|
+
pid: lockData.pid,
|
|
3037
|
+
startedAt: lockData.startedAt,
|
|
3038
|
+
wssUrl: lockData.wssUrl
|
|
3039
|
+
} : null;
|
|
3040
|
+
const tunnelInfo = {
|
|
3041
|
+
up: tunnel.up,
|
|
3042
|
+
wssUrl: tunnel.wssUrl,
|
|
3043
|
+
pid: lockData?.pid ?? null,
|
|
3044
|
+
startedAt: lockData?.startedAt ?? null
|
|
3045
|
+
};
|
|
3046
|
+
let pages = null;
|
|
3047
|
+
if (connection !== void 0) try {
|
|
3048
|
+
pages = listPages(connection, tunnel);
|
|
3049
|
+
} catch {}
|
|
3050
|
+
const limit = Math.min(Math.max(1, recentErrorsLimit), 50);
|
|
3051
|
+
const recentErrors = collector.getRecentErrors(limit);
|
|
3052
|
+
const nextRecommendedAction = computeNextRecommendedAction(tunnelInfo, pages, env);
|
|
3053
|
+
return {
|
|
3054
|
+
mcpVersion,
|
|
3055
|
+
devtoolsVersion,
|
|
3056
|
+
tunnel: tunnelInfo,
|
|
3057
|
+
pages,
|
|
3058
|
+
lastAttachAt: collector.getLastAttachAt(),
|
|
3059
|
+
lastDetachAt: collector.getLastDetachAt(),
|
|
3060
|
+
recentErrors,
|
|
3061
|
+
environment: {
|
|
3062
|
+
kind: env,
|
|
3063
|
+
env: toLegacyEnv(env),
|
|
3064
|
+
reason: envReason,
|
|
3065
|
+
liveGuardActive: isLiveRelayEnv(env)
|
|
3066
|
+
},
|
|
3067
|
+
serverLockHolder,
|
|
3068
|
+
nextRecommendedAction
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
2573
3071
|
//#endregion
|
|
2574
3072
|
//#region src/mcp/totp.ts
|
|
2575
3073
|
/**
|
|
@@ -2660,6 +3158,15 @@ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
|
|
|
2660
3158
|
* and would be stale by the time a human scans. The in-app deep-link builder
|
|
2661
3159
|
* splices the live code at attach time.
|
|
2662
3160
|
*
|
|
3161
|
+
* Tunnel health probe (`TunnelHealthProbe`):
|
|
3162
|
+
* After the tunnel is up, a periodic HTTP HEAD probe hits the tunnel's
|
|
3163
|
+
* `https://` URL every `probeIntervalMs` (default 60 s). Two consecutive
|
|
3164
|
+
* failures trigger a reissue attempt (spawn a new cloudflared quick tunnel
|
|
3165
|
+
* and redirect traffic). After `MAX_REISSUE_ATTEMPTS` (3) consecutive
|
|
3166
|
+
* reissue failures, the probe gives up and marks the tunnel permanently
|
|
3167
|
+
* dropped — `tunnelStatus.up` becomes false with `droppedAt` set. The caller
|
|
3168
|
+
* should surface this to the agent so the user knows to restart the server.
|
|
3169
|
+
*
|
|
2663
3170
|
* SECRET-HANDLING: The TOTP secret and computed code values MUST NOT appear
|
|
2664
3171
|
* in any output from this module.
|
|
2665
3172
|
*
|
|
@@ -2782,6 +3289,118 @@ async function printAttachBanner(input) {
|
|
|
2782
3289
|
const banner = await renderAttachBanner(input);
|
|
2783
3290
|
process.stderr.write(`${banner}\n`);
|
|
2784
3291
|
}
|
|
3292
|
+
/**
|
|
3293
|
+
* Probes `https://` URL with an HTTP HEAD request.
|
|
3294
|
+
* Returns `true` when the server responds (any HTTP status), `false` on
|
|
3295
|
+
* network error or timeout.
|
|
3296
|
+
*
|
|
3297
|
+
* We treat any HTTP response (including 4xx/5xx) as "tunnel alive" because
|
|
3298
|
+
* cloudflared itself responds to the HEAD — if the tunnel process died, the
|
|
3299
|
+
* request fails at the network level rather than returning a status code.
|
|
3300
|
+
*
|
|
3301
|
+
* @param httpsUrl - The `https://` tunnel URL to probe.
|
|
3302
|
+
* @param timeoutMs - Abort timeout in ms. Default 10 000.
|
|
3303
|
+
*/
|
|
3304
|
+
async function probeTunnel(httpsUrl, timeoutMs = 1e4) {
|
|
3305
|
+
const { default: https } = await import("node:https");
|
|
3306
|
+
return new Promise((resolve) => {
|
|
3307
|
+
const url = new URL(httpsUrl);
|
|
3308
|
+
const timer = setTimeout(() => {
|
|
3309
|
+
req.destroy();
|
|
3310
|
+
resolve(false);
|
|
3311
|
+
}, timeoutMs);
|
|
3312
|
+
const req = https.request({
|
|
3313
|
+
hostname: url.hostname,
|
|
3314
|
+
port: 443,
|
|
3315
|
+
path: url.pathname || "/",
|
|
3316
|
+
method: "HEAD"
|
|
3317
|
+
}, (_res) => {
|
|
3318
|
+
clearTimeout(timer);
|
|
3319
|
+
_res.resume();
|
|
3320
|
+
resolve(true);
|
|
3321
|
+
});
|
|
3322
|
+
req.on("error", () => {
|
|
3323
|
+
clearTimeout(timer);
|
|
3324
|
+
resolve(false);
|
|
3325
|
+
});
|
|
3326
|
+
req.end();
|
|
3327
|
+
});
|
|
3328
|
+
}
|
|
3329
|
+
/**
|
|
3330
|
+
* Starts a periodic health probe for a cloudflared quick tunnel.
|
|
3331
|
+
*
|
|
3332
|
+
* Every `probeIntervalMs` the probe sends an HTTP HEAD request to the tunnel's
|
|
3333
|
+
* `https://` URL. When `failuresBeforeReissue` consecutive failures are
|
|
3334
|
+
* detected, it attempts to spawn a new tunnel (up to `MAX_REISSUE_ATTEMPTS`
|
|
3335
|
+
* times). On success the caller is notified via `onReissue`; on permanent
|
|
3336
|
+
* failure via `onPermanentDrop`.
|
|
3337
|
+
*
|
|
3338
|
+
* @returns `stop` — call during server shutdown to clear the probe interval.
|
|
3339
|
+
*/
|
|
3340
|
+
function startTunnelHealthProbe(initialTunnel, localPort, options) {
|
|
3341
|
+
const { probeIntervalMs = 6e4, failuresBeforeReissue = 2, onReissue, onPermanentDrop, log = (msg) => process.stderr.write(msg), probe = probeTunnel, spawnTunnel = startQuickTunnel } = options;
|
|
3342
|
+
let currentTunnel = initialTunnel;
|
|
3343
|
+
let consecutiveFailures = 0;
|
|
3344
|
+
let reissueAttempts = 0;
|
|
3345
|
+
let stopped = false;
|
|
3346
|
+
const handle = setInterval(() => {
|
|
3347
|
+
(async () => {
|
|
3348
|
+
if (stopped) return;
|
|
3349
|
+
const httpsUrl = currentTunnel.url;
|
|
3350
|
+
if (await probe(httpsUrl)) {
|
|
3351
|
+
if (consecutiveFailures > 0) log("[ait-debug] tunnel health probe: tunnel recovered\n");
|
|
3352
|
+
consecutiveFailures = 0;
|
|
3353
|
+
reissueAttempts = 0;
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
consecutiveFailures += 1;
|
|
3357
|
+
log(`[ait-debug] tunnel health probe: failure ${consecutiveFailures}/${failuresBeforeReissue} (url=${httpsUrl})\n`);
|
|
3358
|
+
if (consecutiveFailures < failuresBeforeReissue) return;
|
|
3359
|
+
reissueAttempts += 1;
|
|
3360
|
+
if (reissueAttempts > 3) return;
|
|
3361
|
+
log(`[ait-debug] tunnel drop detected — reissuing (attempt ${reissueAttempts}/3)\n`);
|
|
3362
|
+
try {
|
|
3363
|
+
const newTunnel = await spawnTunnel(localPort);
|
|
3364
|
+
try {
|
|
3365
|
+
currentTunnel.stop();
|
|
3366
|
+
} catch {}
|
|
3367
|
+
currentTunnel = newTunnel;
|
|
3368
|
+
consecutiveFailures = 0;
|
|
3369
|
+
log(`[ait-debug] tunnel reissued — new relay: ${newTunnel.wssUrl}\n`);
|
|
3370
|
+
onReissue(newTunnel);
|
|
3371
|
+
} catch (err) {
|
|
3372
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3373
|
+
log(`[ait-debug] tunnel reissue attempt ${reissueAttempts} failed: ${message}\n`);
|
|
3374
|
+
if (reissueAttempts >= 3) {
|
|
3375
|
+
clearInterval(handle);
|
|
3376
|
+
stopped = true;
|
|
3377
|
+
const droppedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3378
|
+
log(`[ait-debug] tunnel permanently dropped after 3 reissue attempts — restart the debug server to continue (npx @ait-co/devtools devtools-mcp).
|
|
3379
|
+
`);
|
|
3380
|
+
onPermanentDrop(droppedAt);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
})();
|
|
3384
|
+
}, probeIntervalMs);
|
|
3385
|
+
return { stop() {
|
|
3386
|
+
stopped = true;
|
|
3387
|
+
clearInterval(handle);
|
|
3388
|
+
} };
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* Builds a `TunnelStatus` snapshot that includes drop state.
|
|
3392
|
+
*
|
|
3393
|
+
* Convenience helper for callers (debug-server) that maintain a mutable
|
|
3394
|
+
* `tunnelStatus` object — keeps the shape construction in one place.
|
|
3395
|
+
*/
|
|
3396
|
+
function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
|
|
3397
|
+
return {
|
|
3398
|
+
up,
|
|
3399
|
+
wssUrl,
|
|
3400
|
+
droppedAt,
|
|
3401
|
+
reissueAttempts
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
2785
3404
|
//#endregion
|
|
2786
3405
|
//#region src/mcp/debug-server.ts
|
|
2787
3406
|
/**
|
|
@@ -2899,12 +3518,19 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
|
|
|
2899
3518
|
* naturally via `enableDomains`). The tier only controls visibility.
|
|
2900
3519
|
*/
|
|
2901
3520
|
function createDebugServer(deps) {
|
|
2902
|
-
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep } = deps;
|
|
2903
|
-
const resolveEnvironment = getEnvDep ?? (() => getEnvironment({
|
|
2904
|
-
|
|
3521
|
+
const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, defaultEnv } = deps;
|
|
3522
|
+
const resolveEnvironment = getEnvDep ?? (() => getEnvironment({
|
|
3523
|
+
connection,
|
|
3524
|
+
defaultEnv
|
|
3525
|
+
}));
|
|
3526
|
+
const resolveEnvironmentReason = getEnvReasonDep ?? (() => getEnvironmentReason({
|
|
3527
|
+
connection,
|
|
3528
|
+
defaultEnv
|
|
3529
|
+
}));
|
|
3530
|
+
const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
|
|
2905
3531
|
const server = new Server({
|
|
2906
3532
|
name: "ait-debug",
|
|
2907
|
-
version: "0.1.
|
|
3533
|
+
version: "0.1.45"
|
|
2908
3534
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
2909
3535
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
2910
3536
|
const env = resolveEnvironment();
|
|
@@ -2923,15 +3549,16 @@ function createDebugServer(deps) {
|
|
|
2923
3549
|
};
|
|
2924
3550
|
const env = resolveEnvironment();
|
|
2925
3551
|
if (!isToolAvailableIn(name, env)) {
|
|
2926
|
-
const
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
};
|
|
3552
|
+
const requiredEnv = getToolAvailability(name) ?? "unknown";
|
|
3553
|
+
const envReason = resolveEnvironmentReason();
|
|
3554
|
+
logWarn("tool.error", {
|
|
3555
|
+
tool: name,
|
|
3556
|
+
errorKind: "tier-filter",
|
|
3557
|
+
requiredEnv,
|
|
3558
|
+
currentEnv: env,
|
|
3559
|
+
envReason
|
|
3560
|
+
});
|
|
3561
|
+
return tierRejectionError(name, requiredEnv, env, envReason);
|
|
2935
3562
|
}
|
|
2936
3563
|
if (isAitToolName(name)) try {
|
|
2937
3564
|
await connection.enableDomains();
|
|
@@ -2944,19 +3571,31 @@ function createDebugServer(deps) {
|
|
|
2944
3571
|
} catch (err) {
|
|
2945
3572
|
return errorResult(err, name);
|
|
2946
3573
|
}
|
|
3574
|
+
if (name === "get_diagnostics") try {
|
|
3575
|
+
const rawLimit = request.params.arguments?.recent_errors_limit;
|
|
3576
|
+
const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
|
|
3577
|
+
return jsonResult$1(await getDiagnostics({
|
|
3578
|
+
tunnel: getTunnelStatus(),
|
|
3579
|
+
connection,
|
|
3580
|
+
env: resolveEnvironment(),
|
|
3581
|
+
envReason: resolveEnvironmentReason(),
|
|
3582
|
+
collector,
|
|
3583
|
+
readLock: readServerLock,
|
|
3584
|
+
recentErrorsLimit
|
|
3585
|
+
}));
|
|
3586
|
+
} catch (err) {
|
|
3587
|
+
return errorResult(err, name);
|
|
3588
|
+
}
|
|
2947
3589
|
if (name === "build_attach_url") {
|
|
2948
3590
|
const schemeUrl = request.params.arguments?.scheme_url;
|
|
2949
|
-
if (typeof schemeUrl !== "string" || schemeUrl === "") return
|
|
2950
|
-
content: [{
|
|
2951
|
-
type: "text",
|
|
2952
|
-
text: "build_attach_url requires a non-empty scheme_url."
|
|
2953
|
-
}],
|
|
2954
|
-
isError: true
|
|
2955
|
-
};
|
|
3591
|
+
if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요.");
|
|
2956
3592
|
const waitForAttach = request.params.arguments?.wait_for_attach === true;
|
|
2957
3593
|
const openInBrowser = request.params.arguments?.open_in_browser !== false;
|
|
2958
3594
|
const deploymentId = extractDeploymentId(schemeUrl);
|
|
2959
|
-
if (!deploymentId)
|
|
3595
|
+
if (!deploymentId) logInfo("tool.call", {
|
|
3596
|
+
tool: "build_attach_url",
|
|
3597
|
+
msg: "no _deploymentId in scheme_url; matching on presence only"
|
|
3598
|
+
});
|
|
2960
3599
|
/** Returns true when the page list satisfies the attach condition. */
|
|
2961
3600
|
const isMatchingPage = (pages) => {
|
|
2962
3601
|
if (pages.length === 0) return false;
|
|
@@ -2973,10 +3612,50 @@ function createDebugServer(deps) {
|
|
|
2973
3612
|
const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
|
|
2974
3613
|
const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
|
|
2975
3614
|
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).";
|
|
2976
|
-
|
|
3615
|
+
const guiAvailable = canOpenBrowser();
|
|
3616
|
+
if (openInBrowser && !guiAvailable) {
|
|
3617
|
+
const headlessNote = "[open_in_browser] GUI 환경이 감지되지 않았습니다 (headless/remote 환경). open_in_browser=false로 자동 폴백합니다. 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
|
|
3618
|
+
const qrHeadless = await renderQr(attachUrl);
|
|
3619
|
+
const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
|
|
3620
|
+
attachUrl,
|
|
3621
|
+
relayUrl
|
|
3622
|
+
}, null, 2)}\n\n${qrHeadless}`;
|
|
3623
|
+
if (!waitForAttach) return { content: [{
|
|
3624
|
+
type: "text",
|
|
3625
|
+
text: headlessText
|
|
3626
|
+
}] };
|
|
3627
|
+
let attachedPagesHl = [];
|
|
3628
|
+
try {
|
|
3629
|
+
attachedPagesHl = await waitForAttachWithEvents(connection, isMatchingPage, waitForAttachTimeoutMs);
|
|
3630
|
+
} catch {
|
|
3631
|
+
attachedPagesHl = connection.listTargets();
|
|
3632
|
+
return {
|
|
3633
|
+
content: [{
|
|
3634
|
+
type: "text",
|
|
3635
|
+
text: buildTimeoutError(headlessText, waitForAttachTimeoutMs / 1e3, attachedPagesHl)
|
|
3636
|
+
}],
|
|
3637
|
+
isError: true
|
|
3638
|
+
};
|
|
3639
|
+
}
|
|
3640
|
+
const pagesResultHl = listPages(connection, getTunnelStatus());
|
|
3641
|
+
return { content: [{
|
|
3642
|
+
type: "text",
|
|
3643
|
+
text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
|
|
3644
|
+
}] };
|
|
3645
|
+
}
|
|
3646
|
+
if (openInBrowser && guiAvailable && qrHttpServer) {
|
|
2977
3647
|
const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
|
|
2978
3648
|
if (browserResult.opened) {
|
|
2979
|
-
const
|
|
3649
|
+
const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
|
|
3650
|
+
const openResult = {
|
|
3651
|
+
attempted: true,
|
|
3652
|
+
succeeded: true,
|
|
3653
|
+
...browserResult.retried ? { retried: true } : {}
|
|
3654
|
+
};
|
|
3655
|
+
const shortText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
3656
|
+
relayUrl,
|
|
3657
|
+
openResult
|
|
3658
|
+
}, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
|
|
2980
3659
|
if (!waitForAttach) return { content: [{
|
|
2981
3660
|
type: "text",
|
|
2982
3661
|
text: shortText
|
|
@@ -3000,12 +3679,21 @@ function createDebugServer(deps) {
|
|
|
3000
3679
|
text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
3001
3680
|
}] };
|
|
3002
3681
|
}
|
|
3682
|
+
const openResult = {
|
|
3683
|
+
attempted: true,
|
|
3684
|
+
succeeded: false,
|
|
3685
|
+
failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
|
|
3686
|
+
pngUrl: browserResult.pngUrl,
|
|
3687
|
+
...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
|
|
3688
|
+
};
|
|
3003
3689
|
const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
|
|
3004
|
-
const fallbackNote =
|
|
3690
|
+
const fallbackNote = `[open_in_browser] 브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:
|
|
3691
|
+
${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
|
|
3005
3692
|
const qr = await renderQr(attachUrl);
|
|
3006
3693
|
const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
|
|
3007
3694
|
attachUrl,
|
|
3008
|
-
relayUrl
|
|
3695
|
+
relayUrl,
|
|
3696
|
+
openResult
|
|
3009
3697
|
}, null, 2)}\n\n${qr}`;
|
|
3010
3698
|
if (!waitForAttach) return { content: [{
|
|
3011
3699
|
type: "text",
|
|
@@ -3064,20 +3752,13 @@ function createDebugServer(deps) {
|
|
|
3064
3752
|
try {
|
|
3065
3753
|
await connection.enableDomains();
|
|
3066
3754
|
} catch (err) {
|
|
3067
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
3068
3755
|
if (name === "list_pages") {
|
|
3069
3756
|
if (connection instanceof ChiiCdpConnection) try {
|
|
3070
3757
|
await connection.refreshTargets();
|
|
3071
3758
|
} catch {}
|
|
3072
3759
|
return jsonResult$1(listPages(connection, getTunnelStatus()));
|
|
3073
3760
|
}
|
|
3074
|
-
return
|
|
3075
|
-
content: [{
|
|
3076
|
-
type: "text",
|
|
3077
|
-
text: `${message}\nCall list_pages to confirm a mini-app has attached over the relay.`
|
|
3078
|
-
}],
|
|
3079
|
-
isError: true
|
|
3080
|
-
};
|
|
3761
|
+
return classifyEnableDomainError(err, name);
|
|
3081
3762
|
}
|
|
3082
3763
|
try {
|
|
3083
3764
|
switch (name) {
|
|
@@ -3105,26 +3786,19 @@ function createDebugServer(deps) {
|
|
|
3105
3786
|
case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
|
|
3106
3787
|
case "evaluate": {
|
|
3107
3788
|
const expression = request.params.arguments?.expression;
|
|
3108
|
-
if (typeof expression !== "string" || expression === "") return
|
|
3109
|
-
|
|
3110
|
-
type: "text",
|
|
3111
|
-
text: "evaluate requires a non-empty expression."
|
|
3112
|
-
}],
|
|
3113
|
-
isError: true
|
|
3114
|
-
};
|
|
3789
|
+
if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
|
|
3790
|
+
if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("evaluate");
|
|
3115
3791
|
return jsonResult$1(await evaluate(connection, expression));
|
|
3116
3792
|
}
|
|
3117
3793
|
case "call_sdk": {
|
|
3118
3794
|
const sdkName = request.params.arguments?.name;
|
|
3119
|
-
if (typeof sdkName !== "string" || sdkName === "") return
|
|
3120
|
-
content: [{
|
|
3121
|
-
type: "text",
|
|
3122
|
-
text: "call_sdk requires a non-empty name."
|
|
3123
|
-
}],
|
|
3124
|
-
isError: true
|
|
3125
|
-
};
|
|
3795
|
+
if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
|
|
3126
3796
|
const rawArgs = request.params.arguments?.args;
|
|
3127
|
-
|
|
3797
|
+
const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
|
|
3798
|
+
if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
|
|
3799
|
+
const sdkResult = await callSdk(connection, sdkName, sdkArgs);
|
|
3800
|
+
if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
|
|
3801
|
+
return jsonResult$1(sdkResult);
|
|
3128
3802
|
}
|
|
3129
3803
|
default: return unknownTool(name);
|
|
3130
3804
|
}
|
|
@@ -3141,33 +3815,29 @@ function jsonResult$1(value) {
|
|
|
3141
3815
|
}] };
|
|
3142
3816
|
}
|
|
3143
3817
|
function unknownTool(name) {
|
|
3144
|
-
return {
|
|
3145
|
-
content: [{
|
|
3146
|
-
type: "text",
|
|
3147
|
-
text: `Unknown tool: ${name}`
|
|
3148
|
-
}],
|
|
3149
|
-
isError: true
|
|
3150
|
-
};
|
|
3818
|
+
return mcpError(`알 수 없는 tool: ${name}`);
|
|
3151
3819
|
}
|
|
3152
3820
|
/**
|
|
3153
|
-
*
|
|
3154
|
-
*
|
|
3155
|
-
*
|
|
3156
|
-
*
|
|
3821
|
+
* enableDomains()가 던진 에러를 4상태로 분류해 적절한 메시지를 반환한다.
|
|
3822
|
+
*
|
|
3823
|
+
* - "No mini-app page attached" → page 미attach (상태 2)
|
|
3824
|
+
* - crash/destroy/replaced 패턴 → page crash (상태 3)
|
|
3825
|
+
* - relay disconnect 패턴 → relay 연결 끊김
|
|
3826
|
+
* - 그 외 → 원본 메시지 + list_pages 안내
|
|
3157
3827
|
*/
|
|
3158
|
-
function
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3828
|
+
function classifyEnableDomainError(err, toolName) {
|
|
3829
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3830
|
+
if (message.includes("No mini-app page attached") || message.includes("페이지가 attach 안")) return pageMissingError(toolName);
|
|
3831
|
+
if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
|
|
3832
|
+
if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket") || message.includes("Chii relay connection closed")) return relayDisconnectError(toolName);
|
|
3833
|
+
return classifyToolError(err, toolName);
|
|
3162
3834
|
}
|
|
3835
|
+
/**
|
|
3836
|
+
* CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
|
|
3837
|
+
* debug-server 내부 try/catch 블록에서 공통으로 사용한다.
|
|
3838
|
+
*/
|
|
3163
3839
|
function errorResult(err, name) {
|
|
3164
|
-
return
|
|
3165
|
-
content: [{
|
|
3166
|
-
type: "text",
|
|
3167
|
-
text: `${name} failed: ${err instanceof Error ? err.message : String(err)}${isDisconnectError(err) ? "\n\nrelay 연결이 끊겼습니다. list_pages → enableDomains() 재호출로 재연결하세요. 폰이 백그라운드로 내려갔거나 미니앱이 종료됐을 수 있습니다." : "\nCall list_pages to confirm a mini-app has attached over the relay."}`
|
|
3168
|
-
}],
|
|
3169
|
-
isError: true
|
|
3170
|
-
};
|
|
3840
|
+
return classifyToolError(err, name);
|
|
3171
3841
|
}
|
|
3172
3842
|
/**
|
|
3173
3843
|
* Starts a polling watcher that detects the first 0→N target transition on
|
|
@@ -3239,7 +3909,7 @@ function buildRelayVerifyAuth() {
|
|
|
3239
3909
|
* 4. expose the debug tools backed by a `ChiiCdpConnection` + `ChiiAitSource`.
|
|
3240
3910
|
*/
|
|
3241
3911
|
async function runDebugServer(options = {}) {
|
|
3242
|
-
const lockHandle = acquireLock();
|
|
3912
|
+
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
3243
3913
|
const relayPort = options.relayPort ?? 0;
|
|
3244
3914
|
const verifyAuth = buildRelayVerifyAuth();
|
|
3245
3915
|
const totpEnabled = verifyAuth !== void 0;
|
|
@@ -3247,27 +3917,45 @@ async function runDebugServer(options = {}) {
|
|
|
3247
3917
|
port: relayPort,
|
|
3248
3918
|
verifyAuth
|
|
3249
3919
|
});
|
|
3920
|
+
logInfo("server.start", {
|
|
3921
|
+
port: relay.port,
|
|
3922
|
+
totpEnabled
|
|
3923
|
+
});
|
|
3250
3924
|
let tunnel = null;
|
|
3251
|
-
let tunnelStatus =
|
|
3252
|
-
up: false,
|
|
3253
|
-
wssUrl: null
|
|
3254
|
-
};
|
|
3925
|
+
let tunnelStatus = makeTunnelStatus(false, null);
|
|
3255
3926
|
generateAttachToken();
|
|
3927
|
+
let tunnelProbe = null;
|
|
3256
3928
|
startQuickTunnel(relay.port).then((t) => {
|
|
3257
3929
|
tunnel = t;
|
|
3258
|
-
tunnelStatus =
|
|
3259
|
-
up: true,
|
|
3260
|
-
wssUrl: t.wssUrl
|
|
3261
|
-
};
|
|
3930
|
+
tunnelStatus = makeTunnelStatus(true, t.wssUrl);
|
|
3262
3931
|
lockHandle.updateWssUrl(t.wssUrl);
|
|
3932
|
+
logInfo("tunnel.up", { totpEnabled });
|
|
3933
|
+
tunnelProbe = startTunnelHealthProbe(t, relay.port, {
|
|
3934
|
+
onReissue: (newTunnel) => {
|
|
3935
|
+
tunnel = newTunnel;
|
|
3936
|
+
tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
|
|
3937
|
+
lockHandle.updateWssUrl(newTunnel.wssUrl);
|
|
3938
|
+
printAttachBanner({
|
|
3939
|
+
wssUrl: newTunnel.wssUrl,
|
|
3940
|
+
totpEnabled
|
|
3941
|
+
}).then(() => {
|
|
3942
|
+
logInfo("tunnel.up", {
|
|
3943
|
+
totpEnabled,
|
|
3944
|
+
reissued: true
|
|
3945
|
+
});
|
|
3946
|
+
});
|
|
3947
|
+
},
|
|
3948
|
+
onPermanentDrop: (droppedAt) => {
|
|
3949
|
+
tunnelStatus = makeTunnelStatus(false, null, droppedAt, 3);
|
|
3950
|
+
logError("tunnel.down", { msg: `tunnel permanently dropped (${droppedAt}). Restart: npx @ait-co/devtools devtools-mcp` });
|
|
3951
|
+
}
|
|
3952
|
+
});
|
|
3263
3953
|
return printAttachBanner({
|
|
3264
3954
|
wssUrl: t.wssUrl,
|
|
3265
3955
|
totpEnabled
|
|
3266
3956
|
});
|
|
3267
3957
|
}, (err) => {
|
|
3268
|
-
|
|
3269
|
-
process.stderr.write(`[ait-debug] Failed to open cloudflared quick tunnel: ${message}\n[ait-debug] The relay is up locally; attach over the public URL is unavailable until the tunnel starts.
|
|
3270
|
-
`);
|
|
3958
|
+
logError("tunnel.down", { msg: `Failed to open cloudflared quick tunnel: ${err instanceof Error ? err.message : String(err)}. The relay is up locally; attach over the public URL is unavailable until the tunnel starts.` });
|
|
3271
3959
|
});
|
|
3272
3960
|
const connection = new ChiiCdpConnection({ relayBaseUrl: relay.baseUrl });
|
|
3273
3961
|
const aitSource = new ChiiAitSource(connection);
|
|
@@ -3275,17 +3963,19 @@ async function runDebugServer(options = {}) {
|
|
|
3275
3963
|
try {
|
|
3276
3964
|
qrServer = await startQrHttpServer();
|
|
3277
3965
|
} catch (err) {
|
|
3278
|
-
|
|
3279
|
-
process.stderr.write(`[ait-debug] QR HTTP 서버 시작 실패 (text QR fallback 사용): ${message}\n`);
|
|
3966
|
+
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
3280
3967
|
}
|
|
3281
3968
|
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
3969
|
+
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
3282
3970
|
const server = createDebugServer({
|
|
3283
3971
|
connection,
|
|
3284
3972
|
aitSource,
|
|
3285
3973
|
getTunnelStatus: () => tunnelStatus,
|
|
3286
3974
|
get qrHttpServer() {
|
|
3287
3975
|
return qrServer;
|
|
3288
|
-
}
|
|
3976
|
+
},
|
|
3977
|
+
diagnosticsCollector,
|
|
3978
|
+
defaultEnv: "relay-dev"
|
|
3289
3979
|
});
|
|
3290
3980
|
const transport = new StdioServerTransport();
|
|
3291
3981
|
let closed = false;
|
|
@@ -3294,6 +3984,7 @@ async function runDebugServer(options = {}) {
|
|
|
3294
3984
|
if (closed) return;
|
|
3295
3985
|
closed = true;
|
|
3296
3986
|
attachWatcher?.stop();
|
|
3987
|
+
tunnelProbe?.stop();
|
|
3297
3988
|
connection.close();
|
|
3298
3989
|
tunnel?.stop();
|
|
3299
3990
|
relay.close();
|
|
@@ -3308,23 +3999,34 @@ async function runDebugServer(options = {}) {
|
|
|
3308
3999
|
if (!closed) {
|
|
3309
4000
|
closed = true;
|
|
3310
4001
|
attachWatcher?.stop();
|
|
4002
|
+
tunnelProbe?.stop();
|
|
3311
4003
|
tunnel?.stop();
|
|
3312
4004
|
lockHandle.release();
|
|
3313
4005
|
}
|
|
3314
4006
|
});
|
|
3315
4007
|
process.on("uncaughtException", (err) => {
|
|
3316
|
-
|
|
4008
|
+
logError("tool.error", {
|
|
4009
|
+
msg: `uncaughtException: ${String(err)}`,
|
|
4010
|
+
errorKind: "uncaught"
|
|
4011
|
+
});
|
|
3317
4012
|
shutdown();
|
|
3318
4013
|
process.exit(1);
|
|
3319
4014
|
});
|
|
3320
4015
|
process.on("unhandledRejection", (reason) => {
|
|
3321
|
-
|
|
4016
|
+
logError("tool.error", {
|
|
4017
|
+
msg: `unhandledRejection: ${String(reason)}`,
|
|
4018
|
+
errorKind: "unhandled-rejection"
|
|
4019
|
+
});
|
|
3322
4020
|
shutdown();
|
|
3323
4021
|
process.exit(1);
|
|
3324
4022
|
});
|
|
3325
4023
|
await server.connect(transport);
|
|
3326
4024
|
attachWatcher = startAttachWatcher(connection, server, 1e3, () => {
|
|
3327
|
-
|
|
4025
|
+
diagnosticsCollector.recordAttach();
|
|
4026
|
+
devtoolsOpener.open(tunnelStatus.wssUrl, getEnvironment({
|
|
4027
|
+
connection,
|
|
4028
|
+
defaultEnv: "relay-dev"
|
|
4029
|
+
}));
|
|
3328
4030
|
});
|
|
3329
4031
|
}
|
|
3330
4032
|
/**
|
|
@@ -3346,7 +4048,7 @@ async function runDebugServer(options = {}) {
|
|
|
3346
4048
|
* expected and noted in the PR as an explicit out-of-scope follow-up.
|
|
3347
4049
|
*/
|
|
3348
4050
|
async function runLocalDebugServer(options = {}) {
|
|
3349
|
-
const lockHandle = acquireLock();
|
|
4051
|
+
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
3350
4052
|
const chromium = await launchChromium({
|
|
3351
4053
|
port: options.cdpPort ?? 0,
|
|
3352
4054
|
devUrl: options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173"
|
|
@@ -3361,7 +4063,8 @@ async function runLocalDebugServer(options = {}) {
|
|
|
3361
4063
|
const server = createDebugServer({
|
|
3362
4064
|
connection,
|
|
3363
4065
|
aitSource,
|
|
3364
|
-
getTunnelStatus: () => tunnelStatus
|
|
4066
|
+
getTunnelStatus: () => tunnelStatus,
|
|
4067
|
+
defaultEnv: "mock"
|
|
3365
4068
|
});
|
|
3366
4069
|
const transport = new StdioServerTransport();
|
|
3367
4070
|
let closed = false;
|
|
@@ -3387,12 +4090,20 @@ async function runLocalDebugServer(options = {}) {
|
|
|
3387
4090
|
}
|
|
3388
4091
|
});
|
|
3389
4092
|
process.on("uncaughtException", (err) => {
|
|
3390
|
-
|
|
4093
|
+
logError("tool.error", {
|
|
4094
|
+
msg: `uncaughtException: ${String(err)}`,
|
|
4095
|
+
errorKind: "uncaught",
|
|
4096
|
+
mode: "local"
|
|
4097
|
+
});
|
|
3391
4098
|
shutdown();
|
|
3392
4099
|
process.exit(1);
|
|
3393
4100
|
});
|
|
3394
4101
|
process.on("unhandledRejection", (reason) => {
|
|
3395
|
-
|
|
4102
|
+
logError("tool.error", {
|
|
4103
|
+
msg: `unhandledRejection: ${String(reason)}`,
|
|
4104
|
+
errorKind: "unhandled-rejection",
|
|
4105
|
+
mode: "local"
|
|
4106
|
+
});
|
|
3396
4107
|
shutdown();
|
|
3397
4108
|
process.exit(1);
|
|
3398
4109
|
});
|
|
@@ -3458,6 +4169,27 @@ var HttpAitSource = class {
|
|
|
3458
4169
|
* (dev). `devtools_get_mock_state` (the original devtools#130 name) is kept as a
|
|
3459
4170
|
* backward-compatible alias of `AIT.getMockState`.
|
|
3460
4171
|
*
|
|
4172
|
+
* Issue #305 (M2-1) — dev/debug tool-surface unification:
|
|
4173
|
+
* dev-mode now also exposes `list_pages`, `get_diagnostics`, `measure_safe_area`,
|
|
4174
|
+
* and `call_sdk` so the docs/qa/scenarios.md acceptance sequence
|
|
4175
|
+
* `list_pages → measure_safe_area → call_sdk` works in dev mode without
|
|
4176
|
+
* "Unknown tool" failures.
|
|
4177
|
+
*
|
|
4178
|
+
* - `list_pages` — shim: returns the Vite dev URL as a single-entry array.
|
|
4179
|
+
* - `get_diagnostics` — dumps dev-mode server state (endpoint URL, last fetch
|
|
4180
|
+
* error, reachability, mode/environment metadata).
|
|
4181
|
+
* - `measure_safe_area`— reads safeAreaInsets from the mock state snapshot
|
|
4182
|
+
* (source: 'mock-vite').
|
|
4183
|
+
* - `call_sdk` — reads mock state and builds a mock-equivalent result
|
|
4184
|
+
* using window.__ait.state for supported methods; returns
|
|
4185
|
+
* an explicit tier-filter error for methods that require
|
|
4186
|
+
* a live CDP bridge.
|
|
4187
|
+
* - CDP-only tools (`evaluate`, `take_screenshot`, `get_dom_document`,
|
|
4188
|
+
* `take_snapshot`, `list_console_messages`,
|
|
4189
|
+
* `list_network_requests`, `list_exceptions`) — return an
|
|
4190
|
+
* explicit tier-filter error explaining that CDP is unavailable
|
|
4191
|
+
* in dev-mode and pointing to `--mode=local` or `--mode=debug`.
|
|
4192
|
+
*
|
|
3461
4193
|
* This module is reached via the `devtools-mcp --mode=dev` CLI entry (see
|
|
3462
4194
|
* `cli.ts`); the default (no flag) bin mode is the debug-mode CDP/Chii server.
|
|
3463
4195
|
*
|
|
@@ -3472,12 +4204,18 @@ var HttpAitSource = class {
|
|
|
3472
4204
|
* }
|
|
3473
4205
|
* }
|
|
3474
4206
|
*/
|
|
4207
|
+
/** Error message prefix for CDP-dependent tools called in dev-mode. */
|
|
4208
|
+
const CDP_UNAVAILABLE_IN_DEV_MODE = "dev-mode에서는 CDP 연결이 없어 이 도구를 사용할 수 없습니다. 실기기 또는 로컬 Chromium에 붙이려면 `devtools-mcp --mode=local` 또는 `devtools-mcp` (debug 모드 기본)로 전환하세요.";
|
|
3475
4209
|
/**
|
|
3476
4210
|
* Tool descriptors served by the dev-mode server.
|
|
3477
4211
|
*
|
|
3478
4212
|
* All dev-mode tools are Tier C (both envs) per RFC #277 — the dev-mode server
|
|
3479
4213
|
* itself is the mock-side embodiment of those Tier C tools. `availableIn` is
|
|
3480
4214
|
* declared so the surface stays consistent with the debug-mode registry.
|
|
4215
|
+
*
|
|
4216
|
+
* Issue #305: CDP-only tools are also listed with explicit descriptions so
|
|
4217
|
+
* agents do not get "Unknown tool" failures — they get a clear tier-filter
|
|
4218
|
+
* error message instead.
|
|
3481
4219
|
*/
|
|
3482
4220
|
const DEV_TOOL_DEFINITIONS = [
|
|
3483
4221
|
{
|
|
@@ -3519,56 +4257,292 @@ const DEV_TOOL_DEFINITIONS = [
|
|
|
3519
4257
|
required: []
|
|
3520
4258
|
},
|
|
3521
4259
|
availableIn: "both"
|
|
4260
|
+
},
|
|
4261
|
+
{
|
|
4262
|
+
name: "list_pages",
|
|
4263
|
+
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.",
|
|
4264
|
+
inputSchema: {
|
|
4265
|
+
type: "object",
|
|
4266
|
+
properties: {},
|
|
4267
|
+
required: []
|
|
4268
|
+
},
|
|
4269
|
+
availableIn: "both"
|
|
4270
|
+
},
|
|
4271
|
+
{
|
|
4272
|
+
name: "get_diagnostics",
|
|
4273
|
+
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.",
|
|
4274
|
+
inputSchema: {
|
|
4275
|
+
type: "object",
|
|
4276
|
+
properties: { recent_errors_limit: {
|
|
4277
|
+
type: "number",
|
|
4278
|
+
description: "Ignored in dev-mode (no error ring buffer). Present for schema parity."
|
|
4279
|
+
} },
|
|
4280
|
+
required: []
|
|
4281
|
+
},
|
|
4282
|
+
availableIn: "both"
|
|
4283
|
+
},
|
|
4284
|
+
{
|
|
4285
|
+
name: "measure_safe_area",
|
|
4286
|
+
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.",
|
|
4287
|
+
inputSchema: {
|
|
4288
|
+
type: "object",
|
|
4289
|
+
properties: {},
|
|
4290
|
+
required: []
|
|
4291
|
+
},
|
|
4292
|
+
availableIn: "both"
|
|
4293
|
+
},
|
|
4294
|
+
{
|
|
4295
|
+
name: "call_sdk",
|
|
4296
|
+
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.",
|
|
4297
|
+
inputSchema: {
|
|
4298
|
+
type: "object",
|
|
4299
|
+
properties: {
|
|
4300
|
+
name: {
|
|
4301
|
+
type: "string",
|
|
4302
|
+
description: "Mock SDK method name to call (e.g. \"getOperationalEnvironment\")."
|
|
4303
|
+
},
|
|
4304
|
+
args: {
|
|
4305
|
+
type: "array",
|
|
4306
|
+
description: "Arguments (ignored in dev-mode mock path; present for schema parity).",
|
|
4307
|
+
items: {}
|
|
4308
|
+
}
|
|
4309
|
+
},
|
|
4310
|
+
required: ["name"]
|
|
4311
|
+
},
|
|
4312
|
+
availableIn: "both"
|
|
4313
|
+
},
|
|
4314
|
+
{
|
|
4315
|
+
name: "evaluate",
|
|
4316
|
+
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.",
|
|
4317
|
+
inputSchema: {
|
|
4318
|
+
type: "object",
|
|
4319
|
+
properties: { expression: {
|
|
4320
|
+
type: "string",
|
|
4321
|
+
description: "JavaScript expression to evaluate."
|
|
4322
|
+
} },
|
|
4323
|
+
required: ["expression"]
|
|
4324
|
+
},
|
|
4325
|
+
availableIn: "both"
|
|
4326
|
+
},
|
|
4327
|
+
{
|
|
4328
|
+
name: "take_screenshot",
|
|
4329
|
+
description: "Captures a PNG screenshot via CDP Page.captureScreenshot. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4330
|
+
inputSchema: {
|
|
4331
|
+
type: "object",
|
|
4332
|
+
properties: {},
|
|
4333
|
+
required: []
|
|
4334
|
+
},
|
|
4335
|
+
availableIn: "both"
|
|
4336
|
+
},
|
|
4337
|
+
{
|
|
4338
|
+
name: "get_dom_document",
|
|
4339
|
+
description: "Returns the DOM tree via CDP DOM.getDocument. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4340
|
+
inputSchema: {
|
|
4341
|
+
type: "object",
|
|
4342
|
+
properties: {},
|
|
4343
|
+
required: []
|
|
4344
|
+
},
|
|
4345
|
+
availableIn: "both"
|
|
4346
|
+
},
|
|
4347
|
+
{
|
|
4348
|
+
name: "take_snapshot",
|
|
4349
|
+
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`.",
|
|
4350
|
+
inputSchema: {
|
|
4351
|
+
type: "object",
|
|
4352
|
+
properties: {},
|
|
4353
|
+
required: []
|
|
4354
|
+
},
|
|
4355
|
+
availableIn: "both"
|
|
4356
|
+
},
|
|
4357
|
+
{
|
|
4358
|
+
name: "list_console_messages",
|
|
4359
|
+
description: "Lists console messages captured via CDP Runtime.consoleAPICalled. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4360
|
+
inputSchema: {
|
|
4361
|
+
type: "object",
|
|
4362
|
+
properties: {},
|
|
4363
|
+
required: []
|
|
4364
|
+
},
|
|
4365
|
+
availableIn: "both"
|
|
4366
|
+
},
|
|
4367
|
+
{
|
|
4368
|
+
name: "list_network_requests",
|
|
4369
|
+
description: "Lists network requests captured via CDP Network events. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4370
|
+
inputSchema: {
|
|
4371
|
+
type: "object",
|
|
4372
|
+
properties: {},
|
|
4373
|
+
required: []
|
|
4374
|
+
},
|
|
4375
|
+
availableIn: "both"
|
|
4376
|
+
},
|
|
4377
|
+
{
|
|
4378
|
+
name: "list_exceptions",
|
|
4379
|
+
description: "Lists JS exceptions captured via CDP Runtime.exceptionThrown. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug`.",
|
|
4380
|
+
inputSchema: {
|
|
4381
|
+
type: "object",
|
|
4382
|
+
properties: { limit: {
|
|
4383
|
+
type: "number",
|
|
4384
|
+
description: "Maximum exceptions to return."
|
|
4385
|
+
} },
|
|
4386
|
+
required: []
|
|
4387
|
+
},
|
|
4388
|
+
availableIn: "both"
|
|
3522
4389
|
}
|
|
3523
4390
|
];
|
|
4391
|
+
/** All tool names served in dev-mode (including tier-filter stubs). */
|
|
3524
4392
|
const DEV_TOOL_NAMES = new Set(DEV_TOOL_DEFINITIONS.map((t) => t.name));
|
|
4393
|
+
/** CDP-only tools — return a tier-filter error in dev-mode. */
|
|
4394
|
+
const CDP_ONLY_TOOL_NAMES = new Set([
|
|
4395
|
+
"evaluate",
|
|
4396
|
+
"take_screenshot",
|
|
4397
|
+
"get_dom_document",
|
|
4398
|
+
"take_snapshot",
|
|
4399
|
+
"list_console_messages",
|
|
4400
|
+
"list_network_requests",
|
|
4401
|
+
"list_exceptions"
|
|
4402
|
+
]);
|
|
4403
|
+
/**
|
|
4404
|
+
* Builds the `list_pages` dev-mode shim response.
|
|
4405
|
+
* Returns the Vite dev URL as a single-entry page list with `devMode: true`.
|
|
4406
|
+
*/
|
|
4407
|
+
function buildDevListPagesResult(devtoolsUrl) {
|
|
4408
|
+
return {
|
|
4409
|
+
pages: [{
|
|
4410
|
+
url: devtoolsUrl,
|
|
4411
|
+
title: "dev fixture",
|
|
4412
|
+
attached: true
|
|
4413
|
+
}],
|
|
4414
|
+
tunnel: { up: false },
|
|
4415
|
+
devMode: true,
|
|
4416
|
+
singleAttachModel: true
|
|
4417
|
+
};
|
|
4418
|
+
}
|
|
4419
|
+
/**
|
|
4420
|
+
* Builds the `get_diagnostics` dev-mode response.
|
|
4421
|
+
* Probes the mock state endpoint reachability and returns server metadata.
|
|
4422
|
+
*/
|
|
4423
|
+
async function buildDevDiagnostics(devtoolsUrl, stateEndpoint, fetchImpl) {
|
|
4424
|
+
let reachable = false;
|
|
4425
|
+
let lastFetchError = null;
|
|
4426
|
+
let lastFetchAt = null;
|
|
4427
|
+
try {
|
|
4428
|
+
const res = await fetchImpl(stateEndpoint);
|
|
4429
|
+
reachable = res.ok;
|
|
4430
|
+
lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4431
|
+
if (!res.ok) lastFetchError = `HTTP ${res.status} ${res.statusText}`;
|
|
4432
|
+
} catch (err) {
|
|
4433
|
+
lastFetchError = err instanceof Error ? err.message : String(err);
|
|
4434
|
+
lastFetchAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4435
|
+
}
|
|
4436
|
+
return {
|
|
4437
|
+
mode: "dev",
|
|
4438
|
+
devtoolsUrl,
|
|
4439
|
+
mcpStateEndpoint: stateEndpoint,
|
|
4440
|
+
mockStateEndpointReachable: reachable,
|
|
4441
|
+
lastFetchAt,
|
|
4442
|
+
lastFetchError,
|
|
4443
|
+
environment: {
|
|
4444
|
+
kind: "mock",
|
|
4445
|
+
reason: "dev-mode — Vite HTTP endpoint, no CDP connection"
|
|
4446
|
+
},
|
|
4447
|
+
nextRecommendedAction: reachable ? null : "mock state endpoint가 응답하지 않습니다. Vite dev 서버가 `mcp: true` 옵션으로 실행 중인지 확인하고, 필요하면 dev 서버를 재시작하세요."
|
|
4448
|
+
};
|
|
4449
|
+
}
|
|
4450
|
+
/**
|
|
4451
|
+
* Builds the `measure_safe_area` dev-mode response from mock state.
|
|
4452
|
+
* Reads `safeAreaInsets` from the AIT mock state and returns a parity-schema
|
|
4453
|
+
* result with `source: 'mock-vite'`.
|
|
4454
|
+
*/
|
|
4455
|
+
async function buildDevMeasureSafeArea(aitSource) {
|
|
4456
|
+
const rawInsets = (await aitSource.get("AIT.getMockState")).safeAreaInsets;
|
|
4457
|
+
let sdkInsets = null;
|
|
4458
|
+
if (rawInsets !== null && typeof rawInsets === "object" && !Array.isArray(rawInsets)) {
|
|
4459
|
+
const r = rawInsets;
|
|
4460
|
+
sdkInsets = {
|
|
4461
|
+
top: typeof r.top === "number" ? r.top : 0,
|
|
4462
|
+
right: typeof r.right === "number" ? r.right : 0,
|
|
4463
|
+
bottom: typeof r.bottom === "number" ? r.bottom : 0,
|
|
4464
|
+
left: typeof r.left === "number" ? r.left : 0
|
|
4465
|
+
};
|
|
4466
|
+
}
|
|
4467
|
+
return {
|
|
4468
|
+
source: "mock-vite",
|
|
4469
|
+
cssEnv: {
|
|
4470
|
+
top: 0,
|
|
4471
|
+
right: 0,
|
|
4472
|
+
bottom: 0,
|
|
4473
|
+
left: 0
|
|
4474
|
+
},
|
|
4475
|
+
sdkInsets,
|
|
4476
|
+
sdkInsetsSource: sdkInsets !== null ? "window.__ait" : null,
|
|
4477
|
+
...sdkInsets === null ? { sdkInsetsError: "window.__ait.state.safeAreaInsets not found in mock state snapshot" } : {},
|
|
4478
|
+
innerWidth: null,
|
|
4479
|
+
innerHeight: null,
|
|
4480
|
+
devicePixelRatio: null,
|
|
4481
|
+
userAgent: null,
|
|
4482
|
+
navBarHeight: null,
|
|
4483
|
+
navBarHeightSource: "not-available-in-dev-mode"
|
|
4484
|
+
};
|
|
4485
|
+
}
|
|
4486
|
+
/**
|
|
4487
|
+
* Builds the `call_sdk` dev-mode response.
|
|
4488
|
+
*
|
|
4489
|
+
* Supported methods are served from the mock state snapshot. Unsupported
|
|
4490
|
+
* methods return `{ ok: false, error: 'dev-mode-unsupported: ...' }` so the
|
|
4491
|
+
* agent gets an informative message rather than a generic failure.
|
|
4492
|
+
*/
|
|
4493
|
+
async function buildDevCallSdk(methodName, aitSource) {
|
|
4494
|
+
switch (methodName) {
|
|
4495
|
+
case "getOperationalEnvironment": {
|
|
4496
|
+
const env = await aitSource.get("AIT.getOperationalEnvironment");
|
|
4497
|
+
return {
|
|
4498
|
+
ok: true,
|
|
4499
|
+
value: {
|
|
4500
|
+
environment: env.environment,
|
|
4501
|
+
sdkVersion: env.sdkVersion
|
|
4502
|
+
}
|
|
4503
|
+
};
|
|
4504
|
+
}
|
|
4505
|
+
default: return {
|
|
4506
|
+
ok: false,
|
|
4507
|
+
error: `dev-mode-unsupported: "${methodName}"은 dev-mode에서 직접 호출할 수 없습니다. CDP bridge(window.__sdkCall)가 없으므로 실제 SDK 호출은 \`--mode=local\` 또는 debug 모드에서만 가능합니다. 지원 메서드: getOperationalEnvironment (mock state에서 읽음).`
|
|
4508
|
+
};
|
|
4509
|
+
}
|
|
4510
|
+
}
|
|
3525
4511
|
/** Builds the dev-mode MCP server (does not connect a transport). */
|
|
3526
4512
|
function createDevServer(deps = {}) {
|
|
3527
|
-
const
|
|
4513
|
+
const devtoolsUrl = process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
|
|
4514
|
+
const stateEndpoint = `${devtoolsUrl}/api/ait-devtools/state`;
|
|
3528
4515
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
3529
4516
|
const server = new Server({
|
|
3530
4517
|
name: "ait-devtools",
|
|
3531
|
-
version: "0.1.
|
|
4518
|
+
version: "0.1.45"
|
|
3532
4519
|
}, { capabilities: { tools: {} } });
|
|
3533
4520
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
3534
4521
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3535
4522
|
const name = request.params.name;
|
|
3536
|
-
if (!DEV_TOOL_NAMES.has(name)) return {
|
|
3537
|
-
|
|
3538
|
-
type: "text",
|
|
3539
|
-
text: `Unknown tool: ${name}`
|
|
3540
|
-
}],
|
|
3541
|
-
isError: true
|
|
3542
|
-
};
|
|
4523
|
+
if (!DEV_TOOL_NAMES.has(name)) return mcpError(`알 수 없는 tool: ${name}`);
|
|
4524
|
+
if (CDP_ONLY_TOOL_NAMES.has(name)) return mcpError(`${name}: ${CDP_UNAVAILABLE_IN_DEV_MODE}`);
|
|
3543
4525
|
try {
|
|
3544
4526
|
const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
|
|
3545
|
-
if (
|
|
3546
|
-
content: [{
|
|
3547
|
-
type: "text",
|
|
3548
|
-
text: `Unknown tool: ${name}`
|
|
3549
|
-
}],
|
|
3550
|
-
isError: true
|
|
3551
|
-
};
|
|
3552
|
-
switch (effective) {
|
|
4527
|
+
if (isAitToolName(effective)) switch (effective) {
|
|
3553
4528
|
case "AIT.getMockState": return jsonResult(await getMockState(aitSource));
|
|
3554
4529
|
case "AIT.getOperationalEnvironment": return jsonResult(await getOperationalEnvironment(aitSource));
|
|
3555
4530
|
case "AIT.getSdkCallHistory": return jsonResult(await getSdkCallHistory(aitSource));
|
|
3556
|
-
default: return {
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
4531
|
+
default: return mcpError(`알 수 없는 tool: ${name}`);
|
|
4532
|
+
}
|
|
4533
|
+
switch (name) {
|
|
4534
|
+
case "list_pages": return jsonResult(buildDevListPagesResult(devtoolsUrl));
|
|
4535
|
+
case "get_diagnostics": return jsonResult(await buildDevDiagnostics(devtoolsUrl, stateEndpoint, (url) => fetch(url)));
|
|
4536
|
+
case "measure_safe_area": return jsonResult(await buildDevMeasureSafeArea(aitSource));
|
|
4537
|
+
case "call_sdk": {
|
|
4538
|
+
const sdkName = request.params.arguments?.name;
|
|
4539
|
+
if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 메서드 이름을 전달하세요.");
|
|
4540
|
+
return jsonResult(await buildDevCallSdk(sdkName, aitSource));
|
|
4541
|
+
}
|
|
4542
|
+
default: return mcpError(`알 수 없는 tool: ${name}`);
|
|
3563
4543
|
}
|
|
3564
4544
|
} catch (err) {
|
|
3565
|
-
return {
|
|
3566
|
-
content: [{
|
|
3567
|
-
type: "text",
|
|
3568
|
-
text: `${err instanceof Error ? err.message : String(err)}\nIs the Vite dev server running with the @ait-co/devtools unplugin option \`mcp: true\`? Is AIT_DEVTOOLS_URL set correctly?`
|
|
3569
|
-
}],
|
|
3570
|
-
isError: true
|
|
3571
|
-
};
|
|
4545
|
+
return mcpError(`${name} 실패: ${err instanceof Error ? err.message : String(err)}\nVite dev 서버가 @ait-co/devtools unplugin \`mcp: true\` 옵션으로 실행 중인지 확인하세요. AIT_DEVTOOLS_URL 환경변수가 올바르게 설정됐는지도 확인하세요.`);
|
|
3572
4546
|
}
|
|
3573
4547
|
});
|
|
3574
4548
|
return server;
|
|
@@ -3606,6 +4580,15 @@ async function runDevServer() {
|
|
|
3606
4580
|
*
|
|
3607
4581
|
* Node-only stdio process.
|
|
3608
4582
|
*/
|
|
4583
|
+
/**
|
|
4584
|
+
* Returns `true` when `--force` or `--takeover` is present in argv.
|
|
4585
|
+
*
|
|
4586
|
+
* Both flags are accepted as aliases — `--force` is the short form listed in
|
|
4587
|
+
* the `--help` output; `--takeover` is a longer synonym.
|
|
4588
|
+
*/
|
|
4589
|
+
function parseForce(argv) {
|
|
4590
|
+
return argv.includes("--force") || argv.includes("--takeover");
|
|
4591
|
+
}
|
|
3609
4592
|
/** Parses `--mode=<value>` / `--mode <value>` from argv; default `debug`. */
|
|
3610
4593
|
function parseMode(argv) {
|
|
3611
4594
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -3653,8 +4636,12 @@ function normalizeTarget(value) {
|
|
|
3653
4636
|
async function main() {
|
|
3654
4637
|
const args = process.argv.slice(2);
|
|
3655
4638
|
if (parseMode(args) === "dev") await runDevServer();
|
|
3656
|
-
else
|
|
3657
|
-
|
|
4639
|
+
else {
|
|
4640
|
+
const target = parseTarget(args);
|
|
4641
|
+
const force = parseForce(args);
|
|
4642
|
+
if (target === "local") await runLocalDebugServer({ force });
|
|
4643
|
+
else await runDebugServer({ force });
|
|
4644
|
+
}
|
|
3658
4645
|
}
|
|
3659
4646
|
/**
|
|
3660
4647
|
* True when this file is the process entry (the bin), not an import.
|
|
@@ -3681,6 +4668,6 @@ if (isEntrypoint()) main().catch((err) => {
|
|
|
3681
4668
|
process.exitCode = 1;
|
|
3682
4669
|
});
|
|
3683
4670
|
//#endregion
|
|
3684
|
-
export { parseMode, parseTarget };
|
|
4671
|
+
export { parseForce, parseMode, parseTarget };
|
|
3685
4672
|
|
|
3686
4673
|
//# sourceMappingURL=cli.js.map
|