@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.9.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.
package/CHANGELOG.md CHANGED
@@ -15,6 +15,112 @@ Releases before this file exists are recorded in the git tags and commit history
15
15
 
16
16
  ---
17
17
 
18
+ ## 3.9.0
19
+
20
+ ### Changed
21
+
22
+ - **Inline screenshots are compact.** `ios_screenshot` and `android_screenshot`
23
+ without `path` returned the raw PNG as base64: on a 3x simulator that is
24
+ 2.9 MB per capture, 3.86 million characters on the wire, and the model reads
25
+ every one of them. That was the sluggishness people felt in an agent loop,
26
+ not the capture itself (0.5 s). The inline result is now an 800 px JPEG at
27
+ quality 80 (63 KB, 84 K characters), resized through `sips` in 0.1 s; the
28
+ full-resolution PNG still lands on disk and its path is in the text. New
29
+ optional inputs `max_width`, `format` and `quality` are the escape hatch
30
+ (`format: "png"` with `max_width: 0` returns the original bytes). `path`
31
+ behaviour is unchanged: full-resolution PNG, nothing inline, which is what
32
+ design-check and visual diff read.
33
+ - **The UI-tree dumper runs compiled.** `swift ui-tree-dumper.swift` was
34
+ re-interpreted on every call, about a second each. It is now compiled once
35
+ with `swiftc -O` into the user cache, keyed by the source hash and the
36
+ toolchain version, and runs in 55 ms. No `swiftc`, or a failed compile, falls
37
+ back to the interpreted script and stays there for the process lifetime.
38
+ `ios_get_ui_tree`, `ios_accessibility_audit` and `design_ui_geometry` share
39
+ the path.
40
+
41
+ ### Note
42
+
43
+ The README gained a Performance section: read the tree before taking a
44
+ picture, batch a scripted sequence through `agent_run_steps`, write bulk
45
+ captures to files and look only at the ones that changed. The pipeline's
46
+ `/multi-agent:test` flow (16.19.0) now does exactly that.
47
+
48
+ ## 3.8.0
49
+
50
+ ### Added
51
+
52
+ - **`ios_swipe` declares `duration_ms`.** The handler had read it since the
53
+ tool existed; the schema never said so, so no host ever offered it.
54
+ - **Gate 9c and gate 14b.** 9c validates every probed tool's structured result
55
+ against its own declared `outputSchema` with ajv. 14b scans every shell
56
+ template for a `${args.x}` interpolation that no sanitizer wraps, the shape
57
+ gate 14 could not see because it only knew the double-quoted form.
58
+ - **The work directory is pruned.** `.xcresult` bundles, build logs, push
59
+ payloads and audit dumps accumulated under the screenshot directory with
60
+ nothing to remove them. It now keeps the newest 200 entries for 7 days, the
61
+ offload directory's own rule, and never removes a path a caller was just
62
+ promised.
63
+
64
+ ### Fixed
65
+
66
+ - **`agent_run_steps` skipped schema validation.** Batched steps went straight
67
+ to the handlers, so an enum or type guard that lived only in the schema was
68
+ void inside a batch. Every step is validated now and a failed validation is a
69
+ step error. `ios_get_app_container` also passes its `container` value through
70
+ `token()`, so the enum is not the only thing standing between an argument and
71
+ the shell.
72
+ - **`android_type_text` and `android_open_url` were command injection on the
73
+ device.** `shq()` protected the host shell, and `adb shell` hands the string
74
+ to the device shell unquoted, where it is parsed again. Both are quoted twice
75
+ now; a test evaluates the composed argument through `/bin/sh` and checks the
76
+ original bytes come back.
77
+ - **`android_meminfo mode=diff` always threw.** The snapshot emitted `pss_kb`
78
+ and the diff read `pss`, so the tool's own output was never a valid baseline.
79
+ The diff accepts both shapes.
80
+ - **Three `outputSchema`s contradicted their payloads,** which a conforming
81
+ client rejects: audit findings carry `status: "info"`, `ios_visual_diff`
82
+ returns `diff_image: null` on a clean compare, `android_launch_time` returns
83
+ `cold_start: null` below Android 10. The schemas now allow what the handlers
84
+ emit, and gate 9c keeps them honest.
85
+ - **`ios_app_store_audit` with an unknown rule id reported PASS** after running
86
+ nothing. Unknown ids are an error that lists the valid ones, and an empty
87
+ selection is never a pass.
88
+ - **`ios_accessibility_audit_deep` and `ios_leaks` ran through `execSync`**
89
+ with the default 1 MB buffer, so a normal `xcodebuild test` log killed the
90
+ child and the tool blamed the build; the event loop was blocked for the whole
91
+ run. Both use the async spawn path with a 64 MB cap and a timeout.
92
+ - **`android_apk_audit`'s `aapt2 || aapt` fallback never fired,** because a
93
+ failed `run()` returns a truthy error string; with aapt2 absent the report
94
+ said "Not debuggable - OK" and on Linux `apk_size` was `NaN MB` with status
95
+ pass. Failures are detected, the missing-tool branch is reachable, size comes
96
+ from `statSync`.
97
+ - **Capture tools returned a stale file as a fresh capture.** `ios_screenshot`,
98
+ `android_screenshot` and `android_get_ui_tree` decided success by whether the
99
+ path existed, so a second run against a dropped device returned the previous
100
+ image with the real error replaced by a generic one. The subprocess result is
101
+ checked first and a pre-existing file is discarded before capturing.
102
+ - **`ios_set_locale` reported success unconditionally.** Each of its three
103
+ steps is checked.
104
+ - **`ios_xcodebuild action:"test"` used the generic simulator destination,**
105
+ which xcodebuild refuses for testing. Test defaults to the booted simulator
106
+ or fails with a clear message.
107
+ - **`android_launch_time` and `android_launch_app` disagreed on `activity`.**
108
+ Both build the component the same way and describe the field identically.
109
+ - **Pure-JS iOS tools were refused without Xcode.** `ios_visual_diff`,
110
+ `ios_list_crashes` and `ios_xcresult` need no xcrun and run on a Linux box.
111
+ - **`design_report` leaked a Chromium on a failed PDF render.** The browser is
112
+ closed in `finally`.
113
+ - **`ios_archive_audit` reported an unsigned bundle as signed,** because the
114
+ `codesign` failure text never reached the check; and its size used the
115
+ macOS-only `stat -f`.
116
+ - **Smaller ones.** `ios_leaks` accepts a numeric-string pid and matches the
117
+ bundle id as a whole token instead of a regex prefix; the UI-tree dumper's
118
+ remedy text reaches the caller; `ensureBrowser` closes a half-launched browser
119
+ and honours a changed engine; `exportIpa` picks the export it just made rather
120
+ than the first `.ipa` in the directory, and the export options plist is
121
+ XML-escaped; `agent_run_steps` declares the `result` / `error` fields it
122
+ emits.
123
+
18
124
  ## 3.7.1
19
125
 
20
126
  ### Fixed
package/README.md CHANGED
@@ -293,6 +293,13 @@ Mock-mode vs Figma design audit. Generic and platform-agnostic - the heavy orche
293
293
  - **App Store screenshots** - Clean status bar + automated navigation
294
294
  - **Regression testing** - Tap through flows, verify behavior
295
295
 
296
+ ### Performance
297
+
298
+ - **Inline captures are small.** `ios_screenshot` / `android_screenshot` without `path` return an 800 px JPEG, about 60 KB instead of the ~3 MB full-resolution PNG a simulator produces (which is ~4 MB as base64 on every capture). `max_width`, `format` and `quality` tune it; `format: "png"` with `max_width: 0` returns the original bytes. The full-resolution PNG stays on disk, and `path` writes it where you say.
299
+ - **The iOS UI-tree dumper is compiled once.** `ui-tree-dumper.swift` is built with `swiftc -O` on first use and cached per source and toolchain, so a call drops from ~1 s to ~0.05 s. Without `swiftc` it runs interpreted as before.
300
+ - **Batch a scripted sequence.** `agent_run_steps` runs tap, type, wait and screenshot steps in one round trip instead of one call per step.
301
+ - **Read the tree when text is enough.** `ios_get_ui_tree` / `android_get_ui_tree` cost a fraction of a screenshot and return tappable coordinates.
302
+
296
303
  ### How It Works
297
304
 
298
305
  ```mermaid
package/README.tr.md CHANGED
@@ -292,6 +292,13 @@ Mock-mode vs Figma tasarım denetimi. Jenerik ve platform-bağımsız - ağır o
292
292
  - **App Store screenshot'ları** - Temiz status bar + otomatik navigasyon
293
293
  - **Regresyon testi** - Akışlarda tap'le gez, davranışı doğrula
294
294
 
295
+ ### Performans
296
+
297
+ - **Inline capture'lar küçük.** `path` verilmeyen `ios_screenshot` / `android_screenshot` 800 px JPEG döner: simülatörün ürettiği ~3 MB tam çözünürlüklü PNG (her capture'da base64 olarak ~4 MB) yerine yaklaşık 60 KB. `max_width`, `format` ve `quality` ile ayarlanır; `format: "png"` + `max_width: 0` orijinal byte'ları döner. Tam çözünürlüklü PNG diskte kalır, `path` onu istediğin yere yazar.
298
+ - **iOS UI-tree dumper bir kez derlenir.** `ui-tree-dumper.swift` ilk kullanımda `swiftc -O` ile derlenir, kaynak + toolchain başına önbelleğe alınır; bir çağrı ~1 s'den ~0.05 s'ye iner. `swiftc` yoksa eskisi gibi yorumlanarak çalışır.
299
+ - **Senaryoyu tek çağrıda koş.** `agent_run_steps` tap, type, wait ve screenshot adımlarını adım başına bir çağrı yerine tek round trip'te çalıştırır.
300
+ - **Metin yetiyorsa tree oku.** `ios_get_ui_tree` / `android_get_ui_tree` bir screenshot'ın çok küçük bir maliyetiyle tap'lenebilir koordinatlar döner.
301
+
295
302
  ### Nasıl Çalışır
296
303
 
297
304
  ```mermaid
