@mmerterden/multi-agent-toolkit-mcp 3.0.0

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +871 -0
  2. package/LICENSE +21 -0
  3. package/README.md +358 -0
  4. package/README.tr.md +358 -0
  5. package/index.js +1725 -0
  6. package/package.json +89 -0
  7. package/tools/crash-logs/index.js +29 -0
  8. package/tools/design-check/content-cardinality.js +204 -0
  9. package/tools/design-check/geometry.js +140 -0
  10. package/tools/design-check/index.js +219 -0
  11. package/tools/design-check/mock-detect.js +213 -0
  12. package/tools/design-check/report.js +596 -0
  13. package/tools/design-check/scan.js +91 -0
  14. package/tools/design-check/scenario-inventory.js +598 -0
  15. package/tools/design-check/visual-compare.js +961 -0
  16. package/tools/ios-app-store-audit/context.js +181 -0
  17. package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
  18. package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
  19. package/tools/ios-app-store-audit/index.js +164 -0
  20. package/tools/ios-app-store-audit/models.js +57 -0
  21. package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
  22. package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
  23. package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
  24. package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
  25. package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
  26. package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
  27. package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
  28. package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
  29. package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
  30. package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
  31. package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
  32. package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
  33. package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
  34. package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
  35. package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
  36. package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
  37. package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
  38. package/tools/ios-app-store-audit/rules/team-id.js +62 -0
  39. package/tools/ios-testflight/index.js +489 -0
  40. package/tools/ui-inspect/index.js +57 -0
  41. package/ui-tree-dumper.swift +122 -0