package/index.js CHANGED
@@ -16,9 +16,10 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
17
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
18
18
  import { execSync, exec, spawn } from "child_process";
19
- import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs";
19
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync, renameSync } from "fs";
20
20
  import { join, dirname, basename, isAbsolute, resolve, sep } from "path";
21
- import { homedir } from "os";
21
+ import { homedir, tmpdir } from "os";
22
+ import { createHash } from "crypto";
22
23
  import { fileURLToPath } from "url";
23
24
  import { runAudit as runAppStoreAudit } from "./tools/ios-app-store-audit/index.js";
24
25
  import {
@@ -37,11 +38,16 @@ import {
37
38
  offloadLargeText,
38
39
  queryOffloadedOutput,
39
40
  offloadedErrorSummary,
41
+ pruneWorkDir,
40
42
  } from "./tools/offload/index.js";
41
43
 
42
44
  const __dirname = dirname(fileURLToPath(import.meta.url));
43
45
  const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
44
46
  if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true });
47
+ // Same retention as the offload dir: this directory collects screenshots, UI
48
+ // dumps, push payloads, build logs and .xcresult bundles, and nothing else
49
+ // ever removed them.
50
+ pruneWorkDir(SCREENSHOT_DIR);
45
51
 
46
52
  // ── Helpers ──
47
53
 
@@ -80,6 +86,27 @@ function run(cmd, opts = {}) {
80
86
  }
81
87
  }
82
88
 
89
+ // run() reports failure by returning a truthy marker string, so `run(a) || run(b)`
90
+ // never falls back. This variant returns null on failure for exactly that shape.
91
+ function runOrNull(cmd, opts = {}) {
92
+ const out = run(cmd, opts);
93
+ return isFailure(out) ? null : out;
94
+ }
95
+
96
+ // A capture that must be fresh: a file left by an earlier run would otherwise
97
+ // pass the existence check and be returned as this run's result.
98
+ function discardStale(path) {
99
+ try { if (statSync(path).isFile()) unlinkSync(path); } catch {}
100
+ }
101
+
102
+ // The files a result promises to the caller; retention never removes them.
103
+ function promisedPaths(result) {
104
+ if (!result || typeof result !== "object") return [];
105
+ if (result.type === "file") return result.files.map((f) => f.path);
106
+ if (result.type === "image") return [result.path, result.source].filter(Boolean);
107
+ return [];
108
+ }
109
+
83
110
  /**
84
111
  * Run a command whose non-zero exit is a RESULT, not a failure.
85
112
  *
@@ -215,6 +242,64 @@ function shq(value) {
215
242
  return `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
216
243
  }
217
244
 
245
+ // `adb shell <cmd>` is parsed twice: once by the host shell, again by the
246
+ // device shell. One shq() layer survives only the first, so the device shell
247
+ // saw `;`, `&` and `$()` in a text or URL as syntax. The inner layer is what
248
+ // the device shell receives.
249
+ function remoteShq(value) {
250
+ return shq(shq(value));
251
+ }
252
+
253
+ // The ui-tree dumper prints its failure as a JSON object on stdout (with the
254
+ // remedy) and exits 1; a plain exit-status error keeps only stderr.
255
+ function dumperErrorOf(text) {
256
+ const s = String(text ?? "");
257
+ try {
258
+ const parsed = JSON.parse(s);
259
+ return parsed && typeof parsed.error === "string" ? parsed.error : null;
260
+ } catch {}
261
+ const m = s.match(/"error"\s*:\s*("(?:[^"\\]|\\.)*")/);
262
+ if (!m) return null;
263
+ try { return JSON.parse(m[1]); } catch { return null; }
264
+ }
265
+
266
+ const DUMPER_SOURCE = join(__dirname, "ui-tree-dumper.swift");
267
+
268
+ // `swift <script>` re-parses and JITs the dumper on every call (0.9-1.2 s); the
269
+ // -O binary answers in 0.05 s. Compiled on first use, never at server start,
270
+ // and keyed by source + toolchain so an edit or an Xcode update rebuilds it.
271
+ // The directory comes from the environment and the home directory only, never
272
+ // from a tool argument, the same rule the offload dir follows.
273
+ function dumperCacheDir() {
274
+ const xdg = process.env.XDG_CACHE_HOME;
275
+ const base = xdg && isAbsolute(xdg) ? xdg
276
+ : process.platform === "darwin" ? join(homedir(), "Library", "Caches")
277
+ : join(homedir(), ".cache");
278
+ for (const dir of [join(base, "multi-agent-toolkit-mcp"), join(tmpdir(), "multi-agent-toolkit-mcp")]) {
279
+ try { mkdirSync(dir, { recursive: true }); return dir; } catch {}
280
+ }
281
+ return null;
282
+ }
283
+
284
+ let dumperPrefix = null;
285
+ function dumperCommand() {
286
+ if (dumperPrefix) return dumperPrefix;
287
+ dumperPrefix = `swift ${shq(DUMPER_SOURCE)}`;
288
+ const version = runOrNull("swiftc --version", { timeout: 15000 });
289
+ const dir = version ? dumperCacheDir() : null;
290
+ if (!dir) return dumperPrefix;
291
+ const key = createHash("sha256").update(readFileSync(DUMPER_SOURCE)).update(version).digest("hex").slice(0, 16);
292
+ const binary = join(dir, `ui-tree-dumper-${key}`);
293
+ if (!existsSync(binary)) {
294
+ const staging = `${binary}.${process.pid}`;
295
+ const built = run(`swiftc -O -o ${shq(staging)} ${shq(DUMPER_SOURCE)}`, { timeout: 120000 });
296
+ if (isFailure(built) || !existsSync(staging)) return dumperPrefix;
297
+ try { renameSync(staging, binary); } catch { return dumperPrefix; }
298
+ }
299
+ dumperPrefix = shq(binary);
300
+ return dumperPrefix;
301
+ }
302
+
218
303
  // Numeric coordinates / scales: reject anything that is not a plain number so it
219
304
  // can be interpolated bare.
220
305
  function num(value, label) {
@@ -249,6 +334,39 @@ function hasCommand(cmd) {
249
334
  }
250
335
 
251
336
  const HAS_XCRUN = hasCommand("xcrun");
337
+ const HAS_SIPS = hasCommand("sips");
338
+
339
+ // The inline image goes to the model on every capture, and a simulator PNG is
340
+ // ~2.9 MB (3.9 MB as base64). sips turns it into an 800 px JPEG of ~60 KB in
341
+ // 0.12 s, so that is the default; the full-resolution file stays on disk.
342
+ function captureOptions(args) {
343
+ const format = args.format === "png" ? "png" : "jpeg";
344
+ const maxWidth = num(args.max_width ?? 800, "max_width");
345
+ const quality = num(args.quality ?? 80, "quality");
346
+ if (!Number.isInteger(maxWidth) || maxWidth < 0) throw new Error(`Invalid max_width: ${args.max_width} (0 or a positive pixel count)`);
347
+ if (!Number.isInteger(quality) || quality < 1 || quality > 100) throw new Error(`Invalid quality: ${args.quality} (1-100)`);
348
+ return { format, maxWidth, quality };
349
+ }
350
+
351
+ function inlineCapture(f, { format, maxWidth, quality }) {
352
+ const asIs = (note) => ({ type: "image", data: readFileSync(f).toString("base64"), mimeType: "image/png", path: f, note });
353
+ if (format === "png" && maxWidth === 0) return asIs("");
354
+ if (!HAS_SIPS) return asIs(" (sips unavailable, full-resolution PNG returned)");
355
+ const out = f.replace(/\.png$/, "") + (format === "jpeg" ? ".jpg" : `_${maxWidth}.png`);
356
+ discardStale(out);
357
+ const scale = maxWidth ? ` -Z ${maxWidth}` : "";
358
+ const options = format === "jpeg" ? ` -s formatOptions ${quality}` : "";
359
+ const converted = run(`sips${scale} -s format ${format}${options} ${shq(f)} --out ${shq(out)}`);
360
+ if (isFailure(converted) || !existsSync(out)) return asIs(" (sips failed, full-resolution PNG returned)");
361
+ return {
362
+ type: "image",
363
+ data: readFileSync(out).toString("base64"),
364
+ mimeType: format === "jpeg" ? "image/jpeg" : "image/png",
365
+ path: out,
366
+ source: f,
367
+ note: ` (full-resolution PNG: ${f})`,
368
+ };
369
+ }
252
370
 
253
371
  // idb (Facebook) is the real iOS Simulator UI driver - `simctl io` has NO tap/swipe/type
254
372
  // operation, so tap/swipe/type/geometry route through idb. Resolve its binary + set a PATH
@@ -325,9 +443,9 @@ function iosDevice(id) {
325
443
  const IOS_TOOLS = [
326
444
  { name: "ios_list_devices", description: "List all available iOS simulators and their state", inputSchema: { type: "object", properties: {} } },
327
445
  { 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"] } },
328
- { 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." } } } },
446
+ { name: "ios_screenshot", description: "Capture an iOS simulator screenshot. Without `path` the image comes back inline, downscaled to 800 px on the longest side as a quality-80 JPEG (~60 KB instead of the ~3 MB full-resolution PNG, which is ~4 MB as base64 on every capture); `format: \"png\"` with `max_width: 0` returns the original PNG bytes. The full-resolution PNG stays on disk either way. Pass `path` to write the full-resolution PNG there and return only its location, which is what a run taking many captures needs - and what design-check and ios_visual_diff read. 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." }, max_width: { type: "integer", description: "Longest side of the inline image in pixels (default 800). 0 keeps the original size. Ignored with `path`." }, format: { type: "string", enum: ["jpeg", "png"], description: "Inline image format (default jpeg). Ignored with `path`." }, quality: { type: "integer", description: "JPEG quality 1-100 (default 80). Ignored for png and with `path`." } } } },
329
447
  { 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"] } },
330
- { 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"] } },
448
+ { name: "ios_swipe", description: "Swipe on iOS simulator", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, duration_ms: { type: "number", description: "Swipe duration in milliseconds" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
331
449
  { name: "ios_type_text", description: "Type text on iOS simulator", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
332
450
  { 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"] } },
333
451
  { name: "ios_terminate_app", description: "Terminate iOS app", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
@@ -357,7 +475,7 @@ const IOS_TOOLS = [
357
475
  { 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"] } },
358
476
  { 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: [] } },
359
477
  { 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"] } },
360
- { 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"] } },
478
+ { 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; for action test, the booted simulator (xcodebuild refuses a generic destination for test)" }, 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"] } },
361
479
  { 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), metrics (XCTMetric performance results as JSON). 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", "metrics"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" }, test_id: { type: "string", description: "mode=metrics only: scope to one test case or suite instead of every measured test" } }, required: ["id"] } },
362
480
  { 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"] } },
363
481
  { name: "ios_leaks", description: "Look for leaked memory in a running simulator (or host) process with /usr/bin/leaks. mode=snapshot reports the current leak count and bytes; mode=diff reports only leaks new since a saved memory graph, which is the shape a regression gate wants. Reports measurable:false when the target lacks get-task-allow rather than reporting it as clean - leaks exits 0 in that case, so an unmeasurable target and a clean one are indistinguishable by exit status. Debug builds are debuggable; Apple-signed apps are not.", inputSchema: { type: "object", properties: { pid: { type: "number", description: "Process id. Either this or bundle_id." }, bundle_id: { type: "string", description: "Bundle id of an app running on the booted simulator; its pid is resolved for you." }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_graph: { type: "string", description: "mode=diff: path to the memory graph saved by an earlier call" }, output_graph: { type: "string", description: "Save a memory graph here to use as a later baseline" } }, required: [] } },
@@ -365,8 +483,12 @@ const IOS_TOOLS = [
365
483
  { 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)" } } } },
366
484
  ];
367
485
 
486
+ // Pure-JS members of the ios_* family: comparing two PNGs or listing crash
487
+ // reports needs no simulator, so a missing Xcode must not refuse them.
488
+ const IOS_TOOLS_WITHOUT_XCRUN = new Set(["ios_visual_diff", "ios_list_crashes", "ios_xcresult"]);
489
+
368
490
  async function handleIOS(name, args, ctx = {}) {
369
- if (!HAS_XCRUN) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
491
+ if (!HAS_XCRUN && !IOS_TOOLS_WITHOUT_XCRUN.has(name)) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
370
492
  const did = (n) => { try { return iosDevice(n); } catch (e) { return null; } };
371
493
 
372
494
  switch (name) {
@@ -393,11 +515,13 @@ async function handleIOS(name, args, ctx = {}) {
393
515
  const parent = dirname(f);
394
516
  if (!existsSync(parent)) return `ERROR: directory does not exist: ${parent}`;
395
517
  }
396
- run(`xcrun simctl io ${d} screenshot ${shq(f)}`);
518
+ const inline = args.path ? null : captureOptions(args);
519
+ discardStale(f);
520
+ const shot = run(`xcrun simctl io ${d} screenshot ${shq(f)}`);
521
+ if (isFailure(shot)) return shot;
397
522
  if (!existsSync(f)) return "ERROR: Screenshot failed";
398
523
  if (args.path) return fileResult(`Screenshot written: ${f}`, f);
399
- const buf = readFileSync(f);
400
- return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
524
+ return inlineCapture(f, inline);
401
525
  }
402
526
  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})`; }
403
527
  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"; }
@@ -416,7 +540,19 @@ async function handleIOS(name, args, ctx = {}) {
416
540
  }
417
541
  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}`; }
418
542
  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}`; }
419
- 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}`; }
543
+ case "ios_set_locale": {
544
+ const d = iosDevice(args.device_id);
545
+ const bid = sanitizeId(args.bundle_id);
546
+ const written = run(`xcrun simctl spawn ${d} defaults write ${bid} AppleLanguages -array ${shq(args.language)}`);
547
+ if (isFailure(written)) return written;
548
+ // An app that is not running has nothing to terminate; that is not a
549
+ // failure of the locale change, which applies at the next launch.
550
+ const stopped = run(`xcrun simctl terminate ${d} ${bid}`);
551
+ if (isFailure(stopped) && !/No such process|found nothing to terminate|not running/i.test(stopped)) return stopped;
552
+ const launched = run(`xcrun simctl launch ${d} ${bid}`);
553
+ if (isFailure(launched)) return launched;
554
+ return `Locale: ${args.language} (${bid} relaunched)`;
555
+ }
420
556
  case "ios_open_url": { const d = iosDevice(args.device_id); return run(`xcrun simctl openurl ${d} ${shq(args.url)}`) || `Opened: ${args.url}`; }
421
557
  case "ios_status_bar": {
422
558
  const d = iosDevice(args.device_id);
@@ -481,11 +617,10 @@ async function handleIOS(name, args, ctx = {}) {
481
617
  }
482
618
  case "ios_add_media": { const d = iosDevice(args.device_id); return run(`xcrun simctl addmedia ${d} ${shq(args.file_path)}`) || "Media added"; }
483
619
  case "ios_keychain_reset": { const d = iosDevice(args.device_id); return run(`xcrun simctl keychain ${d} reset`) || "Keychain reset"; }
484
- 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"}`); }
620
+ case "ios_get_app_container": { const d = iosDevice(args.device_id); return run(`xcrun simctl get_app_container ${d} ${sanitizeId(args.bundle_id)} ${token(args.container || "app", "container")}`); }
485
621
  case "ios_erase_device": { const d = iosDevice(args.device_id); return run(`xcrun simctl erase ${d}`) || "Device erased"; }
486
622
  case "ios_get_ui_tree": {
487
- const script = join(__dirname, "ui-tree-dumper.swift");
488
- if (!existsSync(script)) return "ui-tree-dumper.swift not found";
623
+ if (!existsSync(DUMPER_SOURCE)) return "ui-tree-dumper.swift not found";
489
624
  // Path check first, matching the Android sibling: the AX dump takes up to
490
625
  // 15s, too expensive to spend before rejecting a bad destination.
491
626
  if (args.path) {
@@ -493,16 +628,18 @@ async function handleIOS(name, args, ctx = {}) {
493
628
  if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
494
629
  }
495
630
  const depth = num(args.max_depth ?? 10, "max_depth");
496
- const tree = run(`swift ${shq(script)} ${depth}`, { timeout: 15000 });
497
- if (!args.path || isFailure(tree)) return tree;
631
+ const tree = runCapture(`${dumperCommand()} ${depth}`, { timeout: 15000 });
632
+ if (isFailure(tree)) return tree;
633
+ const dumperError = dumperErrorOf(tree);
634
+ if (dumperError) return `${ERROR_PREFIX}${dumperError}`;
635
+ if (!args.path) return tree;
498
636
  writeFileSync(String(args.path), tree);
499
637
  return fileResult(`UI tree written: ${args.path}`, String(args.path));
500
638
  }
501
639
  case "ios_accessibility_audit": {
502
- const script = join(__dirname, "ui-tree-dumper.swift");
503
- if (!existsSync(script)) return "ui-tree-dumper.swift not found";
640
+ if (!existsSync(DUMPER_SOURCE)) return "ui-tree-dumper.swift not found";
504
641
  const depth = num(args.max_depth ?? 10, "max_depth");
505
- const treeJson = run(`swift ${shq(script)} ${depth}`, { timeout: 30000 });
642
+ const treeJson = run(`${dumperCommand()} ${depth}`, { timeout: 30000 });
506
643
  let tree = null;
507
644
  try {
508
645
  tree = JSON.parse(treeJson);
@@ -553,14 +690,14 @@ async function handleIOS(name, args, ctx = {}) {
553
690
  const binaryName = appName.replace(".app", "");
554
691
  const binaryPath = `${appDir}/${binaryName}`;
555
692
  // 2. Binary size
556
- const sizeOut = run(`stat -f "%z" ${shq(binaryPath)} 2>/dev/null`);
557
- 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" }); }
693
+ try { const sizeMB = statSync(binaryPath).size / 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" }); } catch {}
558
694
  // 3. Debug tool leak check
559
695
  const debugSymbols = run(`nm ${shq(binaryPath)} 2>/dev/null | grep -iE "FLEX|Reveal|Stetho|Flipper|CocoaDebug|Pulse" | head -10`);
560
696
  findings.push({ check: "debug_tools", status: debugSymbols ? "critical" : "pass", detail: debugSymbols ? `Debug tools found in binary: ${debugSymbols}` : "No debug tools detected" });
561
- // 4. Code signing
562
- const codesign = run(`codesign -dvv ${shq(appDir)} 2>&1`);
563
- const hasSignature = codesign && !codesign.includes("not signed");
697
+ // 4. Code signing. codesign exits 1 on an unsigned bundle, and run()'s
698
+ // failure marker does not carry its "not signed" line; runCapture does.
699
+ const codesign = runCapture(`codesign -dvv ${shq(appDir)} 2>&1`);
700
+ const hasSignature = codesign && !isFailure(codesign) && !codesign.includes("not signed");
564
701
  findings.push({ check: "code_signing", status: hasSignature ? "pass" : "critical", detail: hasSignature ? "Signed" : "NOT SIGNED - will be rejected" });
565
702
  // 5. Provisioning profile
566
703
  const provProfile = run(`ls ${shq(appDir + "/embedded.mobileprovision")} 2>/dev/null`);
@@ -689,7 +826,16 @@ async function handleIOS(name, args, ctx = {}) {
689
826
  if (args.project && args.workspace) return "ERROR: pass project OR workspace, not both";
690
827
  const action = args.action || "build";
691
828
  const config = args.configuration || "Release";
692
- const dest = args.destination || "generic/platform=iOS Simulator";
829
+ let dest = args.destination;
830
+ if (!dest) {
831
+ if (action === "test") {
832
+ const d = did();
833
+ if (!d) return `${ERROR_PREFIX}action "test" needs a booted simulator: xcodebuild refuses generic/platform=iOS Simulator for test. Boot one with ios_boot_device, or pass destination explicitly.`;
834
+ dest = `platform=iOS Simulator,id=${d}`;
835
+ } else {
836
+ dest = "generic/platform=iOS Simulator";
837
+ }
838
+ }
693
839
  const id = `xcresult-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
694
840
  const xcresultPath = join(SCREENSHOT_DIR, `${id}.xcresult`);
695
841
  const logPath = join(SCREENSHOT_DIR, `${id}.log`);
@@ -804,13 +950,21 @@ async function handleIOS(name, args, ctx = {}) {
804
950
  return diffImagePath ? fileResult(report, diffImagePath) : report;
805
951
  }
806
952
  case "ios_leaks": {
807
- let pid = Number.isInteger(args.pid) ? args.pid : null;
953
+ // The schema validator accepts a numeric string for `pid`, so coerce
954
+ // rather than re-check the raw type here.
955
+ const pidNum = args.pid === undefined || args.pid === null || args.pid === "" ? NaN : Number(args.pid);
956
+ let pid = Number.isInteger(pidNum) && pidNum > 0 ? pidNum : null;
808
957
  if (!pid && args.bundle_id) {
809
958
  const d = iosDevice(args.device_id);
810
- const out = run(`xcrun simctl spawn ${d} launchctl list 2>/dev/null | grep ${shq(sanitizeId(args.bundle_id))}`);
811
- const m = out && out.match(/^(\d+)\s/m);
812
- if (!m) return `ERROR: no running process for ${args.bundle_id} on the booted simulator; launch it first`;
813
- pid = parseInt(m[1], 10);
959
+ const id = sanitizeId(args.bundle_id);
960
+ const out = run(`xcrun simctl spawn ${d} launchctl list 2>/dev/null`);
961
+ if (isFailure(out)) return out;
962
+ // Whole-token match: a substring grep of com.x also matched com.xy, and
963
+ // the dots were regex wildcards.
964
+ const label = new RegExp(`(^|[^A-Za-z0-9._-])${id.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}($|[^A-Za-z0-9._-])`);
965
+ const line = out.split("\n").find((l) => /^\d+\s/.test(l) && label.test(l));
966
+ if (!line) return `ERROR: no running process for ${args.bundle_id} on the booted simulator; launch it first`;
967
+ pid = parseInt(line, 10);
814
968
  }
815
969
  if (!pid) return "ERROR: pass pid or bundle_id";
816
970
 
@@ -823,8 +977,12 @@ async function handleIOS(name, args, ctx = {}) {
823
977
  const diffArg = mode === "diff" ? ` --diffFrom=${shq(args.baseline_graph)}` : "";
824
978
  // leaks exits 1 when it FINDS leaks, so a non-zero status is a result and
825
979
  // not a failure. Everything below is decided from the parsed output.
826
- const raw = runCapture(`leaks ${pid}${diffArg}${graphOut} 2>&1`, { timeout: 120000 });
827
- const parsed = parseLeaksOutput(raw);
980
+ // spawnCollect rather than execSync: a two-minute scan must not block the
981
+ // event loop, and its output is capped instead of killing the child.
982
+ const res = await spawnCollect(`leaks ${pid}${diffArg}${graphOut} 2>&1`, { timeout: 120000, signal: ctx.signal });
983
+ if (res.timedOut) return `${ERROR_PREFIX}leaks did not finish within 120s for pid ${pid}`;
984
+ if (res.aborted) return `${ERROR_PREFIX}leaks cancelled`;
985
+ const parsed = parseLeaksOutput(res.output);
828
986
  return JSON.stringify({
829
987
  pid,
830
988
  mode,
@@ -847,14 +1005,28 @@ async function handleIOS(name, args, ctx = {}) {
847
1005
  const only = args.test_identifier ? ` -only-testing:${shq(args.test_identifier)}` : "";
848
1006
  // xcodebuild exits non-zero when the audit finds anything, because each
849
1007
  // finding is an XCTest failure. That is a result, not a failure to run.
850
- runCapture(
851
- `xcodebuild test ${container} -scheme ${shq(args.scheme)} -destination ${shq(`platform=iOS Simulator,id=${d}`)} -resultBundlePath ${shq(bundle)}${only} 2>&1`,
852
- { timeout: 900000 },
853
- );
1008
+ // spawnCollect: a 15-minute test run through execSync blocked the event
1009
+ // loop for its whole duration, and its 1MB maxBuffer killed the child on
1010
+ // any real build log.
1011
+ const stopHeartbeat = startHeartbeat(ctx, "xcodebuild test (accessibility audit)");
1012
+ let build;
1013
+ try {
1014
+ build = await spawnCollect(
1015
+ `xcodebuild test ${container} -scheme ${shq(args.scheme)} -destination ${shq(`platform=iOS Simulator,id=${d}`)} -resultBundlePath ${shq(bundle)}${only} 2>&1`,
1016
+ { timeout: 900000, signal: ctx.signal },
1017
+ );
1018
+ } finally {
1019
+ stopHeartbeat();
1020
+ }
854
1021
  if (!existsSync(bundle)) {
855
- return JSON.stringify({ measurable: false, reason: "xcodebuild produced no result bundle; the build failed before any test ran", test_ran: false, findings: [] }, null, 2);
1022
+ const reason = build.timedOut
1023
+ ? "xcodebuild did not finish within 900s; no result bundle was written"
1024
+ : build.aborted
1025
+ ? "the request was cancelled before xcodebuild finished"
1026
+ : "xcodebuild produced no result bundle; the build failed before any test ran";
1027
+ return JSON.stringify({ measurable: false, reason, test_ran: false, findings: [] }, null, 2);
856
1028
  }
857
- const json = runCapture(`xcrun xcresulttool get test-results tests --path ${shq(bundle)} --format json 2>&1`, { timeout: 60000 });
1029
+ const json = (await spawnCollect(`xcrun xcresulttool get test-results tests --path ${shq(bundle)} --format json 2>&1`, { timeout: 60000, signal: ctx.signal })).output;
858
1030
  const r = parseAuditResults(json, args.test_identifier || null);
859
1031
  return JSON.stringify({
860
1032
  measurable: r.measurable,
@@ -897,14 +1069,23 @@ async function handleIOS(name, args, ctx = {}) {
897
1069
  // model-controlled device_id a command injection.
898
1070
  function adbFlag(id) { return id ? `-s ${deviceSerial(id)}` : ""; }
899
1071
 
1072
+ // One rule for both launchers: `activity` is the class, relative or fully
1073
+ // qualified, and the component is package/activity. A value that already
1074
+ // carries the package (com.x/.Main) is used as it is.
1075
+ function androidComponent(packageName, activity) {
1076
+ const pkg = sanitizeId(packageName);
1077
+ const act = sanitizeId(activity);
1078
+ return act.includes("/") ? act : `${pkg}/${act}`;
1079
+ }
1080
+
900
1081
  const ANDROID_TOOLS = [
901
1082
  { name: "android_list_devices", description: "List connected Android devices and emulators", inputSchema: { type: "object", properties: {} } },
902
- { 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." } } } },
1083
+ { name: "android_screenshot", description: "Capture an Android screenshot. Without `path` the image comes back inline, downscaled to 800 px on the longest side as a quality-80 JPEG (~60 KB instead of a multi-MB full-resolution PNG on every capture); `format: \"png\"` with `max_width: 0` returns the original PNG bytes. Downscaling needs sips (macOS); without it the original PNG is returned and the result says so. Pass `path` to write the full-resolution PNG there and return only its location - what a multi-capture run and the design tools need.", 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." }, max_width: { type: "integer", description: "Longest side of the inline image in pixels (default 800). 0 keeps the original size. Ignored with `path`." }, format: { type: "string", enum: ["jpeg", "png"], description: "Inline image format (default jpeg). Ignored with `path`." }, quality: { type: "integer", description: "JPEG quality 1-100 (default 80). Ignored for png and with `path`." } } } },
903
1084
  { 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"] } },
904
1085
  { 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"] } },
905
- { name: "android_type_text", description: "Type text on Android", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
1086
+ { name: "android_type_text", description: "Type text on Android via `adb shell input text`. The text reaches the device shell as a quoted literal, so shell characters are typed, not interpreted. Platform limit: `input text` drops non-ASCII characters and some symbols; use the keyboard for those.", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
906
1087
  { 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"] } },
907
- { 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"] } },
1088
+ { name: "android_launch_app", description: "Launch Android app by package name", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string", description: "Activity class, relative (.MainActivity) or fully qualified (com.example.MainActivity); started as package_name/activity. Omit to launch through the LAUNCHER intent." }, device_id: { type: "string" } }, required: ["package_name"] } },
908
1089
  { name: "android_stop_app", description: "Force-stop Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
909
1090
  { name: "android_list_packages", description: "List installed Android packages", inputSchema: { type: "object", properties: { filter: { type: "string" }, device_id: { type: "string" } } } },
910
1091
  { name: "android_go_home", description: "Press Android home button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
@@ -924,13 +1105,26 @@ const ANDROID_TOOLS = [
924
1105
  { 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"] } },
925
1106
  { 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"] } },
926
1107
  { name: "android_accessibility_audit", description: "Audit Android app accessibility on the connected device: missing contentDescription, clickable nodes TalkBack cannot focus, touch targets under 48dp, missing resource-ids, and whether the reading order follows the visual layout. Reports measurable:false with a reason rather than a clean result when the dump could not be read.", 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." }, rtl: { type: "boolean", description: "Expect right-to-left reading within a row. Default false." } } } },
927
- { name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
1108
+ { name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string", description: "Activity class, relative (.MainActivity) or fully qualified (com.example.MainActivity); started as package_name/activity. Default .MainActivity." }, device_id: { type: "string" } }, required: ["package_name"] } },
928
1109
  { 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"] } },
929
1110
  { name: "android_meminfo", description: "Read an Android app's memory via `adb shell dumpsys meminfo` (KB, no root). mode=snapshot returns the App Summary rows and totals; mode=diff compares two snapshots so growth across the same flow is visible, which is the signal a leak actually produces - a single absolute number says almost nothing. Reports measurable:false when the package has no running process rather than returning zeros.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_json: { type: "string", description: "mode=diff: the JSON returned by an earlier snapshot call" } }, required: ["package_name"] } },
930
1111
  { 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" } } } },
931
1112
  { 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"] } },
932
1113
  ];
933
1114
 
1115
+ // The snapshot shape the tool emits, and the shape diffMeminfo accepts back
1116
+ // as a baseline. One function so the two cannot drift apart again.
1117
+ function meminfoPayload(snapshot) {
1118
+ return {
1119
+ measurable: snapshot.measurable,
1120
+ reason: snapshot.reason,
1121
+ pss_kb: snapshot.pss,
1122
+ total_pss_kb: snapshot.totalPssKb,
1123
+ total_rss_kb: snapshot.totalRssKb,
1124
+ total_swap_kb: snapshot.totalSwapKb,
1125
+ };
1126
+ }
1127
+
934
1128
  async function handleAndroid(name, args, ctx = {}) {
935
1129
  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.`;
936
1130
  const df = adbFlag(args.device_id);
@@ -943,21 +1137,24 @@ async function handleAndroid(name, args, ctx = {}) {
943
1137
  const parent = dirname(f);
944
1138
  if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
945
1139
  }
946
- run(`adb ${df} shell screencap -p /sdcard/_mcp_screen.png`);
947
- run(`adb ${df} pull /sdcard/_mcp_screen.png ${shq(f)}`);
1140
+ const inline = args.path ? null : captureOptions(args);
1141
+ discardStale(f);
1142
+ const cap = run(`adb ${df} shell screencap -p /sdcard/_mcp_screen.png`);
1143
+ if (isFailure(cap)) return cap;
1144
+ const pulled = run(`adb ${df} pull /sdcard/_mcp_screen.png ${shq(f)}`);
948
1145
  run(`adb ${df} shell rm /sdcard/_mcp_screen.png`);
1146
+ if (isFailure(pulled)) return pulled;
949
1147
  if (!existsSync(f)) return "ERROR: Screenshot failed";
950
1148
  if (args.path) return fileResult(`Screenshot written: ${f}`, f);
951
- const buf = readFileSync(f);
952
- return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
1149
+ return inlineCapture(f, inline);
953
1150
  }
954
1151
  case "android_tap": return run(`adb ${df} shell input tap ${num(args.x, "x")} ${num(args.y, "y")}`) || `Tapped (${args.x}, ${args.y})`;
955
1152
  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";
956
1153
  // `adb shell input text` wants spaces as %s; single-quote the result so the
957
1154
  // remaining characters cannot reach the shell as syntax.
958
- case "android_type_text": return run(`adb ${df} shell input text ${shq(String(args.text ?? "").replace(/ /g, "%s"))}`) || `Typed: ${args.text}`;
1155
+ case "android_type_text": return run(`adb ${df} shell input text ${remoteShq(String(args.text ?? "").replace(/ /g, "%s"))}`) || `Typed: ${args.text}`;
959
1156
  case "android_key_event": return run(`adb ${df} shell input keyevent ${token(args.keycode, "keycode")}`) || `Key ${args.keycode}`;
960
- 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`;
1157
+ case "android_launch_app": return args.activity ? run(`adb ${df} shell am start -n ${androidComponent(args.package_name, args.activity)}`) : run(`adb ${df} shell monkey -p ${sanitizeId(args.package_name)} -c android.intent.category.LAUNCHER 1`) || `Launched`;
961
1158
  case "android_stop_app": return run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`) || "Stopped";
962
1159
  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; }
963
1160
  case "android_go_home": return run(`adb ${df} shell input keyevent 3`) || "Home";
@@ -968,9 +1165,12 @@ async function handleAndroid(name, args, ctx = {}) {
968
1165
  const parent = dirname(f);
969
1166
  if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
970
1167
  }
971
- run(`adb ${df} shell uiautomator dump /sdcard/_mcp_ui.xml`);
972
- run(`adb ${df} pull /sdcard/_mcp_ui.xml ${shq(f)}`);
1168
+ discardStale(f);
1169
+ const dumped = run(`adb ${df} shell uiautomator dump /sdcard/_mcp_ui.xml`);
1170
+ if (isFailure(dumped)) return dumped;
1171
+ const pulled = run(`adb ${df} pull /sdcard/_mcp_ui.xml ${shq(f)}`);
973
1172
  run(`adb ${df} shell rm /sdcard/_mcp_ui.xml`);
1173
+ if (isFailure(pulled)) return pulled;
974
1174
  if (!existsSync(f)) return "ERROR: UI dump failed";
975
1175
  if (args.filter === "interactive") {
976
1176
  const elements = interactiveElements(readFileSync(f, "utf-8"));
@@ -1076,7 +1276,7 @@ async function handleAndroid(name, args, ctx = {}) {
1076
1276
  case "android_uninstall_app": return run(`adb ${df} shell pm uninstall ${sanitizeId(args.package_name)}`) || "Uninstalled";
1077
1277
  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}`); }
1078
1278
  case "android_get_screen_size": return run(`adb ${df} shell wm size`);
1079
- case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${shq(args.url)}`) || `Opened: ${args.url}`;
1279
+ case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${remoteShq(args.url)}`) || `Opened: ${args.url}`;
1080
1280
  case "android_clear_app_data": return run(`adb ${df} shell pm clear ${sanitizeId(args.package_name)}`) || "Cleared";
1081
1281
  case "android_accessibility_audit": {
1082
1282
  run(`adb ${df} shell uiautomator dump /sdcard/_mcp_a11y.xml`);
@@ -1101,8 +1301,8 @@ async function handleAndroid(name, args, ctx = {}) {
1101
1301
  }
1102
1302
  case "android_launch_time": {
1103
1303
  run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
1104
- const activity = sanitizeId(args.activity || `${args.package_name}/.MainActivity`);
1105
- const result = run(`adb ${df} shell am start -W -n ${activity} 2>&1`);
1304
+ const component = androidComponent(args.package_name, args.activity || ".MainActivity");
1305
+ const result = run(`adb ${df} shell am start -W -n ${component} 2>&1`);
1106
1306
  const parsed = parseLaunchOutput(result);
1107
1307
  return JSON.stringify({
1108
1308
  package: args.package_name,
@@ -1122,7 +1322,7 @@ async function handleAndroid(name, args, ctx = {}) {
1122
1322
  if (!existsSync(p)) return `ERROR: APK not found at ${p}`;
1123
1323
  const findings = [];
1124
1324
  // 1. Basic info via aapt2
1125
- const aapt = run(`aapt2 dump badging ${shq(p)} 2>/dev/null`) || run(`aapt dump badging ${shq(p)} 2>/dev/null`);
1325
+ const aapt = runOrNull(`aapt2 dump badging ${shq(p)} 2>/dev/null`) || runOrNull(`aapt dump badging ${shq(p)} 2>/dev/null`);
1126
1326
  if (aapt) {
1127
1327
  const pkg = aapt.match(/package: name='([^']*)'/)?.[1];
1128
1328
  const versionName = aapt.match(/versionName='([^']*)'/)?.[1];
@@ -1143,19 +1343,29 @@ async function handleAndroid(name, args, ctx = {}) {
1143
1343
  } else {
1144
1344
  findings.push({ check: "aapt", status: "warning", detail: "aapt2/aapt not found - install Android SDK Build-Tools for full audit" });
1145
1345
  }
1146
- // 2. Signing check
1147
- const signingInfo = run(`apksigner verify --print-certs ${shq(p)} 2>&1`);
1148
- if (signingInfo && !signingInfo.includes("ERROR")) {
1149
- const hasV2 = signingInfo.includes("v2 scheme") || run(`apksigner verify -v ${shq(p)} 2>&1`)?.includes("Verified using v2");
1346
+ // 2. Signing check. apksigner exits 1 on a failed verification with the
1347
+ // verdict on stdout, so the output is captured whatever the status.
1348
+ const signingInfo = runCapture(`apksigner verify --print-certs ${shq(p)} 2>&1`);
1349
+ if (/DOES NOT VERIFY/.test(signingInfo)) {
1350
+ findings.push({ check: "signing", status: "critical", detail: signingInfo.slice(0, 500) });
1351
+ } else if (isFailure(signingInfo) || /command not found|No such file or directory/i.test(signingInfo)) {
1352
+ findings.push({ check: "signing", status: "warning", detail: "apksigner not found - install Android SDK Build-Tools to verify the signature" });
1353
+ } else if (/^(ERROR|Exception)/m.test(signingInfo)) {
1354
+ findings.push({ check: "signing", status: "warning", detail: signingInfo.slice(0, 500) });
1355
+ } else {
1356
+ const hasV2 = signingInfo.includes("v2 scheme") || (runOrNull(`apksigner verify -v ${shq(p)} 2>&1`) || "").includes("Verified using v2");
1150
1357
  findings.push({ check: "signing", status: "pass", detail: "APK is signed" });
1151
1358
  findings.push({ check: "signing_v2", status: hasV2 ? "pass" : "warning", detail: hasV2 ? "v2+ signature present" : "Only v1 signature - consider v2+ for tamper protection" });
1152
- } else {
1153
- findings.push({ check: "signing", status: signingInfo?.includes("DOES NOT VERIFY") ? "critical" : "warning", detail: signingInfo || "apksigner not found" });
1154
1359
  }
1155
1360
  // 3. File size
1156
- 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 {}
1361
+ try {
1362
+ const mb = statSync(p).size / 1048576;
1363
+ 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" });
1364
+ } catch (e) {
1365
+ findings.push({ check: "apk_size", status: "warning", detail: `could not read the file size: ${e.message}` });
1366
+ }
1157
1367
  // 4. ProGuard/R8 check
1158
- const hasMapping = run(`unzip -l ${shq(p)} 2>/dev/null | grep -c "classes.dex"`)?.trim();
1368
+ const hasMapping = runOrNull(`unzip -l ${shq(p)} 2>/dev/null | grep -c "classes.dex"`);
1159
1369
  const dexCount = parseInt(hasMapping) || 0;
1160
1370
  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" });
1161
1371
  // Summary
@@ -1177,18 +1387,9 @@ async function handleAndroid(name, args, ctx = {}) {
1177
1387
  return "ERROR: baseline_json is not valid JSON";
1178
1388
  }
1179
1389
  const d = diffMeminfo(before, snapshot);
1180
- return JSON.stringify({ package: args.package_name, mode, comparable: d.comparable, reason: d.reason, delta_kb: d.deltaKb, total_pss_delta_kb: d.totalPssDeltaKb, after: snapshot }, null, 2);
1390
+ return JSON.stringify({ package: args.package_name, mode, comparable: d.comparable, reason: d.reason, delta_kb: d.deltaKb, total_pss_delta_kb: d.totalPssDeltaKb, after: meminfoPayload(snapshot) }, null, 2);
1181
1391
  }
1182
- return JSON.stringify({
1183
- package: args.package_name,
1184
- mode,
1185
- measurable: snapshot.measurable,
1186
- reason: snapshot.reason,
1187
- pss_kb: snapshot.pss,
1188
- total_pss_kb: snapshot.totalPssKb,
1189
- total_rss_kb: snapshot.totalRssKb,
1190
- total_swap_kb: snapshot.totalSwapKb,
1191
- }, null, 2);
1392
+ return JSON.stringify({ package: args.package_name, mode, ...meminfoPayload(snapshot) }, null, 2);
1192
1393
  }
1193
1394
  case "android_list_crashes": {
1194
1395
  const lines = args.lines !== undefined ? num(args.lines, "lines") : 200;
@@ -1224,9 +1425,15 @@ async function handleAndroid(name, args, ctx = {}) {
1224
1425
 
1225
1426
  let _browser = null;
1226
1427
  let _page = null;
1227
-
1228
- async function ensureBrowser(browserType = "chromium") {
1229
- if (_browser && _page) return _page;
1428
+ let _engine = null;
1429
+
1430
+ // A request for a different engine closes the current browser and relaunches;
1431
+ // a partial launch (newContext/newPage threw) is closed rather than left as a
1432
+ // browser with no page that the next call would launch beside.
1433
+ async function ensureBrowser(browserType) {
1434
+ const wanted = browserType || _engine || "chromium";
1435
+ if (_browser && _page && _engine === wanted) return _page;
1436
+ if (_browser || _page) await closeBrowser();
1230
1437
  let pw;
1231
1438
  try {
1232
1439
  pw = await import("playwright");
@@ -1234,10 +1441,17 @@ async function ensureBrowser(browserType = "chromium") {
1234
1441
  throw new Error("Web tools require Playwright. Install once with: npm i -g playwright && npx playwright install chromium");
1235
1442
  }
1236
1443
  const engines = { chromium: pw.chromium, webkit: pw.webkit, firefox: pw.firefox };
1237
- const engine = engines[browserType] || pw.chromium;
1444
+ const engine = engines[wanted];
1445
+ if (!engine) throw new Error(`unknown browser engine: ${wanted}`);
1238
1446
  _browser = await engine.launch({ headless: true });
1239
- const ctx = await _browser.newContext();
1240
- _page = await ctx.newPage();
1447
+ try {
1448
+ const ctx = await _browser.newContext();
1449
+ _page = await ctx.newPage();
1450
+ } catch (e) {
1451
+ await closeBrowser();
1452
+ throw e;
1453
+ }
1454
+ _engine = wanted;
1241
1455
  return _page;
1242
1456
  }
1243
1457
 
@@ -1246,6 +1460,7 @@ async function closeBrowser() {
1246
1460
  try { await _browser?.close(); } catch {}
1247
1461
  _page = null;
1248
1462
  _browser = null;
1463
+ _engine = null;
1249
1464
  }
1250
1465
 
1251
1466
  const WEB_TOOLS = [
@@ -1350,6 +1565,10 @@ const AGENT_TOOLS = [
1350
1565
  // a batch able to nest itself has no recursion bound.
1351
1566
  async function dispatchStep(tool, stepArgs) {
1352
1567
  if (tool.startsWith("agent_")) throw new Error(`Nested batch steps are not supported: ${tool}`);
1568
+ // The same boundary check a direct call gets: a batched step used to reach
1569
+ // the handler with its enum/type/required contract unenforced.
1570
+ const schemaError = validateArgs(tool, stepArgs);
1571
+ if (schemaError) throw new Error(`invalid arguments: ${schemaError}`);
1353
1572
  let result;
1354
1573
  if (tool.startsWith("ios_")) result = await handleIOS(tool, stepArgs);
1355
1574
  else if (tool.startsWith("android_")) result = await handleAndroid(tool, stepArgs);
@@ -1611,7 +1830,7 @@ const bundleAuditSchema = (pathKey) => ({
1611
1830
  items: {
1612
1831
  type: "object",
1613
1832
  properties: {
1614
- status: { type: "string", enum: ["pass", "warning", "critical"] },
1833
+ status: { type: "string", enum: ["pass", "warning", "critical", "info"] },
1615
1834
  rule: { type: "string" },
1616
1835
  message: { type: "string" },
1617
1836
  },
@@ -1664,7 +1883,7 @@ const OUTPUT_SCHEMAS = {
1664
1883
  max_diff_pct: { type: "number" },
1665
1884
  baseline: { type: "string" },
1666
1885
  current: { type: "string" },
1667
- diff_image: { type: "string" },
1886
+ diff_image: { type: ["string", "null"] },
1668
1887
  },
1669
1888
  },
1670
1889
 
@@ -1694,9 +1913,11 @@ const OUTPUT_SCHEMAS = {
1694
1913
  required: ["package", "cold_start"],
1695
1914
  properties: {
1696
1915
  package: { type: "string" },
1697
- cold_start: { type: "boolean" },
1916
+ launch_state: { type: ["string", "null"] },
1917
+ cold_start: { type: ["boolean", "null"] },
1698
1918
  total_time_ms: { type: ["integer", "null"] },
1699
1919
  wait_time_ms: { type: ["integer", "null"] },
1920
+ error: { type: ["string", "null"] },
1700
1921
  raw: { type: "string" },
1701
1922
  },
1702
1923
  },
@@ -1733,9 +1954,11 @@ const OUTPUT_SCHEMAS = {
1733
1954
  items: {
1734
1955
  type: "object",
1735
1956
  properties: {
1957
+ step: { type: "integer" },
1736
1958
  tool: { type: "string" },
1737
1959
  status: { type: "string", enum: ["ok", "error"] },
1738
- output: { type: "string" },
1960
+ result: { type: "string" },
1961
+ error: { type: "string" },
1739
1962
  },
1740
1963
  },
1741
1964
  },
@@ -1773,7 +1996,8 @@ const designCtx = {
1773
1996
  adbFlag,
1774
1997
  idb,
1775
1998
  hasIdb: HAS_IDB,
1776
- dumperScript: join(__dirname, "ui-tree-dumper.swift"),
1999
+ dumperScript: DUMPER_SOURCE,
2000
+ dumperCommand,
1777
2001
  };
1778
2002
 
1779
2003
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: ANNOTATED_TOOLS }));
@@ -1821,11 +2045,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1821
2045
  if (result === null || result === undefined) {
1822
2046
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
1823
2047
  }
2048
+ pruneWorkDir(SCREENSHOT_DIR, { keep: promisedPaths(result) });
1824
2049
  if (typeof result === "object" && result?.type === "image") {
1825
2050
  return {
1826
2051
  content: [
1827
2052
  { type: "image", data: result.data, mimeType: result.mimeType },
1828
- { type: "text", text: `Screenshot: ${result.path}` },
2053
+ { type: "text", text: `Screenshot: ${result.path}${result.note ?? ""}` },
1829
2054
  ...(result.path ? [{ type: "resource_link", uri: `file://${result.path}`, name: basename(result.path), mimeType: result.mimeType }] : []),
1830
2055
  ],
1831
2056
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.7.1",
3
+ "version": "3.9.0",
4
4
  "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 87 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -128,7 +128,7 @@ function safeArg(x, label) {
128
128
  return x;
129
129
  }
130
130
 
131
- // ctx: { run, iosDevice, adbFlag, dumperScript }
131
+ // ctx: { run, iosDevice, adbFlag, dumperScript, dumperCommand }
132
132
  export async function handleDesign(name, args, ctx) {
133
133
  switch (name) {
134
134
  case "design_mock_detect":
@@ -172,7 +172,8 @@ export async function handleDesign(name, args, ctx) {
172
172
  }
173
173
  }
174
174
  const depth = Number(args.max_depth) || 12;
175
- const raw = ctx.run(`swift ${shq(ctx.dumperScript)} ${depth}`, { timeout: 15000 });
175
+ const dumper = ctx.dumperCommand ? ctx.dumperCommand() : `swift ${shq(ctx.dumperScript)}`;
176
+ const raw = ctx.run(`${dumper} ${depth}`, { timeout: 15000 });
176
177
  let tree; try { tree = JSON.parse(raw); } catch { return `ERROR: idb unavailable and AX dumper failed: ${String(raw).slice(0, 200)}`; }
177
178
  const elements = flattenIosAxTree(tree);
178
179
  const rf = tree.frame || {};
@@ -541,6 +541,19 @@ ${variants.map((v, i) => variantSection(v, i, fileKey, L)).join("")}
541
541
  </body></html>`;
542
542
  }
543
543
 
544
+ // The browser is closed on every path: a failed setContent/pdf used to leave a
545
+ // headless Chromium running for the life of the server.
546
+ export async function renderPdf({ html, pdfPath, chromium }) {
547
+ const browser = await chromium.launch();
548
+ try {
549
+ const page = await browser.newPage();
550
+ await page.setContent(html, { waitUntil: "networkidle" });
551
+ await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
552
+ } finally {
553
+ await browser.close();
554
+ }
555
+ }
556
+
544
557
  export async function writeReport({ report, outDir, formats = ["html"] }) {
545
558
  const html = renderHtml(report);
546
559
  const out = {};
@@ -571,11 +584,8 @@ export async function writeReport({ report, outDir, formats = ["html"] }) {
571
584
  let ok = false, err = "";
572
585
  try {
573
586
  const { chromium } = await import("playwright");
574
- const browser = await chromium.launch();
575
- const page = await browser.newPage();
576
- await page.setContent(html, { waitUntil: "networkidle" });
577
- await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
578
- await browser.close(); ok = true;
587
+ await renderPdf({ html, pdfPath, chromium });
588
+ ok = true;
579
589
  } catch (e) {
580
590
  err = e.message;
581
591
  // Fallback: use the chrome-headless-shell binary directly (no `playwright` package).
@@ -67,8 +67,18 @@ const RULE_GROUPS = {
67
67
  function resolveRuleSelection(rules) {
68
68
  if (!rules || rules === "all" || rules === "deep") return RULE_GROUPS.all();
69
69
  if (rules === "core") return RULE_GROUPS.core();
70
- // Comma-separated explicit list.
71
- return rules.split(",").map((s) => s.trim()).filter(Boolean);
70
+ // Comma-separated explicit list. An unknown id used to select nothing, and
71
+ // nothing selected audited nothing and reported PASS.
72
+ const known = RULE_REGISTRY.map((r) => r.id);
73
+ const asked = String(rules).split(",").map((s) => s.trim()).filter(Boolean);
74
+ const unknown = asked.filter((id) => !known.includes(id));
75
+ if (unknown.length > 0) {
76
+ throw new Error(`unknown rule id(s): ${unknown.join(", ")}. Valid values: all, core, deep, or a comma-separated subset of ${known.join(", ")}`);
77
+ }
78
+ if (asked.length === 0) {
79
+ throw new Error(`no rules selected from "${rules}". Valid values: all, core, deep, or a comma-separated subset of ${known.join(", ")}`);
80
+ }
81
+ return asked;
72
82
  }
73
83
 
74
84
  // ---------- Public entry ---------------------------------------------------
@@ -87,6 +97,7 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
87
97
 
88
98
  const selectedIDs = new Set(resolveRuleSelection(rules));
89
99
  const toRun = RULE_REGISTRY.filter((r) => selectedIDs.has(r.id));
100
+ if (toRun.length === 0) throw new Error("no rules selected; an audit that runs nothing cannot pass");
90
101
 
91
102
  const violations = [];
92
103
  const ranIDs = [];
@@ -135,6 +146,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
135
146
  info: violations.filter((v) => v.severity === "info").length,
136
147
  total: violations.length,
137
148
  };
149
+ if (ranIDs.length === 0) {
150
+ throw new Error(`no rule ran: ${skippedIDs.map((s) => `${s.id} (${s.reason})`).join("; ")}`);
151
+ }
138
152
  const verdict = summary.error > 0 ? "FAIL" : summary.warning > 0 ? "WARN" : "PASS";
139
153
 
140
154
  return {
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { execFile } from "child_process";
27
- import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs";
27
+ import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
28
28
  import { join } from "path";
29
29
  import { tmpdir } from "os";
30
30
 
@@ -227,6 +227,15 @@ export function resolveAuth(opts = {}) {
227
227
  * @param {string} [opts.signingStyle] "automatic" | "manual"
228
228
  * @returns {string} plist XML
229
229
  */