package/index.js ADDED
@@ -0,0 +1,1725 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * multi-agent-toolkit-mcp - iOS Simulator & Android Emulator MCP Server
5
+ *
6
+ * Tools for mobile device control: screenshot, tap, swipe, type, UI tree,
7
+ * dark mode, accessibility testing, push notifications, location simulation,
8
+ * screen recording, crash logs, mock-mode vs Figma design audit (design-check
9
+ * family), and more. The advertised count is ALL_TOOLS.length; README and
10
+ * package.json carry the number and the gates hold them to it.
11
+ *
12
+ * Works with: Claude Code, Claude Desktop, Cursor, Windsurf, Copilot CLI, Cline, Zed
13
+ */
14
+
15
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
18
+ import { execSync, exec, spawn } from "child_process";
19
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs";
20
+ import { join, dirname, basename, isAbsolute, resolve, sep } from "path";
21
+ import { homedir } from "os";
22
+ import { fileURLToPath } from "url";
23
+ import { runAudit as runAppStoreAudit } from "./tools/ios-app-store-audit/index.js";
24
+ import {
25
+ exportIpa,
26
+ listProviders,
27
+ resolveAuth,
28
+ validateApp,
29
+ } from "./tools/ios-testflight/index.js";
30
+ import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
31
+ import { interactiveElements } from "./tools/ui-inspect/index.js";
32
+ import { selectCrashReports } from "./tools/crash-logs/index.js";
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+ const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
36
+ if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true });
37
+
38
+ // ── Helpers ──
39
+
40
+ // Marker prefix for command failures. The CallTool dispatch turns any result
41
+ // carrying it into an MCP error result (isError: true), so a host - and the
42
+ // pipeline gates reading these results - can tell failure from success.
43
+ const ERROR_PREFIX = "ERROR: ";
44
+
45
+ // A failing CLI often dumps its entire usage text (simctl's is ~3 KB). Inlining
46
+ // that spends the caller's context on a help page, so keep only the actionable
47
+ // head of the message.
48
+ const ERROR_MAX_CHARS = 600;
49
+
50
+ function truncateError(msg) {
51
+ const flat = String(msg).trim();
52
+ if (flat.length <= ERROR_MAX_CHARS) return flat;
53
+ return `${flat.slice(0, ERROR_MAX_CHARS)}\n... [${flat.length - ERROR_MAX_CHARS} more chars truncated]`;
54
+ }
55
+
56
+ function isFailure(value) {
57
+ return typeof value === "string" && value.startsWith(ERROR_PREFIX);
58
+ }
59
+
60
+ function run(cmd, opts = {}) {
61
+ try {
62
+ return execSync(cmd, { encoding: "utf-8", timeout: 30000, ...opts }).trim();
63
+ } catch (e) {
64
+ return `${ERROR_PREFIX}${truncateError(e.message)}`;
65
+ }
66
+ }
67
+
68
+ // Async twin of run() for tools that hold the child for minutes: execSync would
69
+ // block the event loop, leaving the server unable to answer any other request
70
+ // (or a cancellation) for the whole build. Same contract: trimmed stdout on
71
+ // success, ERROR_PREFIX marker on failure.
72
+ function runAsync(cmd, opts = {}) {
73
+ return new Promise((resolve) => {
74
+ exec(cmd, { encoding: "utf-8", timeout: 30000, maxBuffer: 16 * 1024 * 1024, ...opts }, (err, stdout, stderr) => {
75
+ if (err) resolve(`${ERROR_PREFIX}${truncateError(`${err.message}\n${stdout || ""}\n${stderr || ""}`)}`);
76
+ else resolve(String(stdout).trim());
77
+ });
78
+ });
79
+ }
80
+
81
+ // spawn-based collector for the long-runners that also want incremental output
82
+ // (progress heartbeats read the last line). Interleaves stdout+stderr the way a
83
+ // terminal would, honors an AbortSignal, and never rejects.
84
+ //
85
+ // Output is capped at the 64MB the execSync it replaced enforced via maxBuffer:
86
+ // unbounded `output += chunk` on a verbose xcodebuild can exceed V8's max
87
+ // string length, and that throw fires inside a stream 'data' handler - outside
88
+ // the CallTool try/catch - killing the whole stdio server. The oldest chunks
89
+ // are dropped; errors and test verdicts land at the tail of a build log.
90
+ const SPAWN_OUTPUT_CAP = 64 * 1024 * 1024;
91
+
92
+ function spawnCollect(cmd, { timeout = 600000, signal, env, onLine } = {}) {
93
+ return new Promise((resolve) => {
94
+ const child = spawn("/bin/sh", ["-c", cmd], { env });
95
+ const chunks = [];
96
+ let size = 0;
97
+ let truncated = false;
98
+ let timedOut = false;
99
+ let aborted = false;
100
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeout);
101
+ const onAbort = () => { aborted = true; child.kill("SIGTERM"); };
102
+ if (signal) {
103
+ if (signal.aborted) onAbort();
104
+ else signal.addEventListener("abort", onAbort, { once: true });
105
+ }
106
+ const collect = (chunk) => {
107
+ const s = String(chunk);
108
+ chunks.push(s);
109
+ size += s.length;
110
+ while (size > SPAWN_OUTPUT_CAP && chunks.length > 1) {
111
+ size -= chunks[0].length;
112
+ chunks.shift();
113
+ truncated = true;
114
+ }
115
+ if (size > SPAWN_OUTPUT_CAP) {
116
+ chunks[0] = chunks[0].slice(size - SPAWN_OUTPUT_CAP);
117
+ size = SPAWN_OUTPUT_CAP;
118
+ truncated = true;
119
+ }
120
+ if (onLine) {
121
+ const lines = s.split("\n");
122
+ for (let i = lines.length - 1; i >= 0; i--) {
123
+ const line = lines[i].trim();
124
+ if (line) { onLine(line); break; }
125
+ }
126
+ }
127
+ };
128
+ child.stdout.on("data", collect);
129
+ child.stderr.on("data", collect);
130
+ child.on("error", (e) => {
131
+ clearTimeout(timer);
132
+ resolve({ code: 127, output: `${chunks.join("")}\n${e.message}`, truncated, timedOut, aborted });
133
+ });
134
+ child.on("close", (code) => {
135
+ clearTimeout(timer);
136
+ if (signal) signal.removeEventListener("abort", onAbort);
137
+ resolve({ code: code ?? 1, output: chunks.join(""), truncated, timedOut, aborted });
138
+ });
139
+ });
140
+ }
141
+
142
+ function startHeartbeat(ctx, label, detail) {
143
+ if (!ctx?.progress) return () => {};
144
+ const started = Date.now();
145
+ ctx.progress(`${label}: started`);
146
+ const timer = setInterval(() => {
147
+ const d = detail ? detail() : "";
148
+ ctx.progress(`${label}: ${Math.round((Date.now() - started) / 1000)}s${d ? ` - ${d}` : ""}`);
149
+ }, 10000);
150
+ timer.unref?.();
151
+ return () => clearInterval(timer);
152
+ }
153
+
154
+ // Confine a caller-supplied RELATIVE output name to a base directory: a value
155
+ // like "../../../.zshrc" otherwise escapes SCREENSHOT_DIR via join() and
156
+ // overwrites a dotfile. An absolute path is treated as the caller's explicit
157
+ // choice (a local tool writing where the user's own agent asked), but a relative
158
+ // one must resolve inside the base.
159
+ function safeRelOutput(name, baseDir) {
160
+ const raw = String(name ?? "");
161
+ if (isAbsolute(raw)) return raw;
162
+ const full = resolve(baseDir, raw);
163
+ if (full !== baseDir && !full.startsWith(baseDir + sep)) {
164
+ throw new Error(`output path escapes ${baseDir}: ${raw}`);
165
+ }
166
+ return full;
167
+ }
168
+
169
+ function sanitizeId(id) {
170
+ // Bundle IDs / package names: only allow alphanumeric, dots, underscores, hyphens
171
+ if (!id) return id;
172
+ if (!/^[a-zA-Z0-9._\-/]+$/.test(id)) throw new Error(`Invalid identifier: ${id}`);
173
+ return id;
174
+ }
175
+
176
+ // Single-quote a value for POSIX sh. Every command here is built as a string and
177
+ // handed to execSync, i.e. to a shell - and $( ) expands inside DOUBLE quotes,
178
+ // so double-quoting a caller-supplied value does not contain it. Single quotes
179
+ // suppress all expansion; an embedded single quote is closed, escaped, reopened.
180
+ function shq(value) {
181
+ return `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
182
+ }
183
+
184
+ // Numeric coordinates / scales: reject anything that is not a plain number so it
185
+ // can be interpolated bare.
186
+ function num(value, label) {
187
+ const n = Number(value);
188
+ if (!Number.isFinite(n)) throw new Error(`Invalid numeric value for ${label}: ${value}`);
189
+ return n;
190
+ }
191
+
192
+ // Enum-shaped values (permission names, privacy services, keycodes) that are
193
+ // interpolated bare: allow only the shell-inert identifier charset.
194
+ function token(value, label) {
195
+ const s = String(value ?? "");
196
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) throw new Error(`Invalid ${label}: ${value}`);
197
+ return s;
198
+ }
199
+
200
+ // Device serials are interpolated bare into shell command strings, so they get
201
+ // the same treatment as token() - but adb serials for TCP endpoints carry a
202
+ // colon (ip:port), which token() refuses. Colon is shell-inert, so the widened
203
+ // charset stays injection-proof.
204
+ function deviceSerial(value) {
205
+ const s = String(value ?? "");
206
+ if (!/^[A-Za-z0-9._:-]+$/.test(s)) throw new Error(`Invalid device_id: ${value}`);
207
+ return s;
208
+ }
209
+
210
+ function hasCommand(cmd) {
211
+ try {
212
+ execSync(`which ${cmd}`, { encoding: "utf-8", timeout: 5000 });
213
+ return true;
214
+ } catch { return false; }
215
+ }
216
+
217
+ const HAS_XCRUN = hasCommand("xcrun");
218
+
219
+ // idb (Facebook) is the real iOS Simulator UI driver - `simctl io` has NO tap/swipe/type
220
+ // operation, so tap/swipe/type/geometry route through idb. Resolve its binary + set a PATH
221
+ // that includes idb_companion (Homebrew) so the CLI can spawn the companion.
222
+ function resolveIdb() {
223
+ try { const p = execSync("command -v idb 2>/dev/null", { encoding: "utf-8" }).trim(); if (p) return p; } catch {}
224
+ try { const g = execSync("ls -d \"$HOME\"/Library/Python/*/bin/idb 2>/dev/null | head -1", { encoding: "utf-8", shell: "/bin/bash" }).trim(); if (g) return g; } catch {}
225
+ return null;
226
+ }
227
+ // Every user-base bin directory pip could have installed idb into, newest
228
+ // first. resolveIdb globs any Python version, so pinning the companion PATH to
229
+ // one was wrong: on a machine where fb-idb landed under 3.11 the binary
230
+ // resolved fine while this PATH pointed at a 3.9 directory that need not
231
+ // exist, leaving idb_companion findable only if it happened to be on the
232
+ // inherited PATH.
233
+ function pythonUserBins() {
234
+ try {
235
+ const out = execSync('ls -d "$HOME"/Library/Python/*/bin 2>/dev/null', {
236
+ encoding: "utf-8",
237
+ shell: "/bin/bash",
238
+ }).trim();
239
+ return out ? out.split("\n").filter(Boolean).sort().reverse() : [];
240
+ } catch { return []; }
241
+ }
242
+
243
+ const IDB_BIN = resolveIdb();
244
+ const HAS_IDB = !!IDB_BIN;
245
+ // The resolved binary's own directory leads, so whichever install actually won
246
+ // is the one whose siblings (idb_companion) are reachable.
247
+ const IDB_PATH = [...new Set([
248
+ ...(IDB_BIN ? [dirname(IDB_BIN)] : []),
249
+ "/opt/homebrew/bin",
250
+ ...pythonUserBins(),
251
+ ...(process.env.PATH || "").split(":"),
252
+ ].filter(Boolean))].join(":");
253
+ function idb(args, opts = {}) {
254
+ if (!IDB_BIN) return "ERROR: idb not installed. Install: brew install facebook/fb/idb-companion && pip3 install --user fb-idb";
255
+ return run(`${shq(IDB_BIN)} ${args}`, { env: { ...process.env, PATH: IDB_PATH }, ...opts });
256
+ }
257
+ const HAS_ADB = hasCommand("adb");
258
+
259
+ // Active screen recordings, keyed "ios:<udid>" / "android:<serial|default>".
260
+ // The start call spawns the recorder and returns; the stop call looks the
261
+ // child up here, ends it, and hands back the file. One recording per device:
262
+ // two simctl recorders on one simulator fight over the io channel.
263
+ const RECORDINGS = new Map();
264
+
265
+ function fileResult(text, ...paths) {
266
+ return {
267
+ type: "file",
268
+ text,
269
+ files: paths.filter(Boolean).map((p) => ({ path: p, name: basename(p) })),
270
+ };
271
+ }
272
+
273
+ // ── iOS Tools ──
274
+
275
+ function iosDevice(id) {
276
+ // Same exposure as adbFlag: the returned value is interpolated bare into
277
+ // simctl/idb shell strings. UDIDs and "booted" fit the serial charset; a
278
+ // device name with spaces never worked here unquoted, so refusing it trades
279
+ // a broken command for a clear error.
280
+ if (id) return deviceSerial(id);
281
+ const out = run("xcrun simctl list devices booted -j");
282
+ try {
283
+ const data = JSON.parse(out);
284
+ for (const devs of Object.values(data.devices || {})) {
285
+ for (const d of devs) { if (d.state === "Booted") return d.udid; }
286
+ }
287
+ } catch {}
288
+ throw new Error("No booted iOS Simulator. Use ios_list_devices and ios_boot_device first.");
289
+ }
290
+
291
+ const IOS_TOOLS = [
292
+ { name: "ios_list_devices", description: "List all available iOS simulators and their state", inputSchema: { type: "object", properties: {} } },
293
+ { name: "ios_boot_device", description: "Boot an iOS simulator by UDID or name", inputSchema: { type: "object", properties: { device: { type: "string", description: "Device UDID or name" } }, required: ["device"] } },
294
+ { name: "ios_screenshot", description: "Capture an iOS simulator screenshot. Returns a base64 PNG by default; pass `path` to write the file and return only its location, which is what a run taking many captures needs - a base64 image per capture exhausts the caller's context. Use when you need to see the screen, or to collect captures for a later comparison.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, path: { type: "string", description: "Absolute file path to write the PNG to. The parent directory must already exist. Returns the path instead of the image." } } } },
295
+ { name: "ios_tap", description: "Tap at coordinates on iOS simulator", inputSchema: { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, device_id: { type: "string" } }, required: ["x", "y"] } },
296
+ { name: "ios_swipe", description: "Swipe on iOS simulator", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
297
+ { name: "ios_type_text", description: "Type text on iOS simulator", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
298
+ { name: "ios_launch_app", description: "Launch iOS app by bundle ID", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
299
+ { name: "ios_terminate_app", description: "Terminate iOS app", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
300
+ { name: "ios_list_apps", description: "List installed iOS apps", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
301
+ { name: "ios_go_home", description: "Press iOS home button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
302
+ { name: "ios_set_appearance", description: "Set iOS light/dark mode", inputSchema: { type: "object", properties: { mode: { type: "string", enum: ["light", "dark"] }, device_id: { type: "string" } }, required: ["mode"] } },
303
+ { name: "ios_set_content_size", description: "Set iOS Dynamic Type size for accessibility testing", inputSchema: { type: "object", properties: { size: { type: "string", enum: ["extra-small", "small", "medium", "large", "extra-large", "extra-extra-large", "extra-extra-extra-large", "accessibility-medium", "accessibility-large", "accessibility-extra-large", "accessibility-extra-extra-large", "accessibility-extra-extra-extra-large"] }, device_id: { type: "string" } }, required: ["size"] } },
304
+ { name: "ios_set_locale", description: "Change iOS app language (requires restart)", inputSchema: { type: "object", properties: { language: { type: "string", description: "Language code (e.g. tr, en, de, ar)" }, bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["language", "bundle_id"] } },
305
+ { name: "ios_open_url", description: "Open URL or deep link on iOS", inputSchema: { type: "object", properties: { url: { type: "string" }, device_id: { type: "string" } }, required: ["url"] } },
306
+ { name: "ios_status_bar", description: "Override iOS status bar. Use 'preset' for App Store screenshot defaults (clean=9:41/100%, testing=11:11/50%, low-battery=20%, airplane=offline) or 'clear' to revert. Manual time/battery_level override the preset.", inputSchema: { type: "object", properties: { preset: { type: "string", enum: ["clean", "testing", "low-battery", "airplane", "clear"], description: "Predefined status bar preset" }, time: { type: "string" }, battery_level: { type: "number" }, device_id: { type: "string" } } } },
307
+ { name: "ios_push_notification", description: "Send simulated iOS push notification", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, title: { type: "string" }, body: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id", "title", "body"] } },
308
+ { name: "ios_grant_permission", description: "Grant iOS privacy permission", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, service: { type: "string", enum: ["all", "calendar", "contacts-limited", "contacts", "location", "location-always", "photos-add", "photos", "media-library", "microphone", "motion", "reminders", "siri"] }, device_id: { type: "string" } }, required: ["bundle_id", "service"] } },
309
+ { name: "ios_revoke_permission", description: "Revoke iOS privacy permission", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, service: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id", "service"] } },
310
+ { name: "ios_reset_permissions", description: "Reset all iOS permissions for an app", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
311
+ { name: "ios_set_location", description: "Set simulated iOS GPS location", inputSchema: { type: "object", properties: { latitude: { type: "number" }, longitude: { type: "number" }, device_id: { type: "string" } }, required: ["latitude", "longitude"] } },
312
+ { name: "ios_clear_location", description: "Clear simulated iOS location", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
313
+ { name: "ios_set_increase_contrast", description: "Enable/disable iOS Increase Contrast", inputSchema: { type: "object", properties: { enabled: { type: "boolean" }, device_id: { type: "string" } }, required: ["enabled"] } },
314
+ { name: "ios_record_video", description: "Record the iOS simulator screen. action:\"start\" (default) spawns `simctl io recordVideo` in the background and returns immediately; action:\"stop\" ends the recording and returns the video file path. One recording per device.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["start", "stop"], description: "Default start" }, filename: { type: "string", description: "Output file name or absolute path (start only). Default rec_<timestamp>.mp4 in the tool's temp dir." }, codec: { type: "string", enum: ["h264", "hevc"] }, device_id: { type: "string" } } } },
315
+ { name: "ios_add_media", description: "Add photo/video to iOS photo library", inputSchema: { type: "object", properties: { file_path: { type: "string" }, device_id: { type: "string" } }, required: ["file_path"] } },
316
+ { name: "ios_keychain_reset", description: "Reset iOS simulator keychain", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
317
+ { name: "ios_get_app_container", description: "Get iOS app container path", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, container: { type: "string", enum: ["app", "data", "groups"] }, device_id: { type: "string" } }, required: ["bundle_id"] } },
318
+ { name: "ios_erase_device", description: "Factory reset iOS simulator", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
319
+ { name: "ios_get_ui_tree", description: "Get iOS accessibility UI tree via macOS AX APIs. Pass `path` to write the raw dump to a file and return only its location - the full tree can be tens of KB.", inputSchema: { type: "object", properties: { max_depth: { type: "number" }, path: { type: "string", description: "Absolute file path to write the raw tree JSON to. The parent directory must already exist." } } } },
320
+ { name: "ios_accessibility_audit", description: "Audit iOS app accessibility: missing labels, small tap targets (<44pt), missing identifiers. Use scope to filter by identifier prefix (e.g. 'login_' only checks login screen elements).", inputSchema: { type: "object", properties: { max_depth: { type: "number" }, scope: { type: "string", description: "Filter: only audit elements whose identifier starts with this prefix (e.g. 'login_', 'settings_'). Omit to audit all." } } } },
321
+ { name: "ios_biometric", description: "Simulate Face ID / Touch ID on iOS simulator (match or nomatch)", inputSchema: { type: "object", properties: { match: { type: "boolean", description: "true=success, false=failure" }, device_id: { type: "string" } }, required: ["match"] } },
322
+ { name: "ios_archive_audit", description: "DEPRECATED - use ios_app_store_audit (18-rule deep scan). Lighter 6-check audit kept for backward compatibility; will be removed in the next major.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Path to .xcarchive" } }, required: ["archive_path"] } },
323
+ { name: "ios_export_ipa", description: "Export a .xcarchive to a signed .ipa via xcodebuild -exportArchive. Generates the exportOptions.plist from the arguments (method defaults to app-store-connect), so callers do not have to hand-maintain one. Returns the .ipa path plus parsed errors; a run that exits 0 without producing an .ipa is reported as a failure. Pair with ios_testflight_validate for the pre-submission gate.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive" }, output_dir: { type: "string", description: "Directory to write the .ipa into" }, method: { type: "string", description: "Export method: app-store-connect (default) | release-testing | enterprise | development" }, team_id: { type: "string", description: "Apple Developer team ID" }, provisioning_profiles: { type: "object", description: "Map of bundleId -> provisioning profile name (manual signing)" }, signing_style: { type: "string", description: "automatic | manual" }, upload_symbols: { type: "boolean", description: "Include symbols (default true)" }, allow_provisioning_updates: { type: "boolean", description: "Off by default. Lets xcodebuild register devices and create/modify provisioning profiles in the developer account - a change on Apple's side, so it is opt-in" }, timeout_sec: { type: "number", description: "Default 900" } }, required: ["archive_path", "output_dir"] } },
324
+ { name: "ios_testflight_validate", description: "Run Apple's own pre-submission validation on an .ipa via `xcrun altool --validate-app`, then map returned ITMS error codes onto the App Store rule each one implies. This is the authoritative gate: unlike the static ios_app_store_audit it can catch an unregistered bundle ID, a profile that does not match the App Store Connect app record, a version+build pair already used, and entitlements not provisioned for the app ID. Auth is a 3-tier chain: ASC API key (api_key_id + api_issuer_id), else Apple ID + app-specific password referenced indirectly through a keychain item or env var (never passed by value), else the gate returns verdict SKIPPED with a reason. SKIPPED is not a pass - it means Apple was never asked. Set list_providers=true for a pre-flight that reports which teams the credentials can deliver for (needed when a corporate Apple ID belongs to several).", inputSchema: { type: "object", properties: { ipa_path: { type: "string", description: "Absolute path to the .ipa" }, platform: { type: "string", description: "ios (default) | appletvos | visionos | macos" }, api_key_id: { type: "string", description: "App Store Connect API key ID (tier 1)" }, api_issuer_id: { type: "string", description: "App Store Connect issuer ID (tier 1)" }, p8_path: { type: "string", description: "Explicit path to AuthKey_<id>.p8; otherwise altool's search dirs are used" }, apple_id: { type: "string", description: "Apple ID (tier 2)" }, keychain_item: { type: "string", description: "Keychain item holding the app-specific password (tier 2, preferred)" }, password_env_var: { type: "string", description: "Env var holding the app-specific password (tier 2 fallback)" }, provider_public_id: { type: "string", description: "Required when the account belongs to multiple providers" }, list_providers: { type: "boolean", description: "Pre-flight only: list deliverable providers and return" }, timeout_sec: { type: "number", description: "Default 900" } }, required: [] } },
325
+ { name: "ios_app_store_audit", description: "Deep App Store Review compliance audit for .xcarchive bundles. 18-rule catalog covering privacy manifest, code signing, embedded SDKs, entitlements, asset hygiene, IPv6 compliance, debug-tool leak detection, Swift ABI compatibility, SDK floor (ITMS-90725), and more. Returns structured JSON with severity-ranked violations and ITMS error code mappings. Replaces ios_archive_audit (deprecated). Pass rules='core' for the 6 baseline checks (code-signing, entitlements, info-plist, privacy-manifest, binary-size, sdk-floor); 'all'|'deep' for the full 18-rule scan; CSV like 'binary-size,ipv6-compliance' for an explicit subset.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive bundle" }, rules: { type: "string", description: "'all' (default) | 'core' | 'deep' | comma-separated ruleIDs" } }, required: ["archive_path"] } },
326
+ { name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
327
+ { name: "ios_xcresult", description: "Drill into a previous ios_xcodebuild result by xcresult ID. Modes: summary (counts), errors (file:line + message), warnings, log (last N lines), tests (failed). Use this instead of dumping the whole build log into context.", inputSchema: { type: "object", properties: { id: { type: "string", description: "xcresult ID returned by ios_xcodebuild" }, mode: { type: "string", enum: ["summary", "errors", "warnings", "log", "tests"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" } }, required: ["id"] } },
328
+ { name: "ios_visual_diff", description: "Compare two PNG screenshots. Returns JSON with diff_pct, pass/fail vs threshold, and an optional diff image. Use for snapshot regression checks across light/dark, locale, dynamic type variants.", inputSchema: { type: "object", properties: { baseline: { type: "string", description: "Path to baseline PNG" }, current: { type: "string", description: "Path to current PNG" }, threshold: { type: "number", description: "Per-pixel color threshold 0..1 (default 0.1, lower = stricter)" }, max_diff_pct: { type: "number", description: "Fail if diff exceeds this percent (default 1.0)" }, output: { type: "string", description: "Path to write diff PNG (optional)" } }, required: ["baseline", "current"] } },
329
+ { name: "ios_list_crashes", description: "List recent crash reports from the host's ~/Library/Logs/DiagnosticReports - where simulator app crashes land. Filter by process name, bound by age and count.", inputSchema: { type: "object", properties: { app: { type: "string", description: "Only reports whose file name (the crashed process) contains this substring" }, since_min: { type: "number", description: "Only reports newer than this many minutes" }, limit: { type: "number", description: "Max reports returned, newest first (default 20)" } } } },
330
+ ];
331
+
332
+ async function handleIOS(name, args, ctx = {}) {
333
+ if (!HAS_XCRUN) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
334
+ const did = (n) => { try { return iosDevice(n); } catch (e) { return null; } };
335
+
336
+ switch (name) {
337
+ case "ios_list_devices": {
338
+ const out = run("xcrun simctl list devices available -j");
339
+ try {
340
+ const data = JSON.parse(out);
341
+ const result = [];
342
+ for (const [rt, devs] of Object.entries(data.devices || {}))
343
+ for (const d of devs) result.push({ name: d.name, udid: d.udid, state: d.state, runtime: rt.split(".").pop() });
344
+ return JSON.stringify(result.filter(d => d.name.includes("iPhone") || d.name.includes("iPad")), null, 2);
345
+ } catch { return out; }
346
+ }
347
+ case "ios_boot_device": return run(`xcrun simctl boot ${shq(args.device)}`);
348
+ // `path` returns the file location instead of the image. A design audit takes
349
+ // 100+ captures, and a base64 PNG per capture exhausts the caller's context
350
+ // long before the audit finishes - the run that motivated this shelled out to
351
+ // `xcrun simctl io ... screenshot <path>` directly to avoid it. With `path`
352
+ // the caller keeps the tool and decides which captures to actually look at.
353
+ case "ios_screenshot": {
354
+ const d = iosDevice(args.device_id);
355
+ const f = args.path ? String(args.path) : join(SCREENSHOT_DIR, `ios_${Date.now()}.png`);
356
+ if (args.path) {
357
+ const parent = dirname(f);
358
+ if (!existsSync(parent)) return `ERROR: directory does not exist: ${parent}`;
359
+ }
360
+ run(`xcrun simctl io ${d} screenshot ${shq(f)}`);
361
+ if (!existsSync(f)) return "ERROR: Screenshot failed";
362
+ if (args.path) return fileResult(`Screenshot written: ${f}`, f);
363
+ const buf = readFileSync(f);
364
+ return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
365
+ }
366
+ case "ios_tap": { const d = iosDevice(args.device_id); const r = idb(`ui tap --udid ${d} ${num(args.x, "x")} ${num(args.y, "y")}`); return (typeof r === "string" && r.startsWith("ERROR")) ? r : `Tapped (${args.x}, ${args.y})`; }
367
+ case "ios_swipe": { const d = iosDevice(args.device_id); const dur = args.duration_ms ? ` --duration ${(args.duration_ms / 1000).toFixed(2)}` : ""; const r = idb(`ui swipe --udid ${d} ${num(args.x1, "x1")} ${num(args.y1, "y1")} ${num(args.x2, "x2")} ${num(args.y2, "y2")}${dur}`); return isFailure(r) ? r : "Swiped"; }
368
+ case "ios_type_text": { const d = iosDevice(args.device_id); const r = idb(`ui text --udid ${d} ${shq(args.text)}`); return (typeof r === "string" && r.startsWith("ERROR")) ? r : `Typed: ${args.text}`; }
369
+ case "ios_launch_app": { const d = iosDevice(args.device_id); return run(`xcrun simctl launch ${d} ${sanitizeId(args.bundle_id)}`) || `Launched ${args.bundle_id}`; }
370
+ case "ios_terminate_app": { const d = iosDevice(args.device_id); return run(`xcrun simctl terminate ${d} ${sanitizeId(args.bundle_id)}`) || `Terminated`; }
371
+ case "ios_list_apps": { const d = iosDevice(args.device_id); return run(`xcrun simctl listapps ${d} 2>/dev/null | grep -E "CFBundleIdentifier|CFBundleDisplayName" | paste - - | sort`); }
372
+ // `simctl io <device> pressButton` does not exist - `io` supports only
373
+ // enumerate/poll/recordVideo/screenshot/screenConfig. The home button is an
374
+ // idb UI operation, same as tap/swipe/type.
375
+ case "ios_go_home": {
376
+ const d = iosDevice(args.device_id);
377
+ if (!HAS_IDB) return `${ERROR_PREFIX}ios_go_home needs idb. Install: brew install facebook/fb/idb-companion && pip3 install --user fb-idb`;
378
+ const out = idb(`ui button --udid ${d} HOME`);
379
+ return isFailure(out) ? out : "Home";
380
+ }
381
+ case "ios_set_appearance": { const d = iosDevice(args.device_id); return run(`xcrun simctl ui ${d} appearance ${token(args.mode, "appearance mode")}`) || `Appearance: ${args.mode}`; }
382
+ case "ios_set_content_size": { const d = iosDevice(args.device_id); return run(`xcrun simctl ui ${d} content_size ${token(args.size, "content size")}`) || `Content size: ${args.size}`; }
383
+ case "ios_set_locale": { const d = iosDevice(args.device_id); const bid = sanitizeId(args.bundle_id); run(`xcrun simctl spawn ${d} defaults write ${bid} AppleLanguages -array ${shq(args.language)}`); run(`xcrun simctl terminate ${d} ${bid}`); run(`xcrun simctl launch ${d} ${bid}`); return `Locale: ${args.language}`; }
384
+ case "ios_open_url": { const d = iosDevice(args.device_id); return run(`xcrun simctl openurl ${d} ${shq(args.url)}`) || `Opened: ${args.url}`; }
385
+ case "ios_status_bar": {
386
+ const d = iosDevice(args.device_id);
387
+ if (args.preset === "clear") return run(`xcrun simctl status_bar ${d} clear`) || "Status bar cleared";
388
+ const PRESETS = {
389
+ clean: { time: "9:41", battery: 100, dataNetwork: "wifi", wifiBars: 3, cellBars: 4, mode: "charged" },
390
+ testing: { time: "11:11", battery: 50, dataNetwork: "wifi", wifiBars: 3, cellBars: 4, mode: "discharging" },
391
+ "low-battery":{ time: "9:41", battery: 20, dataNetwork: "wifi", wifiBars: 3, cellBars: 4, mode: "discharging" },
392
+ airplane: { time: "9:41", battery: 100, dataNetwork: "hide", wifiBars: 0, cellBars: 0, mode: "charged" }
393
+ };
394
+ const p = args.preset ? PRESETS[args.preset] : null;
395
+ const time = args.time || p?.time;
396
+ const battery = args.battery_level !== undefined ? args.battery_level : p?.battery;
397
+ let cmd = `xcrun simctl status_bar ${d} override`;
398
+ // time is caller-supplied and was double-quoted, so $( ) expanded; single-quote it.
399
+ if (time) cmd += ` --time ${shq(time)}`;
400
+ // battery_level is declared numeric but never validated, so a string reached
401
+ // the bare interpolation. num() rejects anything non-numeric.
402
+ if (battery !== undefined) cmd += ` --batteryLevel ${num(battery, "battery_level")} --batteryState ${token(p?.mode || "charged", "battery mode")}`;
403
+ if (p) {
404
+ cmd += ` --dataNetwork ${p.dataNetwork} --wifiMode active --wifiBars ${p.wifiBars} --cellularMode ${p.cellBars > 0 ? "active" : "notSupported"} --cellularBars ${p.cellBars} --operatorName ""`;
405
+ }
406
+ return run(cmd) || `Status bar set${args.preset ? ` (preset: ${args.preset})` : ""}`;
407
+ }
408
+ case "ios_push_notification": { const d = iosDevice(args.device_id); const payload = { aps: { alert: { title: args.title, body: args.body }, sound: "default" } }; const f = join(SCREENSHOT_DIR, `push_${Date.now()}.json`); writeFileSync(f, JSON.stringify(payload)); return run(`xcrun simctl push ${d} ${sanitizeId(args.bundle_id)} ${shq(f)}`) || `Push sent: ${args.title}`; }
409
+ case "ios_grant_permission": { const d = iosDevice(args.device_id); return run(`xcrun simctl privacy ${d} grant ${token(args.service, "privacy service")} ${sanitizeId(args.bundle_id)}`) || `Granted ${args.service}`; }
410
+ case "ios_revoke_permission": { const d = iosDevice(args.device_id); return run(`xcrun simctl privacy ${d} revoke ${token(args.service, "privacy service")} ${sanitizeId(args.bundle_id)}`) || `Revoked ${args.service}`; }
411
+ case "ios_reset_permissions": { const d = iosDevice(args.device_id); return run(`xcrun simctl privacy ${d} reset all ${sanitizeId(args.bundle_id)}`) || `Reset permissions`; }
412
+ case "ios_set_location": { const d = iosDevice(args.device_id); return run(`xcrun simctl location ${d} set ${num(args.latitude, "latitude")},${num(args.longitude, "longitude")}`) || `Location: ${args.latitude},${args.longitude}`; }
413
+ case "ios_clear_location": { const d = iosDevice(args.device_id); return run(`xcrun simctl location ${d} clear`) || "Location cleared"; }
414
+ case "ios_set_increase_contrast": { const d = iosDevice(args.device_id); return run(`xcrun simctl ui ${d} increase_contrast ${args.enabled ? "enabled" : "disabled"}`) || `Contrast: ${args.enabled}`; }
415
+ case "ios_record_video": {
416
+ const d = iosDevice(args.device_id);
417
+ const key = `ios:${d}`;
418
+ if ((args.action || "start") === "stop") {
419
+ const rec = RECORDINGS.get(key);
420
+ if (!rec) return `${ERROR_PREFIX}no active iOS recording on ${d}. Start one with action:"start".`;
421
+ RECORDINGS.delete(key);
422
+ const exited = new Promise((resolve) => rec.child.once("close", () => resolve(true)));
423
+ // SIGINT, not SIGTERM: simctl finalizes the mp4 container on interrupt
424
+ // and a harder kill leaves the file unplayable.
425
+ rec.child.kill("SIGINT");
426
+ const closed = await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 8000, false))]);
427
+ if (!existsSync(rec.path)) return `${ERROR_PREFIX}recording stopped but no file at ${rec.path}`;
428
+ // The file exists from the moment recording began, so existence alone
429
+ // does not prove simctl finished writing it - only a clean exit does.
430
+ if (!closed) return fileResult(`Recording stopping: simctl did not exit within 8s, so the file at ${rec.path} may still be finalizing. Wait for its size to stabilize before using it.`, rec.path);
431
+ return fileResult(`Recording saved: ${rec.path}`, rec.path);
432
+ }
433
+ if (RECORDINGS.has(key)) return `${ERROR_PREFIX}a recording is already running on ${d}. Stop it first with action:"stop".`;
434
+ const f = args.filename
435
+ ? safeRelOutput(args.filename, SCREENSHOT_DIR)
436
+ : join(SCREENSHOT_DIR, `rec_${Date.now()}.mp4`);
437
+ // Validated before spawning, like every other path-accepting tool: a bad
438
+ // directory otherwise surfaces only at stop time, as a confusing failure.
439
+ const recParent = dirname(f);
440
+ if (!existsSync(recParent)) return `${ERROR_PREFIX}directory does not exist: ${recParent}`;
441
+ const child = spawn("xcrun", ["simctl", "io", d, "recordVideo", `--codec=${args.codec || "hevc"}`, "--force", f], { stdio: "ignore" });
442
+ child.once("close", () => { if (RECORDINGS.get(key)?.child === child) RECORDINGS.delete(key); });
443
+ RECORDINGS.set(key, { child, path: f });
444
+ return `Recording started on ${d} (pid ${child.pid}) -> ${f}\nCall ios_record_video with action:"stop" to finish.`;
445
+ }
446
+ case "ios_add_media": { const d = iosDevice(args.device_id); return run(`xcrun simctl addmedia ${d} ${shq(args.file_path)}`) || "Media added"; }
447
+ case "ios_keychain_reset": { const d = iosDevice(args.device_id); return run(`xcrun simctl keychain ${d} reset`) || "Keychain reset"; }
448
+ case "ios_get_app_container": { const d = iosDevice(args.device_id); return run(`xcrun simctl get_app_container ${d} ${sanitizeId(args.bundle_id)} ${args.container || "app"}`); }
449
+ case "ios_erase_device": { const d = iosDevice(args.device_id); return run(`xcrun simctl erase ${d}`) || "Device erased"; }
450
+ case "ios_get_ui_tree": {
451
+ const script = join(__dirname, "ui-tree-dumper.swift");
452
+ if (!existsSync(script)) return "ui-tree-dumper.swift not found";
453
+ // Path check first, matching the Android sibling: the AX dump takes up to
454
+ // 15s, too expensive to spend before rejecting a bad destination.
455
+ if (args.path) {
456
+ const parent = dirname(String(args.path));
457
+ if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
458
+ }
459
+ const depth = num(args.max_depth ?? 10, "max_depth");
460
+ const tree = run(`swift ${shq(script)} ${depth}`, { timeout: 15000 });
461
+ if (!args.path || isFailure(tree)) return tree;
462
+ writeFileSync(String(args.path), tree);
463
+ return fileResult(`UI tree written: ${args.path}`, String(args.path));
464
+ }
465
+ case "ios_accessibility_audit": {
466
+ const script = join(__dirname, "ui-tree-dumper.swift");
467
+ if (!existsSync(script)) return "ui-tree-dumper.swift not found";
468
+ const depth = num(args.max_depth ?? 10, "max_depth");
469
+ const scope = args.scope || null;
470
+ const treeJson = run(`swift ${shq(script)} ${depth}`, { timeout: 15000 });
471
+ try {
472
+ const issues = [];
473
+ let totalScanned = 0, totalSkipped = 0;
474
+ function auditNode(node, path = "") {
475
+ const loc = path ? `${path} > ${node.role}` : node.role;
476
+ const w = node.frame?.w || 0, h = node.frame?.h || 0;
477
+ const isInteractive = ["AXButton", "AXLink", "AXTextField", "AXTextArea", "AXCheckBox", "AXRadioButton", "AXSlider", "AXSwitch", "AXTab"].includes(node.role);
478
+ if (isInteractive) {
479
+ // Scope filter: skip elements outside scope
480
+ if (scope && node.identifier && !node.identifier.startsWith(scope)) { totalSkipped++; if (node.children) node.children.forEach(c => auditNode(c, loc)); return; }
481
+ if (scope && !node.identifier) { /* no identifier = can't scope, still audit */ }
482
+ totalScanned++;
483
+ if (!node.title && !node.description && !node.value) issues.push({ severity: "critical", issue: "Missing accessibility label", element: loc, identifier: node.identifier, frame: node.frame });
484
+ if (!node.identifier) issues.push({ severity: "warning", issue: "Missing accessibility identifier (UI testing)", element: loc });
485
+ if (w > 0 && h > 0 && (w < 44 || h < 44)) issues.push({ severity: "important", issue: `Tap target too small: ${w.toFixed(0)}x${h.toFixed(0)}pt (min 44x44)`, element: loc, identifier: node.identifier, frame: node.frame });
486
+ }
487
+ if (node.children) node.children.forEach(c => auditNode(c, loc));
488
+ }
489
+ const tree = JSON.parse(treeJson);
490
+ auditNode(tree);
491
+ return JSON.stringify({ scope: scope || "all", elements_scanned: totalScanned, elements_skipped: totalSkipped, total_issues: issues.length, critical: issues.filter(i => i.severity === "critical").length, important: issues.filter(i => i.severity === "important").length, warning: issues.filter(i => i.severity === "warning").length, issues }, null, 2);
492
+ } catch (e) { return `ERROR parsing UI tree: ${e.message}\n\nRaw output:\n${treeJson?.substring(0, 500)}`; }
493
+ }
494
+ // `simctl keychain <device> biometric-enroll` / `biometric-match` do not
495
+ // exist - `keychain` supports only add-root-cert/add-cert/reset. The only
496
+ // available lever is the (undocumented) BiometricKit notification, which is
497
+ // posted via notifyutil inside the simulator. notifyutil exits 0 even when
498
+ // it cannot set or post the name, so its output has to be inspected: a
499
+ // "Failed with code N" line means the notification did not land, and that
500
+ // must be reported as a failure rather than as a simulated success.
501
+ case "ios_biometric": {
502
+ const d = iosDevice(args.device_id);
503
+ const action = args.match ? "match" : "nomatch";
504
+ const NOTIFYUTIL = "/usr/bin/notifyutil"; // simctl spawn does not resolve PATH
505
+ const enroll = run(`xcrun simctl spawn ${d} ${NOTIFYUTIL} -s com.apple.BiometricKit.enrollmentChanged 1`);
506
+ const post = run(`xcrun simctl spawn ${d} ${NOTIFYUTIL} -p com.apple.BiometricKit_Sim.pearl.${action}`);
507
+ const combined = `${enroll}\n${post}`;
508
+ if (isFailure(enroll) || isFailure(post)) return combined.trim();
509
+ if (/Failed with code \d+/.test(combined)) {
510
+ return `${ERROR_PREFIX}biometric notification not delivered on this simulator/Xcode. ` +
511
+ `notifyutil reported: ${combined.replace(/\s+/g, " ").trim()}. ` +
512
+ `BiometricKit simulation is undocumented by Apple and requires a foreground app ` +
513
+ `registered for these notifications; verify Face ID enrollment in the Simulator ` +
514
+ `Features menu before relying on this tool.`;
515
+ }
516
+ return `Biometric ${action} posted (enrollment set, ${action} delivered)`;
517
+ }
518
+ case "ios_archive_audit": {
519
+ const p = args.archive_path;
520
+ if (!existsSync(p)) return `ERROR: Archive not found at ${p}`;
521
+ const findings = [];
522
+ // 1. Find .app inside archive
523
+ const appDir = run(`find ${shq(p + "/Products/Applications")} -name "*.app" -maxdepth 1 2>/dev/null | head -1`);
524
+ if (!appDir) return "ERROR: No .app found inside archive";
525
+ const appName = appDir.split("/").pop();
526
+ const binaryName = appName.replace(".app", "");
527
+ const binaryPath = `${appDir}/${binaryName}`;
528
+ // 2. Binary size
529
+ const sizeOut = run(`stat -f "%z" ${shq(binaryPath)} 2>/dev/null`);
530
+ if (sizeOut) { const sizeMB = parseInt(sizeOut) / 1048576; findings.push({ check: "binary_size", value: `${sizeMB.toFixed(1)} MB`, status: sizeMB > 500 ? "warning" : "pass", detail: sizeMB > 500 ? "Binary exceeds 500MB - may hit App Store limits" : "OK" }); }
531
+ // 3. Debug tool leak check
532
+ const debugSymbols = run(`nm ${shq(binaryPath)} 2>/dev/null | grep -iE "FLEX|Reveal|Stetho|Flipper|CocoaDebug|Pulse" | head -10`);
533
+ findings.push({ check: "debug_tools", status: debugSymbols ? "critical" : "pass", detail: debugSymbols ? `Debug tools found in binary: ${debugSymbols}` : "No debug tools detected" });
534
+ // 4. Code signing
535
+ const codesign = run(`codesign -dvv ${shq(appDir)} 2>&1`);
536
+ const hasSignature = codesign && !codesign.includes("not signed");
537
+ findings.push({ check: "code_signing", status: hasSignature ? "pass" : "critical", detail: hasSignature ? "Signed" : "NOT SIGNED - will be rejected" });
538
+ // 5. Provisioning profile
539
+ const provProfile = run(`ls ${shq(appDir + "/embedded.mobileprovision")} 2>/dev/null`);
540
+ findings.push({ check: "provisioning_profile", status: provProfile ? "pass" : "warning", detail: provProfile ? "Present" : "No embedded.mobileprovision - may be App Store distribution" });
541
+ // 6. Entitlements
542
+ const entitlements = run(`codesign -d --entitlements - ${shq(appDir)} 2>/dev/null`);
543
+ const hasGetTaskAllow = entitlements && entitlements.includes("get-task-allow");
544
+ findings.push({ check: "entitlements_debug", status: hasGetTaskAllow ? "critical" : "pass", detail: hasGetTaskAllow ? "get-task-allow is TRUE - this is a DEBUG build, App Store will reject" : "get-task-allow not set or false - OK" });
545
+ // 7. Info.plist checks
546
+ const plistPath = `${appDir}/Info.plist`;
547
+ const plistJson = run(`plutil -convert json -o - ${shq(plistPath)} 2>/dev/null`);
548
+ if (plistJson) {
549
+ try {
550
+ const plist = JSON.parse(plistJson);
551
+ findings.push({ check: "bundle_version", status: plist.CFBundleShortVersionString ? "pass" : "critical", detail: plist.CFBundleShortVersionString || "MISSING" });
552
+ findings.push({ check: "min_os_version", value: plist.MinimumOSVersion || "unknown", status: "info" });
553
+ const ats = plist.NSAppTransportSecurity;
554
+ findings.push({ check: "app_transport_security", status: ats?.NSAllowsArbitraryLoads ? "warning" : "pass", detail: ats?.NSAllowsArbitraryLoads ? "NSAllowsArbitraryLoads=true - ATS disabled, needs justification" : "ATS enforced" });
555
+ // Privacy permission strings
556
+ const privacyKeys = ["NSCameraUsageDescription", "NSPhotoLibraryUsageDescription", "NSLocationWhenInUseUsageDescription", "NSMicrophoneUsageDescription", "NSContactsUsageDescription", "NSFaceIDUsageDescription"];
557
+ const declaredPerms = privacyKeys.filter(k => plist[k]);
558
+ findings.push({ check: "privacy_strings", value: `${declaredPerms.length} permissions declared`, status: "info", detail: declaredPerms.join(", ") || "None" });
559
+ } catch {}
560
+ }
561
+ // 8. Privacy manifest
562
+ const privacyManifest = run(`find ${shq(appDir)} -name "PrivacyInfo.xcprivacy" 2>/dev/null | head -1`);
563
+ findings.push({ check: "privacy_manifest", status: privacyManifest ? "pass" : "warning", detail: privacyManifest ? "PrivacyInfo.xcprivacy found" : "No privacy manifest - required for apps using Required Reason APIs" });
564
+ // 9. Embedded frameworks check
565
+ const frameworks = run(`ls ${shq(appDir + "/Frameworks/")} 2>/dev/null`);
566
+ if (frameworks) { findings.push({ check: "embedded_frameworks", value: frameworks.split("\n").length + " frameworks", status: "info", detail: frameworks }); }
567
+ // Summary
568
+ const critical = findings.filter(f => f.status === "critical").length;
569
+ const warnings = findings.filter(f => f.status === "warning").length;
570
+ return JSON.stringify({ archive: p, app: appName, summary: { critical, warnings, passed: findings.filter(f => f.status === "pass").length, total_checks: findings.length }, verdict: critical > 0 ? "FAIL - critical issues must be fixed" : warnings > 0 ? "WARN - review warnings before submission" : "PASS - ready for App Store", findings }, null, 2);
571
+ }
572
+ case "ios_export_ipa": {
573
+ if (!HAS_XCRUN) return "ERROR: xcrun not available - Xcode Command Line Tools required";
574
+ if (!args.archive_path) return "ERROR: archive_path is required";
575
+ if (!args.output_dir) return "ERROR: output_dir is required";
576
+ // finally, not success-path only: a rejection would otherwise leave the
577
+ // 10s heartbeat interval emitting progress frames forever.
578
+ const stopHeartbeat = startHeartbeat(ctx, "xcodebuild -exportArchive");
579
+ let res;
580
+ try {
581
+ res = await exportIpa({
582
+ archivePath: args.archive_path,
583
+ outputDir: args.output_dir,
584
+ method: args.method,
585
+ teamId: args.team_id,
586
+ provisioningProfiles: args.provisioning_profiles,
587
+ signingStyle: args.signing_style,
588
+ uploadSymbols: args.upload_symbols,
589
+ allowProvisioningUpdates: args.allow_provisioning_updates,
590
+ timeoutSec: args.timeout_sec,
591
+ signal: ctx.signal,
592
+ });
593
+ } finally {
594
+ stopHeartbeat();
595
+ }
596
+ // Progressive disclosure: the full xcodebuild log is large and almost
597
+ // never what the caller needs. Return the verdict plus the first errors.
598
+ return JSON.stringify(
599
+ {
600
+ ok: res.ok,
601
+ ipa: res.ipaPath || null,
602
+ exportOptions: res.exportOptionsPath,
603
+ errorCount: res.errors.length,
604
+ errors: res.errors.slice(0, 20),
605
+ hint: res.ok
606
+ ? undefined
607
+ : "Common causes: no App Store distribution certificate in the keychain, a provisioning profile that does not match the bundle ID, or signing_style=manual without provisioning_profiles.",
608
+ },
609
+ null,
610
+ 2,
611
+ );
612
+ }
613
+ case "ios_testflight_validate": {
614
+ if (!HAS_XCRUN) return "ERROR: xcrun not available - Xcode Command Line Tools required";
615
+ const auth = resolveAuth({
616
+ apiKeyId: args.api_key_id,
617
+ apiIssuerId: args.api_issuer_id,
618
+ p8Path: args.p8_path,
619
+ appleId: args.apple_id,
620
+ keychainItem: args.keychain_item,
621
+ passwordEnvVar: args.password_env_var,
622
+ providerPublicId: args.provider_public_id,
623
+ });
624
+ if (args.list_providers) {
625
+ const res = await listProviders(auth, ctx.signal);
626
+ return JSON.stringify({ authTier: auth.tier, authMethod: auth.method, ...res }, null, 2);
627
+ }
628
+ if (!args.ipa_path) return "ERROR: ipa_path is required (or pass list_providers=true)";
629
+ // Same finally rationale as ios_export_ipa: never leak the heartbeat.
630
+ const stopHeartbeat = startHeartbeat(ctx, "altool --validate-app");
631
+ let res;
632
+ try {
633
+ res = await validateApp({
634
+ ipaPath: args.ipa_path,
635
+ platform: args.platform,
636
+ auth,
637
+ timeoutSec: args.timeout_sec,
638
+ signal: ctx.signal,
639
+ });
640
+ } finally {
641
+ stopHeartbeat();
642
+ }
643
+ return JSON.stringify(res, null, 2);
644
+ }
645
+ case "ios_app_store_audit": {
646
+ const archivePath = args.archive_path;
647
+ if (!archivePath) return "ERROR: archive_path is required";
648
+ if (!existsSync(archivePath)) return `ERROR: Archive not found at ${archivePath}`;
649
+ try {
650
+ const result = await runAppStoreAudit({
651
+ archivePath,
652
+ rules: args.rules || "all",
653
+ });
654
+ return JSON.stringify(result, null, 2);
655
+ } catch (err) {
656
+ return `ERROR: ${err.message}`;
657
+ }
658
+ }
659
+ case "ios_xcodebuild": {
660
+ if (!HAS_XCRUN) return "ERROR: xcrun not available - Xcode Command Line Tools required";
661
+ if (!args.project && !args.workspace) return "ERROR: project or workspace required";
662
+ if (args.project && args.workspace) return "ERROR: pass project OR workspace, not both";
663
+ const action = args.action || "build";
664
+ const config = args.configuration || "Release";
665
+ const dest = args.destination || "generic/platform=iOS Simulator";
666
+ const id = `xcresult-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
667
+ const xcresultPath = join(SCREENSHOT_DIR, `${id}.xcresult`);
668
+ const logPath = join(SCREENSHOT_DIR, `${id}.log`);
669
+ const target = args.workspace ? `-workspace ${shq(args.workspace)}` : `-project ${shq(args.project)}`;
670
+ const derivedFlag = args.derived_data_path ? `-derivedDataPath ${shq(args.derived_data_path)}` : "";
671
+ const actionMap = { "clean-build": "clean build", build: "build", test: "test", clean: "clean", archive: "archive" };
672
+ // extra_args is appended verbatim (multiple flags, so it can't be a single
673
+ // shq'd token), which makes it a shell passthrough. Reject the metacharacters
674
+ // that enable command chaining or substitution so it stays "extra xcodebuild
675
+ // flags" and cannot become an arbitrary-command tool.
676
+ const extra = args.extra_args || "";
677
+ if (/[;&|`$(){}<>\n\\]/.test(extra)) {
678
+ return "ERROR: extra_args may contain only plain flags and values (no shell metacharacters ; & | ` $ ( ) { } < > \\).";
679
+ }
680
+ const cmd = `xcodebuild ${target} -scheme ${shq(args.scheme)} -configuration ${shq(config)} -destination ${shq(dest)} -resultBundlePath ${shq(xcresultPath)} ${derivedFlag} ${actionMap[action]} ${extra}`.trim();
681
+ const timeout = (args.timeout_sec || 600) * 1000;
682
+ let lastLine = "";
683
+ const stopHeartbeat = startHeartbeat(ctx, `xcodebuild ${action}`, () => lastLine);
684
+ const res = await spawnCollect(cmd, { timeout, signal: ctx.signal, onLine: (l) => { lastLine = l; } });
685
+ stopHeartbeat();
686
+ const exitCode = res.timedOut || res.aborted ? 1 : res.code;
687
+ const raw = (res.truncated ? "[output truncated: kept the last 64MB]\n" : "") + res.output + (res.timedOut ? "\n[TIMEOUT]" : "") + (res.aborted ? "\n[CANCELLED]" : "");
688
+ writeFileSync(logPath, raw);
689
+ const errMatches = raw.match(/^.*error:.*$/gim) || [];
690
+ const warnMatches = raw.match(/^.*warning:.*$/gim) || [];
691
+ const errors = errMatches.length;
692
+ const warnings = warnMatches.length;
693
+ let testInfo = "";
694
+ const testMatch = raw.match(/Test Suite.*passed.*Executed (\d+) test[s]?, with (\d+) failure/i);
695
+ if (testMatch) testInfo = `, ${testMatch[1]} tests (${testMatch[2]} failed)`;
696
+ const status = exitCode === 0 ? "SUCCESS" : "FAILURE";
697
+ const truncNote = res.truncated ? "\nNote: output exceeded 64MB, the log holds only the tail." : "";
698
+ return fileResult(`Build: ${status} (${errors} errors, ${warnings} warnings${testInfo}) [${id}]\nProject: ${args.scheme} (${action}, ${config})\nLog: ${logPath}\nxcresult: ${xcresultPath}\nDrill in: ios_xcresult({id:"${id}", mode:"errors"})${truncNote}`, logPath);
699
+ }
700
+ case "ios_xcresult": {
701
+ const id = args.id;
702
+ if (!id || !/^xcresult-[\w-]+$/.test(id)) return "ERROR: invalid xcresult id";
703
+ const logPath = join(SCREENSHOT_DIR, `${id}.log`);
704
+ const xcresultPath = join(SCREENSHOT_DIR, `${id}.xcresult`);
705
+ if (!existsSync(logPath)) return `ERROR: no log for ${id} at ${logPath}`;
706
+ const raw = readFileSync(logPath, "utf-8");
707
+ const mode = args.mode || "summary";
708
+ if (mode === "log") {
709
+ const n = args.log_lines || 200;
710
+ const lines = raw.split("\n");
711
+ return lines.slice(-n).join("\n");
712
+ }
713
+ if (mode === "errors") {
714
+ const errs = (raw.match(/^.*error:.*$/gim) || []).map(l => l.trim());
715
+ return JSON.stringify({ id, count: errs.length, errors: errs }, null, 2);
716
+ }
717
+ if (mode === "warnings") {
718
+ const warns = (raw.match(/^.*warning:.*$/gim) || []).map(l => l.trim());
719
+ return JSON.stringify({ id, count: warns.length, warnings: warns }, null, 2);
720
+ }
721
+ if (mode === "tests") {
722
+ if (existsSync(xcresultPath)) {
723
+ const out = run(`xcrun xcresulttool get test-results tests --path ${shq(xcresultPath)} --format json 2>/dev/null`, { timeout: 20000 });
724
+ if (out && !out.startsWith("ERROR")) return out;
725
+ }
726
+ const failed = (raw.match(/^.*Test Case.*failed.*$/gim) || []).map(l => l.trim());
727
+ return JSON.stringify({ id, failed_count: failed.length, failed_tests: failed }, null, 2);
728
+ }
729
+ const errCount = (raw.match(/^.*error:.*$/gim) || []).length;
730
+ const warnCount = (raw.match(/^.*warning:.*$/gim) || []).length;
731
+ return JSON.stringify({ id, errors: errCount, warnings: warnCount, log_path: logPath, xcresult_path: xcresultPath }, null, 2);
732
+ }
733
+ case "ios_visual_diff": {
734
+ if (!existsSync(args.baseline)) return `ERROR: baseline not found: ${args.baseline}`;
735
+ if (!existsSync(args.current)) return `ERROR: current not found: ${args.current}`;
736
+ let PNG, pixelmatch;
737
+ try {
738
+ ({ PNG } = await import("pngjs"));
739
+ pixelmatch = (await import("pixelmatch")).default;
740
+ } catch (e) {
741
+ return `ERROR: visual diff requires pixelmatch + pngjs. Install: npm i -g pngjs pixelmatch (or rerun npm install in multi-agent-toolkit-mcp)`;
742
+ }
743
+ const a = PNG.sync.read(readFileSync(args.baseline));
744
+ const b = PNG.sync.read(readFileSync(args.current));
745
+ if (a.width !== b.width || a.height !== b.height) {
746
+ return JSON.stringify({ passed: false, reason: "size_mismatch", baseline_size: `${a.width}x${a.height}`, current_size: `${b.width}x${b.height}` }, null, 2);
747
+ }
748
+ const diff = new PNG({ width: a.width, height: a.height });
749
+ const threshold = args.threshold !== undefined ? args.threshold : 0.1;
750
+ const diffPixels = pixelmatch(a.data, b.data, diff.data, a.width, a.height, { threshold });
751
+ const total = a.width * a.height;
752
+ const diffPct = (diffPixels / total) * 100;
753
+ const maxPct = args.max_diff_pct !== undefined ? args.max_diff_pct : 1.0;
754
+ const passed = diffPct <= maxPct;
755
+ let diffImagePath = null;
756
+ if (args.output || !passed) {
757
+ diffImagePath = args.output || join(SCREENSHOT_DIR, `diff-${Date.now()}.png`);
758
+ writeFileSync(diffImagePath, PNG.sync.write(diff));
759
+ }
760
+ const report = JSON.stringify({ passed, diff_pct: parseFloat(diffPct.toFixed(4)), diff_pixels: diffPixels, total_pixels: total, threshold, max_diff_pct: maxPct, baseline: args.baseline, current: args.current, diff_image: diffImagePath }, null, 2);
761
+ return diffImagePath ? fileResult(report, diffImagePath) : report;
762
+ }
763
+ case "ios_list_crashes": {
764
+ const dir = join(homedir(), "Library", "Logs", "DiagnosticReports");
765
+ if (!existsSync(dir)) return JSON.stringify({ dir, count: 0, reports: [] }, null, 2);
766
+ const entries = readdirSync(dir).map((name) => {
767
+ try {
768
+ const st = statSync(join(dir, name));
769
+ return st.isFile() ? { name, mtimeMs: st.mtimeMs, size: st.size } : null;
770
+ } catch { return null; }
771
+ }).filter(Boolean);
772
+ const sinceMs = args.since_min !== undefined ? Date.now() - num(args.since_min, "since_min") * 60000 : undefined;
773
+ const selected = selectCrashReports(entries, { app: args.app, sinceMs, limit: args.limit !== undefined ? num(args.limit, "limit") : 20 });
774
+ const reports = selected.map((e) => ({
775
+ file: join(dir, e.name),
776
+ process: e.name.replace(/-\d{4}-\d{2}-\d{2}-\d{6}.*$/, ""),
777
+ modified: new Date(e.mtimeMs).toISOString(),
778
+ size: e.size,
779
+ }));
780
+ return JSON.stringify({ dir, count: reports.length, reports }, null, 2);
781
+ }
782
+ default: return null;
783
+ }
784
+ }
785
+
786
+ // ── Android Tools ──
787
+
788
+ // Validation lives inside the flag builder so every call site - including new
789
+ // ones - is covered: a raw `-s ${id}` reached execSync through run(), making a
790
+ // model-controlled device_id a command injection.
791
+ function adbFlag(id) { return id ? `-s ${deviceSerial(id)}` : ""; }
792
+
793
+ const ANDROID_TOOLS = [
794
+ { name: "android_list_devices", description: "List connected Android devices and emulators", inputSchema: { type: "object", properties: {} } },
795
+ { name: "android_screenshot", description: "Capture an Android screenshot. Returns a base64 PNG by default; pass `path` to write the file and return only its location - a base64 image per capture exhausts the caller's context on multi-capture runs.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, path: { type: "string", description: "Absolute file path to write the PNG to. The parent directory must already exist. Returns the path instead of the image." } } } },
796
+ { name: "android_tap", description: "Tap at coordinates on Android", inputSchema: { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, device_id: { type: "string" } }, required: ["x", "y"] } },
797
+ { name: "android_swipe", description: "Swipe on Android", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, duration_ms: { type: "number" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
798
+ { name: "android_type_text", description: "Type text on Android", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
799
+ { name: "android_key_event", description: "Send Android key event (HOME=3, BACK=4, ENTER=66)", inputSchema: { type: "object", properties: { keycode: { type: "number" }, device_id: { type: "string" } }, required: ["keycode"] } },
800
+ { name: "android_launch_app", description: "Launch Android app by package name", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
801
+ { name: "android_stop_app", description: "Force-stop Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
802
+ { name: "android_list_packages", description: "List installed Android packages", inputSchema: { type: "object", properties: { filter: { type: "string" }, device_id: { type: "string" } } } },
803
+ { name: "android_go_home", description: "Press Android home button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
804
+ { name: "android_go_back", description: "Press Android back button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
805
+ { name: "android_get_ui_tree", description: "Dump Android UI hierarchy (uiautomator XML with bounds, text, resource-id). The raw dump is unbounded; pass `path` to write it to a file instead of inlining, or filter:\"interactive\" for a compact JSON list of just the interactive elements (class, text, resource-id, center coords).", inputSchema: { type: "object", properties: { device_id: { type: "string" }, path: { type: "string", description: "Absolute file path to write the dump to: the raw XML, or the filtered JSON when filter is set. The parent directory must already exist." }, filter: { type: "string", enum: ["interactive"], description: "interactive: return only clickable/focusable elements as compact JSON instead of the raw XML" } } } },
806
+ { name: "android_set_dark_mode", description: "Enable/disable Android dark mode", inputSchema: { type: "object", properties: { enabled: { type: "boolean" }, device_id: { type: "string" } }, required: ["enabled"] } },
807
+ { name: "android_set_font_scale", description: "Set Android font scale (1.0=normal, 2.0=extra large)", inputSchema: { type: "object", properties: { scale: { type: "number" }, device_id: { type: "string" } }, required: ["scale"] } },
808
+ { name: "android_set_locale", description: "Set an app's locale (per-app locale, Android 13+/API 33). package_name is needed in practice: the per-app locale API is app-scoped, and device-wide locale change is not reachable over adb without root.", inputSchema: { type: "object", properties: { locale: { type: "string", description: "BCP-47 tag, e.g. tr-TR, en-US" }, package_name: { type: "string", description: "Target app package. Without it the call returns an error instead of silently doing nothing." }, device_id: { type: "string" } }, required: ["locale"] } },
809
+ { name: "android_set_location", description: "Set mock Android GPS location", inputSchema: { type: "object", properties: { latitude: { type: "number" }, longitude: { type: "number" }, device_id: { type: "string" } }, required: ["latitude", "longitude"] } },
810
+ { name: "android_grant_permission", description: "Grant Android runtime permission", inputSchema: { type: "object", properties: { package_name: { type: "string" }, permission: { type: "string" }, device_id: { type: "string" } }, required: ["package_name", "permission"] } },
811
+ { name: "android_revoke_permission", description: "Revoke Android runtime permission", inputSchema: { type: "object", properties: { package_name: { type: "string" }, permission: { type: "string" }, device_id: { type: "string" } }, required: ["package_name", "permission"] } },
812
+ { name: "android_record_screen", description: "Record the Android screen. action:\"start\" (default) spawns `adb shell screenrecord` in the background and returns immediately; action:\"stop\" ends the recording, pulls the file off the device and returns its local path. One recording per device; screenrecord hard-caps a segment at 180s.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["start", "stop"], description: "Default start" }, duration: { type: "number", description: "Time limit in seconds (start only, max 180, default 180)" }, path: { type: "string", description: "Local file path to pull the recording to (start only). Default rec_<timestamp>.mp4 in the tool's temp dir." }, device_id: { type: "string" } } } },
813
+ { name: "android_install_apk", description: "Install APK on Android", inputSchema: { type: "object", properties: { apk_path: { type: "string" }, device_id: { type: "string" } }, required: ["apk_path"] } },
814
+ { name: "android_uninstall_app", description: "Uninstall Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
815
+ { name: "android_logcat", description: "Get Android logcat entries", inputSchema: { type: "object", properties: { tag: { type: "string" }, lines: { type: "number" }, device_id: { type: "string" } } } },
816
+ { name: "android_get_screen_size", description: "Get Android screen resolution", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
817
+ { name: "android_open_url", description: "Open URL or deep link on Android", inputSchema: { type: "object", properties: { url: { type: "string" }, device_id: { type: "string" } }, required: ["url"] } },
818
+ { name: "android_clear_app_data", description: "Clear all data for Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
819
+ { name: "android_accessibility_audit", description: "Audit Android app accessibility: missing contentDescription, small touch targets (<48dp), missing resource-id. Use scope to filter by resource-id prefix.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, scope: { type: "string", description: "Filter: only audit elements whose resource-id contains this prefix (e.g. 'login_', 'com.example:id/login_'). Omit to audit all." } } } },
820
+ { name: "android_launch_time", description: "Measure Android app cold launch time (TotalTime in ms)", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
821
+ { name: "android_apk_audit", description: "Audit APK/AAB for Play Store compliance: debug flag, target SDK, permissions, signing, ProGuard", inputSchema: { type: "object", properties: { apk_path: { type: "string", description: "Path to .apk file" } }, required: ["apk_path"] } },
822
+ { name: "android_list_crashes", description: "Dump the Android crash log buffer (`adb logcat -b crash -d`), tail-bounded. Empty output means no crashes since the buffer was last cleared.", inputSchema: { type: "object", properties: { lines: { type: "number", description: "Max lines returned, from the end (default 200)" }, device_id: { type: "string" } } } },
823
+ { name: "android_set_orientation", description: "Rotate the Android screen to portrait or landscape. Disables accelerometer rotation and pins user_rotation, so the device stays put until rotation is re-enabled. Accounts for the device's natural orientation (detected via wm size), so landscape-natural tablets rotate correctly too. No iOS counterpart: simctl exposes no rotation lever.", inputSchema: { type: "object", properties: { orientation: { type: "string", enum: ["portrait", "landscape"] }, device_id: { type: "string" } }, required: ["orientation"] } },
824
+ ];
825
+
826
+ async function handleAndroid(name, args, ctx = {}) {
827
+ if (!HAS_ADB) return `${ERROR_PREFIX}Android SDK not installed - adb not on PATH, so Android tools cannot run. Install platform-tools and ensure adb is on PATH.`;
828
+ const df = adbFlag(args.device_id);
829
+
830
+ switch (name) {
831
+ case "android_list_devices": return run("adb devices -l");
832
+ case "android_screenshot": {
833
+ const f = args.path ? String(args.path) : join(SCREENSHOT_DIR, `android_${Date.now()}.png`);
834
+ if (args.path) {
835
+ const parent = dirname(f);
836
+ if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
837
+ }
838
+ run(`adb ${df} shell screencap -p /sdcard/_mcp_screen.png`);
839
+ run(`adb ${df} pull /sdcard/_mcp_screen.png ${shq(f)}`);
840
+ run(`adb ${df} shell rm /sdcard/_mcp_screen.png`);
841
+ if (!existsSync(f)) return "ERROR: Screenshot failed";
842
+ if (args.path) return fileResult(`Screenshot written: ${f}`, f);
843
+ const buf = readFileSync(f);
844
+ return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
845
+ }
846
+ case "android_tap": return run(`adb ${df} shell input tap ${num(args.x, "x")} ${num(args.y, "y")}`) || `Tapped (${args.x}, ${args.y})`;
847
+ case "android_swipe": return run(`adb ${df} shell input swipe ${num(args.x1, "x1")} ${num(args.y1, "y1")} ${num(args.x2, "x2")} ${num(args.y2, "y2")} ${num(args.duration_ms || 300, "duration_ms")}`) || "Swiped";
848
+ // `adb shell input text` wants spaces as %s; single-quote the result so the
849
+ // remaining characters cannot reach the shell as syntax.
850
+ case "android_type_text": return run(`adb ${df} shell input text ${shq(String(args.text ?? "").replace(/ /g, "%s"))}`) || `Typed: ${args.text}`;
851
+ case "android_key_event": return run(`adb ${df} shell input keyevent ${token(args.keycode, "keycode")}`) || `Key ${args.keycode}`;
852
+ case "android_launch_app": return args.activity ? run(`adb ${df} shell am start -n ${sanitizeId(args.package_name)}/${sanitizeId(args.activity)}`) : run(`adb ${df} shell monkey -p ${sanitizeId(args.package_name)} -c android.intent.category.LAUNCHER 1`) || `Launched`;
853
+ case "android_stop_app": return run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`) || "Stopped";
854
+ case "android_list_packages": { const out = run(`adb ${df} shell pm list packages`); return args.filter ? out.split("\n").filter(l => l.toLowerCase().includes(args.filter.toLowerCase())).join("\n") : out; }
855
+ case "android_go_home": return run(`adb ${df} shell input keyevent 3`) || "Home";
856
+ case "android_go_back": return run(`adb ${df} shell input keyevent 4`) || "Back";
857
+ case "android_get_ui_tree": {
858
+ const f = args.path ? String(args.path) : join(SCREENSHOT_DIR, `ui_${Date.now()}.xml`);
859
+ if (args.path) {
860
+ const parent = dirname(f);
861
+ if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
862
+ }
863
+ run(`adb ${df} shell uiautomator dump /sdcard/_mcp_ui.xml`);
864
+ run(`adb ${df} pull /sdcard/_mcp_ui.xml ${shq(f)}`);
865
+ run(`adb ${df} shell rm /sdcard/_mcp_ui.xml`);
866
+ if (!existsSync(f)) return "ERROR: UI dump failed";
867
+ if (args.filter === "interactive") {
868
+ const elements = interactiveElements(readFileSync(f, "utf-8"));
869
+ const report = JSON.stringify({ filter: "interactive", count: elements.length, elements }, null, 2);
870
+ // The persisted file must match the returned text: leaving the raw XML
871
+ // at `path` attached a resource_link to content the tool never showed.
872
+ if (args.path) {
873
+ writeFileSync(f, report);
874
+ return fileResult(report, f);
875
+ }
876
+ return report;
877
+ }
878
+ return args.path ? fileResult(`UI tree written: ${f}`, f) : readFileSync(f, "utf-8");
879
+ }
880
+ case "android_set_dark_mode": return run(`adb ${df} shell cmd uimode night ${args.enabled ? "yes" : "no"}`) || `Dark mode: ${args.enabled}`;
881
+ case "android_set_font_scale": return run(`adb ${df} shell settings put system font_scale ${num(args.scale, "scale")}`) || `Font scale: ${args.scale}`;
882
+ // The old SET_LOCALE broadcast is a dead pre-Android-7 mechanism: `am
883
+ // broadcast` exits 0 even when nothing handles the intent, and the previous
884
+ // implementation also sent stderr to /dev/null, so it reported success
885
+ // unconditionally. `cmd locale set-app-locales` is the supported per-app
886
+ // path (API 33+). Device-wide locale needs root and is out of scope.
887
+ case "android_set_locale": {
888
+ const pkg = sanitizeId(args.package_name);
889
+ if (!pkg) return `${ERROR_PREFIX}android_set_locale requires package_name (per-app locale API is app-scoped)`;
890
+ const locale = String(args.locale || "");
891
+ if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(locale)) {
892
+ return `${ERROR_PREFIX}invalid locale tag "${locale}" (expected BCP-47, e.g. tr-TR)`;
893
+ }
894
+ const out = run(`adb ${df} shell cmd locale set-app-locales ${pkg} --locales ${locale}`);
895
+ if (isFailure(out)) return out;
896
+ // `cmd` prints usage or an Exception on an unsupported platform rather
897
+ // than exiting non-zero, so inspect the output before claiming success.
898
+ if (/Unknown command|Exception|usage:/i.test(out)) {
899
+ return `${ERROR_PREFIX}per-app locale not supported on this device (needs API 33+). Device said: ${out.replace(/\s+/g, " ").slice(0, 300)}`;
900
+ }
901
+ // Read the locale back and COMPARE it. The previous form reported the
902
+ // REQUESTED value as fact and put the device's answer in a parenthetical,
903
+ // so a device that accepted the command but applied a different locale (or
904
+ // none at all) still read as success - the same report-without-checking
905
+ // shape the `Unknown command` branch above was added to remove.
906
+ const verify = run(`adb ${df} shell cmd locale get-app-locales ${pkg}`);
907
+ if (isFailure(verify)) {
908
+ return `${ERROR_PREFIX}set ${locale} for ${pkg} but could not read it back to confirm: ${verify}`;
909
+ }
910
+ const reported = verify.replace(/\s+/g, " ").trim();
911
+ // Output looks like `Locales for com.x for user 0 are [tr-TR]`. Compare
912
+ // case-insensitively, and accept the device answering with a more specific
913
+ // tag than asked for (`tr` -> `tr-TR`), which is correct, not a mismatch.
914
+ const bracketed = /\[([^\]]*)\]/.exec(reported)?.[1] ?? reported;
915
+ const want = locale.toLowerCase();
916
+ const got = bracketed.toLowerCase();
917
+ if (!(got === want || got.startsWith(`${want}-`) || want.startsWith(`${got}-`))) {
918
+ return `${ERROR_PREFIX}per-app locale not applied: asked for ${locale}, device reports "${reported.slice(0, 200)}"`;
919
+ }
920
+ return `Locale for ${pkg}: ${locale} (verified: ${bracketed})`;
921
+ }
922
+ case "android_set_location": return run(`adb ${df} emu geo fix ${num(args.longitude, "longitude")} ${num(args.latitude, "latitude")}`) || `Location set`;
923
+ case "android_grant_permission": return run(`adb ${df} shell pm grant ${sanitizeId(args.package_name)} ${token(args.permission, "permission")}`) || "Granted";
924
+ case "android_revoke_permission": return run(`adb ${df} shell pm revoke ${sanitizeId(args.package_name)} ${token(args.permission, "permission")}`) || "Revoked";
925
+ case "android_record_screen": {
926
+ const key = `android:${args.device_id || "default"}`;
927
+ if ((args.action || "start") === "stop") {
928
+ const rec = RECORDINGS.get(key);
929
+ if (!rec) return `${ERROR_PREFIX}no active Android recording${args.device_id ? ` on ${args.device_id}` : ""}. Start one with action:"start".`;
930
+ // SIGINT to screenrecord on the device is the supported stop: it makes
931
+ // the recorder finalize the mp4. Killing the local adb client does not
932
+ // reliably reach the remote process.
933
+ run(`adb ${df} shell pkill -2 screenrecord || adb ${df} shell kill -2 \\$(pidof screenrecord)`);
934
+ await new Promise((resolve) => setTimeout(resolve, 2000));
935
+ try { rec.child.kill("SIGTERM"); } catch {}
936
+ rec.exited = true;
937
+ // The remote file is deleted, and the map entry released, only after a
938
+ // confirmed pull: rm-then-check lost the recording irrecoverably on a
939
+ // transient pull failure. Keeping the entry lets stop be retried.
940
+ const pull = run(`adb ${df} pull ${shq(rec.remotePath)} ${shq(rec.path)}`, { timeout: 60000 });
941
+ if (isFailure(pull)) return `${pull}\nThe recording is still on the device at ${rec.remotePath}; call action:"stop" again to retry the pull.`;
942
+ if (!existsSync(rec.path)) return `${ERROR_PREFIX}pull produced no file at ${rec.path}. The recording is still on the device at ${rec.remotePath}; call action:"stop" again to retry.`;
943
+ run(`adb ${df} shell rm -f ${shq(rec.remotePath)}`);
944
+ RECORDINGS.delete(key);
945
+ return fileResult(`Recording saved: ${rec.path}`, rec.path);
946
+ }
947
+ const existing = RECORDINGS.get(key);
948
+ if (existing) {
949
+ // The entry deliberately survives child exit (screenrecord self-stops at
950
+ // its 180s cap) so stop can still pull the file - but a start after that
951
+ // exit must not claim a recording is running when none is.
952
+ if (existing.exited) return `${ERROR_PREFIX}the previous recording finished but was not collected. Call action:"stop" to pull it before starting a new one.`;
953
+ return `${ERROR_PREFIX}a recording is already running${args.device_id ? ` on ${args.device_id}` : ""}. Stop it first with action:"stop".`;
954
+ }
955
+ const dur = Math.min(num(args.duration || 180, "duration"), 180);
956
+ const remotePath = `/sdcard/_mcp_rec_${Date.now()}.mp4`;
957
+ const localPath = args.path ? String(args.path) : join(SCREENSHOT_DIR, `rec_${Date.now()}.mp4`);
958
+ const parent = dirname(localPath);
959
+ if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
960
+ const argv = [...(args.device_id ? ["-s", deviceSerial(args.device_id)] : []), "shell", "screenrecord", "--time-limit", String(dur), remotePath];
961
+ const child = spawn("adb", argv, { stdio: "ignore" });
962
+ const entry = { child, remotePath, path: localPath, exited: false };
963
+ child.once("close", () => { if (RECORDINGS.get(key) === entry) entry.exited = true; });
964
+ RECORDINGS.set(key, entry);
965
+ return `Recording started (pid ${child.pid}, limit ${dur}s) -> ${localPath}\nCall android_record_screen with action:"stop" to finish.`;
966
+ }
967
+ case "android_install_apk": return await runAsync(`adb ${df} install -r ${shq(args.apk_path)}`, { timeout: 120000, signal: ctx.signal }) || "Installed";
968
+ case "android_uninstall_app": return run(`adb ${df} shell pm uninstall ${sanitizeId(args.package_name)}`) || "Uninstalled";
969
+ case "android_logcat": { const lines = args.lines !== undefined ? num(args.lines, "lines") : 50; const tf = args.tag ? `| grep -i ${shq(args.tag)}` : ""; return run(`adb ${df} logcat -d ${tf} | tail -${lines}`); }
970
+ case "android_get_screen_size": return run(`adb ${df} shell wm size`);
971
+ case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${shq(args.url)}`) || `Opened: ${args.url}`;
972
+ case "android_clear_app_data": return run(`adb ${df} shell pm clear ${sanitizeId(args.package_name)}`) || "Cleared";
973
+ case "android_accessibility_audit": {
974
+ run(`adb ${df} shell uiautomator dump /sdcard/_mcp_a11y.xml`);
975
+ const f = join(SCREENSHOT_DIR, `a11y_${Date.now()}.xml`);
976
+ run(`adb ${df} pull /sdcard/_mcp_a11y.xml ${shq(f)}`);
977
+ run(`adb ${df} shell rm /sdcard/_mcp_a11y.xml`);
978
+ if (!existsSync(f)) return "ERROR: UI dump failed";
979
+ const xml = readFileSync(f, "utf-8");
980
+ const scope = args.scope || null;
981
+ const issues = [];
982
+ let totalScanned = 0, totalSkipped = 0;
983
+ const nodeRegex = /<node[^>]*>/g;
984
+ let match;
985
+ while ((match = nodeRegex.exec(xml)) !== null) {
986
+ const node = match[0];
987
+ const cls = node.match(/class="([^"]*)"/)?.[1] || "";
988
+ const desc = node.match(/content-desc="([^"]*)"/)?.[1] || "";
989
+ const rid = node.match(/resource-id="([^"]*)"/)?.[1] || "";
990
+ const text = node.match(/text="([^"]*)"/)?.[1] || "";
991
+ const clickable = node.includes('clickable="true"');
992
+ const bounds = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
993
+ if (clickable) {
994
+ if (scope && rid && !rid.includes(scope)) { totalSkipped++; continue; }
995
+ totalScanned++;
996
+ if (!desc && !text) issues.push({ severity: "critical", issue: "Missing contentDescription", element: cls, resourceId: rid });
997
+ if (!rid) issues.push({ severity: "warning", issue: "Missing resource-id (UI testing)", element: cls });
998
+ if (bounds) {
999
+ const w = parseInt(bounds[3]) - parseInt(bounds[1]);
1000
+ const h = parseInt(bounds[4]) - parseInt(bounds[2]);
1001
+ if (w < 48 || h < 48) issues.push({ severity: "important", issue: `Touch target too small: ${w}x${h}dp (min 48x48)`, element: cls, resourceId: rid });
1002
+ }
1003
+ }
1004
+ }
1005
+ return JSON.stringify({ scope: scope || "all", elements_scanned: totalScanned, elements_skipped: totalSkipped, total_issues: issues.length, critical: issues.filter(i => i.severity === "critical").length, important: issues.filter(i => i.severity === "important").length, warning: issues.filter(i => i.severity === "warning").length, issues }, null, 2);
1006
+ }
1007
+ case "android_launch_time": {
1008
+ run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
1009
+ const activity = sanitizeId(args.activity || `${args.package_name}/.MainActivity`);
1010
+ const result = run(`adb ${df} shell am start -W -n ${activity} 2>&1`);
1011
+ const totalTime = result.match(/TotalTime:\s*(\d+)/)?.[1];
1012
+ const waitTime = result.match(/WaitTime:\s*(\d+)/)?.[1];
1013
+ return JSON.stringify({ package: args.package_name, cold_start: true, total_time_ms: totalTime ? parseInt(totalTime) : null, wait_time_ms: waitTime ? parseInt(waitTime) : null, raw: result }, null, 2);
1014
+ }
1015
+ case "android_apk_audit": {
1016
+ const p = args.apk_path;
1017
+ if (!existsSync(p)) return `ERROR: APK not found at ${p}`;
1018
+ const findings = [];
1019
+ // 1. Basic info via aapt2
1020
+ const aapt = run(`aapt2 dump badging ${shq(p)} 2>/dev/null`) || run(`aapt dump badging ${shq(p)} 2>/dev/null`);
1021
+ if (aapt) {
1022
+ const pkg = aapt.match(/package: name='([^']*)'/)?.[1];
1023
+ const versionName = aapt.match(/versionName='([^']*)'/)?.[1];
1024
+ const versionCode = aapt.match(/versionCode='([^']*)'/)?.[1];
1025
+ const targetSdk = aapt.match(/targetSdkVersion:'(\d+)'/)?.[1];
1026
+ const minSdk = aapt.match(/sdkVersion:'(\d+)'/)?.[1];
1027
+ findings.push({ check: "package", value: pkg, status: "info" });
1028
+ findings.push({ check: "version", value: `${versionName} (${versionCode})`, status: "info" });
1029
+ findings.push({ check: "target_sdk", value: targetSdk, status: targetSdk && parseInt(targetSdk) >= 34 ? "pass" : "warning", detail: targetSdk && parseInt(targetSdk) < 34 ? `Target SDK ${targetSdk} - Google Play requires 34+ for new apps` : "OK" });
1030
+ findings.push({ check: "min_sdk", value: minSdk, status: "info" });
1031
+ // Permissions audit
1032
+ const perms = [...aapt.matchAll(/uses-permission: name='([^']*)'/g)].map(m => m[1]);
1033
+ const dangerousPerms = perms.filter(p => /(CAMERA|CONTACTS|LOCATION|MICROPHONE|PHONE|SMS|STORAGE|CALENDAR)/.test(p));
1034
+ findings.push({ check: "permissions", value: `${perms.length} total, ${dangerousPerms.length} dangerous`, status: dangerousPerms.length > 5 ? "warning" : "info", detail: dangerousPerms.join(", ") || "None dangerous" });
1035
+ // Debuggable check
1036
+ const debuggable = aapt.includes("application-debuggable");
1037
+ findings.push({ check: "debuggable", status: debuggable ? "critical" : "pass", detail: debuggable ? "App is DEBUGGABLE - Play Store will reject" : "Not debuggable - OK" });
1038
+ } else {
1039
+ findings.push({ check: "aapt", status: "warning", detail: "aapt2/aapt not found - install Android SDK Build-Tools for full audit" });
1040
+ }
1041
+ // 2. Signing check
1042
+ const signingInfo = run(`apksigner verify --print-certs ${shq(p)} 2>&1`);
1043
+ if (signingInfo && !signingInfo.includes("ERROR")) {
1044
+ const hasV2 = signingInfo.includes("v2 scheme") || run(`apksigner verify -v ${shq(p)} 2>&1`)?.includes("Verified using v2");
1045
+ findings.push({ check: "signing", status: "pass", detail: "APK is signed" });
1046
+ findings.push({ check: "signing_v2", status: hasV2 ? "pass" : "warning", detail: hasV2 ? "v2+ signature present" : "Only v1 signature - consider v2+ for tamper protection" });
1047
+ } else {
1048
+ findings.push({ check: "signing", status: signingInfo?.includes("DOES NOT VERIFY") ? "critical" : "warning", detail: signingInfo || "apksigner not found" });
1049
+ }
1050
+ // 3. File size
1051
+ try { const stat = run(`stat -f "%z" ${shq(p)} 2>/dev/null`) || run(`stat -c "%s" ${shq(p)} 2>/dev/null`); if (stat) { const mb = parseInt(stat) / 1048576; findings.push({ check: "apk_size", value: `${mb.toFixed(1)} MB`, status: mb > 150 ? "warning" : "pass", detail: mb > 150 ? "APK > 150MB - consider App Bundle (.aab)" : "OK" }); } } catch {}
1052
+ // 4. ProGuard/R8 check
1053
+ const hasMapping = run(`unzip -l ${shq(p)} 2>/dev/null | grep -c "classes.dex"`)?.trim();
1054
+ const dexCount = parseInt(hasMapping) || 0;
1055
+ findings.push({ check: "dex_files", value: `${dexCount} dex file(s)`, status: dexCount > 3 ? "warning" : "info", detail: dexCount > 3 ? "Many DEX files - ensure R8/ProGuard minification is enabled" : "OK" });
1056
+ // Summary
1057
+ const critical = findings.filter(f => f.status === "critical").length;
1058
+ const warnings = findings.filter(f => f.status === "warning").length;
1059
+ return JSON.stringify({ apk: p, summary: { critical, warnings, passed: findings.filter(f => f.status === "pass").length, total_checks: findings.length }, verdict: critical > 0 ? "FAIL - critical issues must be fixed" : warnings > 0 ? "WARN - review warnings" : "PASS - ready for Play Store", findings }, null, 2);
1060
+ }
1061
+ case "android_list_crashes": {
1062
+ const lines = args.lines !== undefined ? num(args.lines, "lines") : 200;
1063
+ const out = run(`adb ${df} logcat -b crash -d | tail -${lines}`);
1064
+ if (isFailure(out)) return out;
1065
+ return out.trim() || "No crashes in the crash buffer.";
1066
+ }
1067
+ case "android_set_orientation": {
1068
+ const base = { portrait: 0, landscape: 1 }[args.orientation];
1069
+ if (base === undefined) return `${ERROR_PREFIX}orientation must be portrait or landscape`;
1070
+ // user_rotation counts from the device's NATURAL orientation, which on
1071
+ // many tablets is landscape - a hardcoded {portrait:0, landscape:1}
1072
+ // inverts the result there. `wm size` reports the rotation-independent
1073
+ // physical size, so width > height identifies a landscape-natural panel.
1074
+ // If the probe yields nothing, fall back to assuming portrait-natural.
1075
+ const size = run(`adb ${df} shell wm size`);
1076
+ const dims = /Physical size:\s*(\d+)x(\d+)/.exec(size);
1077
+ const landscapeNatural = dims ? Number(dims[1]) > Number(dims[2]) : false;
1078
+ const rotation = landscapeNatural ? 1 - base : base;
1079
+ const lock = run(`adb ${df} shell settings put system accelerometer_rotation 0`);
1080
+ if (isFailure(lock)) return lock;
1081
+ const rotate = run(`adb ${df} shell settings put system user_rotation ${rotation}`);
1082
+ return isFailure(rotate) ? rotate : `Orientation: ${args.orientation} (auto-rotate disabled${landscapeNatural ? ", landscape-natural device detected" : ""})`;
1083
+ }
1084
+ default: return null;
1085
+ }
1086
+ }
1087
+
1088
+ // ── Web Automation Tools (Playwright-powered) ──
1089
+
1090
+ // Playwright is an optional peer dependency. Single browser instance, lazy-loaded.
1091
+ // Tools return a clear message if Playwright is not installed.
1092
+
1093
+ let _browser = null;
1094
+ let _page = null;
1095
+
1096
+ async function ensureBrowser(browserType = "chromium") {
1097
+ if (_browser && _page) return _page;
1098
+ let pw;
1099
+ try {
1100
+ pw = await import("playwright");
1101
+ } catch {
1102
+ throw new Error("Web tools require Playwright. Install once with: npm i -g playwright && npx playwright install chromium");
1103
+ }
1104
+ const engines = { chromium: pw.chromium, webkit: pw.webkit, firefox: pw.firefox };
1105
+ const engine = engines[browserType] || pw.chromium;
1106
+ _browser = await engine.launch({ headless: true });
1107
+ const ctx = await _browser.newContext();
1108
+ _page = await ctx.newPage();
1109
+ return _page;
1110
+ }
1111
+
1112
+ async function closeBrowser() {
1113
+ try { await _page?.context()?.close(); } catch {}
1114
+ try { await _browser?.close(); } catch {}
1115
+ _page = null;
1116
+ _browser = null;
1117
+ }
1118
+
1119
+ const WEB_TOOLS = [
1120
+ { name: "web_goto", description: "Open a URL in a headless browser (Playwright). Reuses a single browser instance across calls.", inputSchema: { type: "object", properties: { url: { type: "string" }, browser: { type: "string", enum: ["chromium", "webkit", "firefox"], description: "Default chromium; use webkit for Safari-like behavior" }, wait_until: { type: "string", enum: ["load", "domcontentloaded", "networkidle"] } }, required: ["url"] } },
1121
+ { name: "web_screenshot", description: "Capture a screenshot of the current page. Returns a base64 PNG by default; pass `path` to write the file and return only its location.", inputSchema: { type: "object", properties: { full_page: { type: "boolean" }, path: { type: "string", description: "Absolute file path to write the PNG to. The parent directory must already exist. Returns the path instead of the image." } } } },
1122
+ { name: "web_click", description: "Click an element by CSS selector or text. Auto-waits for element.", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector, or 'text=...' / 'role=...'" }, timeout_ms: { type: "number" } }, required: ["selector"] } },
1123
+ { name: "web_type", description: "Type text into an input matched by selector.", inputSchema: { type: "object", properties: { selector: { type: "string" }, text: { type: "string" }, clear_first: { type: "boolean" } }, required: ["selector", "text"] } },
1124
+ { name: "web_eval", description: "Run arbitrary JavaScript in the page context and return the result as JSON.", inputSchema: { type: "object", properties: { script: { type: "string", description: "JS expression or function body (use `return ...`)" } }, required: ["script"] } },
1125
+ { name: "web_wait_for", description: "Wait for a selector to appear (or a timeout).", inputSchema: { type: "object", properties: { selector: { type: "string" }, timeout_ms: { type: "number" }, state: { type: "string", enum: ["attached", "detached", "visible", "hidden"] } }, required: ["selector"] } },
1126
+ { name: "web_get_text", description: "Extract textContent of the first match of a selector.", inputSchema: { type: "object", properties: { selector: { type: "string" } }, required: ["selector"] } },
1127
+ { name: "web_close", description: "Close the current browser context and release resources.", inputSchema: { type: "object", properties: {} } },
1128
+ ];
1129
+
1130
+ async function handleWeb(name, args) {
1131
+ if (name === "web_close") { await closeBrowser(); return "Browser closed"; }
1132
+ const page = await ensureBrowser(args.browser);
1133
+ switch (name) {
1134
+ case "web_goto": {
1135
+ await page.goto(args.url, { waitUntil: args.wait_until || "load", timeout: 30000 });
1136
+ return `Opened ${args.url} (title: "${await page.title()}")`;
1137
+ }
1138
+ case "web_screenshot": {
1139
+ const path = args.path ? String(args.path) : join(SCREENSHOT_DIR, `web_${Date.now()}.png`);
1140
+ if (args.path && !existsSync(dirname(path))) return `${ERROR_PREFIX}directory does not exist: ${dirname(path)}`;
1141
+ const buf = await page.screenshot({ fullPage: args.full_page ?? true });
1142
+ writeFileSync(path, buf);
1143
+ if (args.path) return fileResult(`Screenshot written: ${path}`, path);
1144
+ return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path };
1145
+ }
1146
+ case "web_click": {
1147
+ await page.click(args.selector, { timeout: args.timeout_ms || 5000 });
1148
+ return `Clicked: ${args.selector}`;
1149
+ }
1150
+ case "web_type": {
1151
+ if (args.clear_first) await page.fill(args.selector, "");
1152
+ await page.fill(args.selector, args.text);
1153
+ return `Typed into ${args.selector}: ${args.text.length} chars`;
1154
+ }
1155
+ case "web_eval": {
1156
+ const fn = args.script.includes("return ") ? `(() => { ${args.script} })()` : args.script;
1157
+ const result = await page.evaluate(fn);
1158
+ return JSON.stringify(result);
1159
+ }
1160
+ case "web_wait_for": {
1161
+ await page.waitForSelector(args.selector, { timeout: args.timeout_ms || 5000, state: args.state || "visible" });
1162
+ return `${args.selector} is ${args.state || "visible"}`;
1163
+ }
1164
+ case "web_get_text": {
1165
+ const text = await page.locator(args.selector).first().textContent();
1166
+ return text ?? "";
1167
+ }
1168
+ default: return null;
1169
+ }
1170
+ }
1171
+
1172
+ // ── Autonomous Agent DSL (batch step execution) ──
1173
+
1174
+ const AGENT_TOOLS = [
1175
+ {
1176
+ name: "agent_run_steps",
1177
+ description: "Execute a sequence of device/web/design actions as a single batch. Each step is {tool: <ios_* | android_* | web_* | design_* tool name>, args: {...}, continue_on_error?: bool, wait_ms?: number}. Returns per-step status plus a verdict. A step whose command fails is reported status \"error\"; unless it sets continue_on_error the batch stops there and the result is flagged as an error. Nested agent_* steps are refused. Use this to script multi-step flows (login, navigation, form fill) without multiple MCP round trips.",
1178
+ inputSchema: {
1179
+ type: "object",
1180
+ properties: {
1181
+ steps: {
1182
+ type: "array",
1183
+ items: {
1184
+ type: "object",
1185
+ properties: {
1186
+ tool: { type: "string", description: "Name of an ios_* / android_* / web_* / design_* tool" },
1187
+ args: { type: "object" },
1188
+ continue_on_error: { type: "boolean", description: "If true, record the error and move on. Default false." },
1189
+ wait_ms: { type: "number", description: "Sleep this many ms after this step before the next one." },
1190
+ },
1191
+ required: ["tool"],
1192
+ },
1193
+ description: "Ordered list of steps",
1194
+ },
1195
+ stop_on_first_error: { type: "boolean", description: "Default true. Set false to run every step regardless." },
1196
+ },
1197
+ required: ["steps"],
1198
+ },
1199
+ },
1200
+ ];
1201
+
1202
+ // design_* is dispatched here too: the tool description offers it, and a design
1203
+ // audit is a legitimate step in a scripted flow. agent_* is refused on purpose -
1204
+ // a batch able to nest itself has no recursion bound.
1205
+ async function dispatchStep(tool, stepArgs) {
1206
+ if (tool.startsWith("agent_")) throw new Error(`Nested batch steps are not supported: ${tool}`);
1207
+ let result;
1208
+ if (tool.startsWith("ios_")) result = await handleIOS(tool, stepArgs);
1209
+ else if (tool.startsWith("android_")) result = await handleAndroid(tool, stepArgs);
1210
+ else if (tool.startsWith("web_")) result = await handleWeb(tool, stepArgs);
1211
+ else if (tool.startsWith("design_")) result = await handleDesign(tool, stepArgs, designCtx);
1212
+ else throw new Error(`Unknown tool: ${tool}`);
1213
+ // Every handler's `default:` arm returns null, and that is its only source of
1214
+ // null, so null means "I do not recognise this tool". The throw below used to
1215
+ // fire only for a name matching no prefix at all, so a typo that kept the
1216
+ // family - ios_taap, design_reprot - fell through to the handler, came back
1217
+ // null, and was recorded as a step with status "ok" and result "null". Same
1218
+ // false-success class as the batch runner not applying isFailure().
1219
+ if (result === null || result === undefined) throw new Error(`Unknown tool: ${tool}`);
1220
+ return result;
1221
+ }
1222
+
1223
+ async function handleAgent(name, args) {
1224
+ if (name !== "agent_run_steps") return null;
1225
+ const steps = Array.isArray(args.steps) ? args.steps : [];
1226
+ const stopOnError = args.stop_on_first_error !== false;
1227
+ const results = [];
1228
+ let aborted = false;
1229
+ for (let i = 0; i < steps.length; i++) {
1230
+ const step = steps[i];
1231
+ const stepArgs = step.args || {};
1232
+ let outcome;
1233
+ try {
1234
+ const result = await dispatchStep(step.tool, stepArgs);
1235
+ // A failing CLI does not throw: run() returns the ERROR_PREFIX marker
1236
+ // string instead. The CallTool handler makes that judgement with
1237
+ // isFailure() before answering the host, and the batch has to make the
1238
+ // same one - reading the marker as a successful result reported a broken
1239
+ // step as status "ok", left errors at 0, published verdict "all_ok", and
1240
+ // silently defeated stop_on_first_error.
1241
+ if (isFailure(result)) {
1242
+ outcome = {
1243
+ step: i + 1,
1244
+ tool: step.tool,
1245
+ status: "error",
1246
+ error: String(result).slice(ERROR_PREFIX.length),
1247
+ };
1248
+ } else {
1249
+ outcome = {
1250
+ step: i + 1,
1251
+ tool: step.tool,
1252
+ status: "ok",
1253
+ result: typeof result === "object"
1254
+ ? (result?.type === "file" ? String(result.text).slice(0, 500) : "(binary)")
1255
+ : String(result).slice(0, 500),
1256
+ };
1257
+ }
1258
+ } catch (e) {
1259
+ outcome = { step: i + 1, tool: step.tool, status: "error", error: truncateError(e.message) };
1260
+ }
1261
+ results.push(outcome);
1262
+ if (outcome.status === "error" && !step.continue_on_error && stopOnError) {
1263
+ aborted = true;
1264
+ break;
1265
+ }
1266
+ if (step.wait_ms) await new Promise((r) => setTimeout(r, step.wait_ms));
1267
+ }
1268
+ const errors = results.filter((r) => r.status === "error").length;
1269
+ const text = JSON.stringify(
1270
+ {
1271
+ total_steps: steps.length,
1272
+ executed: results.length,
1273
+ errors,
1274
+ verdict: errors === 0 ? "all_ok" : errors === results.length ? "all_failed" : "partial",
1275
+ aborted,
1276
+ results,
1277
+ },
1278
+ null,
1279
+ 2,
1280
+ );
1281
+ // An abort is an unambiguous failure of the batch as requested, so the host
1282
+ // sees isError. Errors the caller opted into with continue_on_error are not -
1283
+ // there the verdict carries the nuance and the call itself succeeded.
1284
+ return { type: "batch", text, isError: aborted };
1285
+ }
1286
+
1287
+ // ── Server ──
1288
+
1289
+ const ALL_TOOLS = [...IOS_TOOLS, ...ANDROID_TOOLS, ...WEB_TOOLS, ...AGENT_TOOLS, ...DESIGN_TOOLS];
1290
+
1291
+ // Name -> inputSchema, so the CallTool boundary can enforce the declared shape.
1292
+ const TOOL_SCHEMAS = new Map(ALL_TOOLS.map((t) => [t.name, t.inputSchema || {}]));
1293
+
1294
+ // Minimal JSON-Schema-subset validator. The low-level MCP Server does NOT check
1295
+ // arguments against inputSchema, so every declared `type`/`enum`/`required` was
1296
+ // documentation only - a string reached a handler that interpolated it as a
1297
+ // number, which is the root cause of the shell-injection class. This enforces
1298
+ // the declared contract at the one boundary every tool passes through. Zero
1299
+ // dependencies (no ajv): supports type (string/number/integer/boolean/array/
1300
+ // object), required, and enum - the only constructs the tool schemas use.
1301
+ function schemaTypeOk(value, type) {
1302
+ switch (type) {
1303
+ // A scalar is a fine string (paths/schemes have always arrived as strings,
1304
+ // and a number/boolean stringifies harmlessly). Reject only arrays/objects.
1305
+ case "string": return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
1306
+ // Lenient-but-safe: accept a numeric string ("50") the way callers have
1307
+ // always sent it, but reject anything that is not a finite number ("50; rm"
1308
+ // -> NaN -> rejected). This is what blocks the shell-injection class without
1309
+ // breaking a caller that passes numbers as JSON strings.
1310
+ case "number": return typeof value !== "object" && value !== "" && Number.isFinite(Number(value));
1311
+ case "integer": return typeof value !== "object" && value !== "" && Number.isInteger(Number(value));
1312
+ case "boolean": return typeof value === "boolean";
1313
+ case "array": return Array.isArray(value);
1314
+ case "object": return value !== null && typeof value === "object" && !Array.isArray(value);
1315
+ default: return true; // unknown/absent type: accept
1316
+ }
1317
+ }
1318
+
1319
+ function validateArgs(name, args) {
1320
+ const schema = TOOL_SCHEMAS.get(name);
1321
+ if (!schema || schema.type !== "object") return null;
1322
+ const props = schema.properties || {};
1323
+ for (const req of schema.required || []) {
1324
+ if (args[req] === undefined || args[req] === null) {
1325
+ return `missing required argument: ${req}`;
1326
+ }
1327
+ }
1328
+ for (const [key, spec] of Object.entries(props)) {
1329
+ if (args[key] === undefined || args[key] === null) continue;
1330
+ if (spec.type && !schemaTypeOk(args[key], spec.type)) {
1331
+ return `argument '${key}' must be ${spec.type}, got ${Array.isArray(args[key]) ? "array" : typeof args[key]}`;
1332
+ }
1333
+ if (Array.isArray(spec.enum) && !spec.enum.includes(args[key])) {
1334
+ return `argument '${key}' must be one of ${JSON.stringify(spec.enum)}`;
1335
+ }
1336
+ }
1337
+ return null;
1338
+ }
1339
+
1340
+ // Tool annotations (MCP 2025-11-25). Hosts use these for permission prompts and
1341
+ // for deciding what may run unattended, so the classification lives here in one
1342
+ // auditable place rather than inline on every tool literal.
1343
+ const READ_ONLY_TOOLS = new Set([
1344
+ "ios_list_devices", "ios_screenshot", "ios_list_apps", "ios_get_ui_tree", "ios_get_app_container",
1345
+ "ios_accessibility_audit", "ios_archive_audit", "ios_app_store_audit", "ios_xcresult", "ios_visual_diff",
1346
+ "ios_list_crashes",
1347
+ "android_list_devices", "android_screenshot", "android_get_ui_tree", "android_list_packages",
1348
+ "android_logcat", "android_get_screen_size", "android_accessibility_audit", "android_apk_audit",
1349
+ "android_list_crashes",
1350
+ "web_screenshot", "web_get_text",
1351
+ "design_mock_detect", "design_scenario_inventory", "design_ui_geometry", "design_visual_compare",
1352
+ ]);
1353
+
1354
+ // Irreversible on the target device: data loss the caller cannot undo.
1355
+ const DESTRUCTIVE_TOOLS = new Set([
1356
+ "ios_erase_device", "ios_keychain_reset", "ios_reset_permissions",
1357
+ "android_clear_app_data", "android_uninstall_app",
1358
+ // android_install_apk uses `-r` (reinstall), overwriting an installed app;
1359
+ // ios_add_media writes into the simulator photo library. Both mutate device
1360
+ // state, so destructiveHint:false was a stronger claim than the spec default.
1361
+ "android_install_apk", "ios_add_media",
1362
+ ]);
1363
+
1364
+ // Repeating the call lands the device in the same state.
1365
+ const IDEMPOTENT_TOOLS = new Set([
1366
+ "ios_boot_device", "ios_go_home", "ios_terminate_app", "ios_set_appearance", "ios_set_content_size",
1367
+ "ios_set_locale", "ios_set_location", "ios_clear_location", "ios_set_increase_contrast",
1368
+ "ios_status_bar", "ios_grant_permission", "ios_revoke_permission",
1369
+ "android_go_home", "android_stop_app", "android_set_dark_mode", "android_set_font_scale",
1370
+ "android_set_locale", "android_set_location", "android_grant_permission", "android_revoke_permission",
1371
+ "android_set_orientation",
1372
+ "web_close",
1373
+ ]);
1374
+
1375
+ // Web tools reach arbitrary sites; everything else talks to a local simulator,
1376
+ // emulator, archive or build output.
1377
+ const withAnnotations = (tool) => ({
1378
+ ...tool,
1379
+ annotations: {
1380
+ readOnlyHint: READ_ONLY_TOOLS.has(tool.name),
1381
+ destructiveHint: DESTRUCTIVE_TOOLS.has(tool.name),
1382
+ idempotentHint: IDEMPOTENT_TOOLS.has(tool.name),
1383
+ // agent_run_steps dispatches web_* steps, so it reaches the open web too.
1384
+ openWorldHint: tool.name.startsWith("web_") || tool.name === "agent_run_steps",
1385
+ },
1386
+ });
1387
+
1388
+ // -- outputSchema + structuredContent --
1389
+ //
1390
+ // The 2026-07-28 spec makes outputSchema a full JSON Schema 2020-12 and lets
1391
+ // structuredContent be any JSON value. Declaring it turns a payload from "text
1392
+ // the model has to parse" into a contract the host can validate, and it is what
1393
+ // lets a caller rely on a field being there.
1394
+ //
1395
+ // Only tools whose shape was read off the return statement are listed. A schema
1396
+ // that does not match the payload is worse than no schema, because the host
1397
+ // rejects a perfectly good result, so these are deliberately absent until their
1398
+ // shape is pinned:
1399
+ //
1400
+ // ios_xcresult four shapes depending on mode
1401
+ // ios_app_store_audit the object is built in tools/ios-app-store-audit
1402
+ // design_mock_detect, design_scenario_inventory, design_visual_compare,
1403
+ // design_report built in tools/design-check
1404
+ // web_eval returns whatever the evaluated expression produced
1405
+ //
1406
+ // Text content stays on every result, so a host that ignores structuredContent
1407
+ // sees no change.
1408
+
1409
+ const ISSUE_LIST = {
1410
+ type: "array",
1411
+ items: {
1412
+ type: "object",
1413
+ properties: {
1414
+ severity: { type: "string", enum: ["critical", "important", "warning"] },
1415
+ message: { type: "string" },
1416
+ },
1417
+ },
1418
+ };
1419
+
1420
+ const ACCESSIBILITY_AUDIT_SCHEMA = {
1421
+ type: "object",
1422
+ required: ["total_issues", "critical", "important", "warning", "issues"],
1423
+ properties: {
1424
+ scope: { type: "string" },
1425
+ elements_scanned: { type: "integer" },
1426
+ elements_skipped: { type: "integer" },
1427
+ total_issues: { type: "integer" },
1428
+ critical: { type: "integer" },
1429
+ important: { type: "integer" },
1430
+ warning: { type: "integer" },
1431
+ issues: ISSUE_LIST,
1432
+ },
1433
+ };
1434
+
1435
+ const AUDIT_SUMMARY = {
1436
+ type: "object",
1437
+ properties: {
1438
+ critical: { type: "integer" },
1439
+ warnings: { type: "integer" },
1440
+ passed: { type: "integer" },
1441
+ total_checks: { type: "integer" },
1442
+ },
1443
+ };
1444
+
1445
+ const bundleAuditSchema = (pathKey) => ({
1446
+ type: "object",
1447
+ required: ["summary", "verdict", "findings"],
1448
+ properties: {
1449
+ [pathKey]: { type: "string" },
1450
+ app: { type: "string" },
1451
+ summary: AUDIT_SUMMARY,
1452
+ verdict: { type: "string" },
1453
+ findings: {
1454
+ type: "array",
1455
+ items: {
1456
+ type: "object",
1457
+ properties: {
1458
+ status: { type: "string", enum: ["pass", "warning", "critical"] },
1459
+ rule: { type: "string" },
1460
+ message: { type: "string" },
1461
+ },
1462
+ },
1463
+ },
1464
+ },
1465
+ });
1466
+
1467
+ const OUTPUT_SCHEMAS = {
1468
+ ios_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
1469
+ android_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
1470
+
1471
+ ios_archive_audit: bundleAuditSchema("archive"),
1472
+ android_apk_audit: bundleAuditSchema("apk"),
1473
+
1474
+ // ios_list_devices deliberately has NO outputSchema.
1475
+ //
1476
+ // It declared `{ type: "array", items: {...} }`, which is not a legal
1477
+ // outputSchema: MCP's `structuredContent` is an OBJECT, so the schema that
1478
+ // describes it must be `type: "object"`. Claude Code validates the entire
1479
+ // tools/list response, so ONE bad schema on the FIRST tool took down every
1480
+ // tool the server serves (78 at the time):
1481
+ //
1482
+ // Reconnected to dev-toolkit, but fetching tools failed:
1483
+ // path: [tools, 0, outputSchema, type], message: Invalid input: expected "object"
1484
+ //
1485
+ // The server read "connected - tools fetch failed" with every tool unavailable,
1486
+ // and nothing in this repo objected: gate 9 checked that declared schemas come
1487
+ // WITH structuredContent, never that a schema is spec-legal. Gate 9b now does.
1488
+ //
1489
+ // Not fixed by wrapping the payload as `{ devices: [...] }`: this tool's text
1490
+ // output is a JSON array that callers parse, so re-shaping it is a breaking
1491
+ // change bought for structured output nobody requested. Dropping the
1492
+ // declaration restores a legal tools/list and leaves the text byte-identical.
1493
+
1494
+ // Two shapes, both keyed on `passed`: a size mismatch short-circuits before
1495
+ // any pixel comparison, so the diff fields are absent in that one.
1496
+ ios_visual_diff: {
1497
+ type: "object",
1498
+ required: ["passed"],
1499
+ properties: {
1500
+ passed: { type: "boolean" },
1501
+ reason: { type: "string" },
1502
+ baseline_size: { type: "string" },
1503
+ current_size: { type: "string" },
1504
+ diff_pct: { type: "number" },
1505
+ diff_pixels: { type: "integer" },
1506
+ total_pixels: { type: "integer" },
1507
+ threshold: { type: "number" },
1508
+ max_diff_pct: { type: "number" },
1509
+ baseline: { type: "string" },
1510
+ current: { type: "string" },
1511
+ diff_image: { type: "string" },
1512
+ },
1513
+ },
1514
+
1515
+ ios_list_crashes: {
1516
+ type: "object",
1517
+ required: ["dir", "count", "reports"],
1518
+ properties: {
1519
+ dir: { type: "string" },
1520
+ count: { type: "integer" },
1521
+ reports: {
1522
+ type: "array",
1523
+ items: {
1524
+ type: "object",
1525
+ properties: {
1526
+ file: { type: "string" },
1527
+ process: { type: "string" },
1528
+ modified: { type: "string" },
1529
+ size: { type: "integer" },
1530
+ },
1531
+ },
1532
+ },
1533
+ },
1534
+ },
1535
+
1536
+ android_launch_time: {
1537
+ type: "object",
1538
+ required: ["package", "cold_start"],
1539
+ properties: {
1540
+ package: { type: "string" },
1541
+ cold_start: { type: "boolean" },
1542
+ total_time_ms: { type: ["integer", "null"] },
1543
+ wait_time_ms: { type: ["integer", "null"] },
1544
+ raw: { type: "string" },
1545
+ },
1546
+ },
1547
+
1548
+ design_ui_geometry: {
1549
+ type: "object",
1550
+ required: ["platform", "unit", "count", "elements"],
1551
+ properties: {
1552
+ platform: { type: "string", enum: ["ios", "android"] },
1553
+ unit: { type: "string", enum: ["points", "pixels"] },
1554
+ source: { type: "string" },
1555
+ screen: {
1556
+ type: ["object", "null"],
1557
+ properties: { w: { type: "number" }, h: { type: "number" } },
1558
+ },
1559
+ count: { type: "integer" },
1560
+ elements: { type: "array", items: { type: "object" } },
1561
+ },
1562
+ },
1563
+
1564
+ // verdict is the field the pipeline gates read, so it is required and
1565
+ // enumerated rather than a free string.
1566
+ agent_run_steps: {
1567
+ type: "object",
1568
+ required: ["total_steps", "executed", "errors", "verdict", "aborted", "results"],
1569
+ properties: {
1570
+ total_steps: { type: "integer" },
1571
+ executed: { type: "integer" },
1572
+ errors: { type: "integer" },
1573
+ verdict: { type: "string", enum: ["all_ok", "all_failed", "partial"] },
1574
+ aborted: { type: "boolean" },
1575
+ results: {
1576
+ type: "array",
1577
+ items: {
1578
+ type: "object",
1579
+ properties: {
1580
+ tool: { type: "string" },
1581
+ status: { type: "string", enum: ["ok", "error"] },
1582
+ output: { type: "string" },
1583
+ },
1584
+ },
1585
+ },
1586
+ },
1587
+ },
1588
+ };
1589
+
1590
+ const withOutputSchema = (tool) =>
1591
+ OUTPUT_SCHEMAS[tool.name] ? { ...tool, outputSchema: OUTPUT_SCHEMAS[tool.name] } : tool;
1592
+
1593
+ // structuredContent is attached only when the tool declares a schema and its
1594
+ // payload really is JSON. A tool that unexpectedly returned prose still gets
1595
+ // its text content rather than a malformed structured field.
1596
+ function withStructured(name, text) {
1597
+ if (!OUTPUT_SCHEMAS[name]) return { content: [{ type: "text", text }] };
1598
+ try {
1599
+ return { content: [{ type: "text", text }], structuredContent: JSON.parse(text) };
1600
+ } catch {
1601
+ return { content: [{ type: "text", text }] };
1602
+ }
1603
+ }
1604
+
1605
+ const ANNOTATED_TOOLS = ALL_TOOLS.map(withAnnotations).map(withOutputSchema);
1606
+
1607
+ const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
1608
+
1609
+ const server = new Server(
1610
+ { name: "multi-agent-toolkit-mcp", version: PKG.version },
1611
+ { capabilities: { tools: {} } }
1612
+ );
1613
+
1614
+ const designCtx = {
1615
+ run,
1616
+ iosDevice,
1617
+ adbFlag,
1618
+ idb,
1619
+ hasIdb: HAS_IDB,
1620
+ dumperScript: join(__dirname, "ui-tree-dumper.swift"),
1621
+ };
1622
+
1623
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: ANNOTATED_TOOLS }));
1624
+
1625
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1626
+ const { name, arguments: args } = request.params;
1627
+ // Long-running tools (xcodebuild, export, validate, install) report progress
1628
+ // when the client sent a progressToken, and abandon their child process when
1629
+ // the client cancels the request. Everything else ignores the context.
1630
+ const progressToken = request.params._meta?.progressToken;
1631
+ let tick = 0;
1632
+ const ctx = {
1633
+ signal: extra?.signal,
1634
+ progress:
1635
+ progressToken !== undefined && extra?.sendNotification
1636
+ ? (message) => {
1637
+ extra
1638
+ .sendNotification({
1639
+ method: "notifications/progress",
1640
+ params: { progressToken, progress: ++tick, message },
1641
+ })
1642
+ .catch(() => {});
1643
+ }
1644
+ : null,
1645
+ };
1646
+ try {
1647
+ // Enforce the declared input schema before any handler sees the arguments:
1648
+ // a type mismatch (string where a number is declared) is rejected here
1649
+ // instead of reaching a bare shell interpolation downstream.
1650
+ const schemaError = validateArgs(name, args || {});
1651
+ if (schemaError) {
1652
+ return { content: [{ type: "text", text: `${ERROR_PREFIX}invalid arguments: ${schemaError}` }], isError: true };
1653
+ }
1654
+ let result;
1655
+ if (name.startsWith("ios_")) result = await handleIOS(name, args || {}, ctx);
1656
+ else if (name.startsWith("android_")) result = await handleAndroid(name, args || {}, ctx);
1657
+ else if (name.startsWith("web_")) result = await handleWeb(name, args || {});
1658
+ else if (name.startsWith("agent_")) result = await handleAgent(name, args || {});
1659
+ else if (name.startsWith("design_")) result = await handleDesign(name, args || {}, designCtx);
1660
+ else return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
1661
+ // Same reason as dispatchStep: a handler returns null only from its
1662
+ // `default:` arm, so an unrecognised name that happens to carry a known
1663
+ // family prefix used to answer the host with the literal text "null" and no
1664
+ // isError flag - a failure that reads as success.
1665
+ if (result === null || result === undefined) {
1666
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
1667
+ }
1668
+ if (typeof result === "object" && result?.type === "image") {
1669
+ return {
1670
+ content: [
1671
+ { type: "image", data: result.data, mimeType: result.mimeType },
1672
+ { type: "text", text: `Screenshot: ${result.path}` },
1673
+ ...(result.path ? [{ type: "resource_link", uri: `file://${result.path}`, name: basename(result.path), mimeType: result.mimeType }] : []),
1674
+ ],
1675
+ };
1676
+ }
1677
+ // File-returning tools (screenshot with `path`, build logs, diff images,
1678
+ // recordings) carry a resource_link per file alongside the text, per the
1679
+ // 2025-06-18 MCP spec, so a host can open the artifact without parsing
1680
+ // the path out of prose. structuredContent still applies when the text
1681
+ // is JSON and the tool declares a schema.
1682
+ if (typeof result === "object" && result?.type === "file") {
1683
+ const envelope = withStructured(name, result.text);
1684
+ for (const f of result.files) {
1685
+ envelope.content.push({ type: "resource_link", uri: `file://${f.path}`, name: f.name });
1686
+ }
1687
+ return envelope;
1688
+ }
1689
+ // The batch report is always the payload; whether the envelope is an error
1690
+ // is decided inside handleAgent (abort yes, opted-into failures no).
1691
+ if (typeof result === "object" && result?.type === "batch") {
1692
+ // The report is JSON either way, so it carries structuredContent even on
1693
+ // the error envelope - a caller reading `verdict` needs it most when the
1694
+ // batch failed.
1695
+ const envelope = withStructured(name, result.text);
1696
+ return result.isError ? { ...envelope, isError: true } : envelope;
1697
+ }
1698
+ // A command failure must surface as an MCP error result, not as text that
1699
+ // reads like success to the host and to the pipeline gates.
1700
+ if (isFailure(result)) {
1701
+ return { content: [{ type: "text", text: String(result) }], isError: true };
1702
+ }
1703
+ return withStructured(name, String(result));
1704
+ } catch (e) {
1705
+ return {
1706
+ content: [{ type: "text", text: `${ERROR_PREFIX}${truncateError(e.message)}` }],
1707
+ isError: true,
1708
+ };
1709
+ }
1710
+ });
1711
+
1712
+ // Recorder children must not outlive the server: `simctl io recordVideo` has no
1713
+ // time limit, so an orphan keeps writing until the disk fills. SIGINT is the
1714
+ // finalizing stop for both recorders, so the files stay playable.
1715
+ function stopRecorders() {
1716
+ for (const rec of RECORDINGS.values()) {
1717
+ try { rec.child.kill("SIGINT"); } catch {}
1718
+ }
1719
+ }
1720
+
1721
+ process.on("SIGTERM", async () => { stopRecorders(); await closeBrowser(); process.exit(0); });
1722
+ process.on("SIGINT", async () => { stopRecorders(); await closeBrowser(); process.exit(0); });
1723
+
1724
+ const transport = new StdioServerTransport();
1725
+ await server.connect(transport);