230
+ export function xmlEscape(value) {
231
+ return String(value ?? "")
232
+ .replace(/&/g, "&amp;")
233
+ .replace(/</g, "&lt;")
234
+ .replace(/>/g, "&gt;")
235
+ .replace(/"/g, "&quot;")
236
+ .replace(/'/g, "&apos;");
237
+ }
238
+
230
239
  export function buildExportOptionsPlist(opts) {
231
240
  const {
232
241
  method = "app-store-connect",
@@ -236,17 +245,17 @@ export function buildExportOptionsPlist(opts) {
236
245
  signingStyle,
237
246
  } = opts;
238
247
 
239
- const entries = [` <key>method</key>\n <string>${method}</string>`];
248
+ const entries = [` <key>method</key>\n <string>${xmlEscape(method)}</string>`];
240
249
  entries.push(
241
250
  ` <key>uploadSymbols</key>\n <${uploadSymbols ? "true" : "false"}/>`,
242
251
  );
243
- if (teamId) entries.push(` <key>teamID</key>\n <string>${teamId}</string>`);
252
+ if (teamId) entries.push(` <key>teamID</key>\n <string>${xmlEscape(teamId)}</string>`);
244
253
  if (signingStyle) {
245
- entries.push(` <key>signingStyle</key>\n <string>${signingStyle}</string>`);
254
+ entries.push(` <key>signingStyle</key>\n <string>${xmlEscape(signingStyle)}</string>`);
246
255
  }
247
256
  if (provisioningProfiles && Object.keys(provisioningProfiles).length > 0) {
248
257
  const rows = Object.entries(provisioningProfiles)
249
- .map(([bundleId, profile]) => ` <key>${bundleId}</key>\n <string>${profile}</string>`)
258
+ .map(([bundleId, profile]) => ` <key>${xmlEscape(bundleId)}</key>\n <string>${xmlEscape(profile)}</string>`)
250
259
  .join("\n");
251
260
  entries.push(` <key>provisioningProfiles</key>\n <dict>\n${rows}\n </dict>`);
252
261
  }
@@ -263,6 +272,36 @@ export function buildExportOptionsPlist(opts) {
263
272
  ].join("\n");
264
273
  }
265
274
 
275
+ /**
276
+ * The .ipa this export wrote: the newest one in output_dir, and only if it was
277
+ * written after the export started. An older .ipa left by a previous run was
278
+ * reported as this run's result.
279
+ *
280
+ * @param {string} outputDir
281
+ * @param {number} notBefore - epoch ms; files modified earlier are stale
282
+ * @returns {{ipaPath: string|null, stale: string[]}}
283
+ */
284
+ export function pickExportedIpa(outputDir, notBefore) {
285
+ if (!outputDir || !existsSync(outputDir)) return { ipaPath: null, stale: [] };
286
+ const candidates = readdirSync(outputDir)
287
+ .filter((f) => f.endsWith(".ipa"))
288
+ .map((f) => {
289
+ const full = join(outputDir, f);
290
+ try {
291
+ return { full, mtime: statSync(full).mtimeMs };
292
+ } catch {
293
+ return null;
294
+ }
295
+ })
296
+ .filter(Boolean)
297
+ .sort((a, b) => b.mtime - a.mtime);
298
+ const fresh = candidates.find((c) => c.mtime >= notBefore);
299
+ return {
300
+ ipaPath: fresh ? fresh.full : null,
301
+ stale: candidates.filter((c) => c.mtime < notBefore).map((c) => c.full),
302
+ };
303
+ }
304
+
266
305
  /**
267
306
  * Export a .xcarchive to a signed .ipa.
268
307
  *
@@ -305,6 +344,8 @@ export async function exportIpa(opts) {
305
344
  // want that.
306
345
  if (allowProvisioningUpdates) argv.push("-allowProvisioningUpdates");
307
346
 
347
+ // 2s of slack for filesystems with coarse mtime granularity.
348
+ const startedAt = Date.now() - 2000;
308
349
  const { err, stdout, stderr } = await execFileAsync("xcodebuild", argv, {
309
350
  encoding: "utf-8",
310
351
  timeout: timeoutSec * 1000,
@@ -317,13 +358,15 @@ export async function exportIpa(opts) {
317
358
  : stdout;
318
359
 
319
360
  const errors = (log.match(/^.*error:.*$/gim) || []).map((l) => l.trim());
320
- let ipaPath;
321
- if (existsSync(outputDir)) {
322
- const ipa = readdirSync(outputDir).find((f) => f.endsWith(".ipa"));
323
- if (ipa) ipaPath = join(outputDir, ipa);
324
- }
361
+ const picked = pickExportedIpa(outputDir, startedAt);
362
+ const ipaPath = picked.ipaPath || undefined;
325
363
  // xcodebuild can exit 0 and still produce nothing useful.
326
- if (!ipaPath) ok = false;
364
+ if (!ipaPath) {
365
+ ok = false;
366
+ if (picked.stale.length > 0) {
367
+ errors.push(`error: no .ipa was written by this export; ${picked.stale.join(", ")} in output_dir predates this run`);
368
+ }
369
+ }
327
370
 
328
371
  // The plist is consumed by the time xcodebuild returns. Leaving the temp dir
329
372
  // behind would accumulate one per export for the life of the machine; the path
@@ -132,17 +132,32 @@ export function parseMeminfoOutput(raw) {
132
132
  return { measurable: true, reason: null, pss, totalPssKb, totalRssKb, totalSwapKb };
133
133
  }
134
134
 
135
+ /**
136
+ * Accept both snapshot shapes: the parser's (pss, totalPssKb) and the one the
137
+ * android_meminfo tool emits and hands back as baseline_json (pss_kb,
138
+ * total_pss_kb). The diff read only the parser shape while the tool emitted
139
+ * the other, so mode=diff never compared anything.
140
+ */
141
+ function normalizeSnapshot(s) {
142
+ if (!s || typeof s !== "object") return null;
143
+ const pss = s.pss && typeof s.pss === "object" ? s.pss : s.pss_kb && typeof s.pss_kb === "object" ? s.pss_kb : {};
144
+ const totalPssKb = typeof s.totalPssKb === "number" ? s.totalPssKb : typeof s.total_pss_kb === "number" ? s.total_pss_kb : null;
145
+ return { measurable: s.measurable === true, pss, totalPssKb };
146
+ }
147
+
135
148
  /**
136
149
  * Difference between two meminfo snapshots, in KB.
137
150
  *
138
151
  * Only keys present in both are compared; a key missing from either side is
139
152
  * absent from the result rather than counted as zero growth.
140
153
  *
141
- * @param {object} before - parseMeminfoOutput result
142
- * @param {object} after - parseMeminfoOutput result
154
+ * @param {object} before - parseMeminfoOutput result, or the android_meminfo snapshot payload
155
+ * @param {object} after - parseMeminfoOutput result, or the android_meminfo snapshot payload
143
156
  * @returns {{comparable: boolean, reason: string|null, deltaKb: object, totalPssDeltaKb: number|null}}
144
157
  */
145
- export function diffMeminfo(before, after) {
158
+ export function diffMeminfo(beforeRaw, afterRaw) {
159
+ const before = normalizeSnapshot(beforeRaw);
160
+ const after = normalizeSnapshot(afterRaw);
146
161
  if (!before?.measurable || !after?.measurable) {
147
162
  return { comparable: false, reason: "one of the snapshots was not measurable", deltaKb: {}, totalPssDeltaKb: null };
148
163
  }
@@ -15,7 +15,7 @@
15
15
  // outputSchema answers with JSON the host parses as structuredContent, and
16
16
  // replacing that with a summary would break the parse.
17
17
 
18
- import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from "fs";
18
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, rmSync } from "fs";
19
19
  import { join, resolve, sep } from "path";
20
20
  import { homedir } from "os";
21
21
 
@@ -31,20 +31,24 @@ const TAIL_LINES = 20;
31
31
  export const OFFLOAD_KEEP_FILES = 50;
32
32
  export const OFFLOAD_KEEP_DAYS = 7;
33
33
 
34
- // `keep` is the file the caller just wrote. It is never deleted, whatever the
35
- // retention numbers say: the returned text promises that path to the caller, and
36
- // this module exists precisely because losing the payload is the failure mode. A
37
- // keepFiles of 0 must bound the directory, not break the answer.
38
- export function pruneOffloadDir(dir, opts = {}) {
39
- const keepFiles = opts.keepFiles ?? OFFLOAD_KEEP_FILES;
40
- const keepDays = opts.keepDays ?? OFFLOAD_KEEP_DAYS;
41
- const now = opts.now ?? Date.now();
42
- const keep = typeof opts.keep === "string" ? resolve(opts.keep) : null;
34
+ // The server's own scratch directory (screenshots, UI dumps, push payloads,
35
+ // build logs, .xcresult bundles) gets the same treatment with a wider count,
36
+ // since a single design audit writes 100+ captures.
37
+ export const WORK_KEEP_ENTRIES = 200;
38
+ export const WORK_KEEP_DAYS = 7;
39
+
40
+ // `keep` names the file(s) the caller was just promised. They are never deleted,
41
+ // whatever the retention numbers say: the returned text promises that path to
42
+ // the caller, and this module exists precisely because losing the payload is the
43
+ // failure mode. A keepFiles of 0 must bound the directory, not break the answer.
44
+ function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
45
+ const keepList = Array.isArray(keep) ? keep : keep ? [keep] : [];
46
+ const keepSet = new Set(keepList.filter((k) => typeof k === "string").map((k) => resolve(k)));
43
47
  const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
44
48
  let removed = 0;
45
49
  try {
46
50
  const entries = readdirSync(dir)
47
- .filter((n) => n.endsWith(".txt"))
51
+ .filter(match)
48
52
  .map((n) => {
49
53
  const full = join(dir, n);
50
54
  try {
@@ -57,12 +61,12 @@ export function pruneOffloadDir(dir, opts = {}) {
57
61
  .sort((a, b) => b.mtime - a.mtime);
58
62
 
59
63
  for (let i = 0; i < entries.length; i++) {
60
- if (keep && resolve(entries[i].full) === keep) continue;
64
+ if (keepSet.has(resolve(entries[i].full))) continue;
61
65
  const tooOld = entries[i].mtime < cutoff;
62
66
  const tooMany = i >= keepFiles;
63
67
  if (!tooOld && !tooMany) continue;
64
68
  try {
65
- unlinkSync(entries[i].full);
69
+ rmSync(entries[i].full, { recursive: true, force: true });
66
70
  removed++;
67
71
  } catch {
68
72
  // A file another process holds open is skipped, not fatal.
@@ -74,6 +78,28 @@ export function pruneOffloadDir(dir, opts = {}) {
74
78
  return removed;
75
79
  }
76
80
 
81
+ export function pruneOffloadDir(dir, opts = {}) {
82
+ return pruneEntries(dir, {
83
+ keepFiles: opts.keepFiles ?? OFFLOAD_KEEP_FILES,
84
+ keepDays: opts.keepDays ?? OFFLOAD_KEEP_DAYS,
85
+ now: opts.now ?? Date.now(),
86
+ keep: opts.keep,
87
+ match: (n) => n.endsWith(".txt"),
88
+ });
89
+ }
90
+
91
+ // Every entry counts here, directories included: an .xcresult bundle is a
92
+ // directory and was the largest thing nothing ever removed.
93
+ export function pruneWorkDir(dir, opts = {}) {
94
+ return pruneEntries(dir, {
95
+ keepFiles: opts.keepFiles ?? WORK_KEEP_ENTRIES,
96
+ keepDays: opts.keepDays ?? WORK_KEEP_DAYS,
97
+ now: opts.now ?? Date.now(),
98
+ keep: opts.keep,
99
+ match: () => true,
100
+ });
101
+ }
102
+
77
103
  let lastOffload = null;
78
104
 
79
105
  export function lastOffloadRecord() {