@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.11.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/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 {
@@ -28,6 +29,15 @@ import {
28
29
  validateApp,
29
30
  } from "./tools/ios-testflight/index.js";
30
31
  import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
32
+ import {
33
+ CODE_TOOLS,
34
+ handleCode,
35
+ CODE_READ_ONLY,
36
+ CODE_IDEMPOTENT,
37
+ CODE_OUTPUT_SCHEMAS,
38
+ shutdownAllLsp,
39
+ } from "./tools/code-intel/index.js";
40
+ import { PASS_TOOLS, handlePass, PASS_READ_ONLY, PASS_OUTPUT_SCHEMAS } from "./tools/pass-kit/index.js";
31
41
  import { parseLaunchOutput } from "./tools/launch-time/index.js";
32
42
  import { parseLeaksOutput, parseMeminfoOutput, diffMeminfo } from "./tools/memory/index.js";
33
43
  import { auditIosTree, auditAndroidDump, parseAuditResults } from "./tools/a11y/index.js";
@@ -37,11 +47,16 @@ import {
37
47
  offloadLargeText,
38
48
  queryOffloadedOutput,
39
49
  offloadedErrorSummary,
50
+ pruneWorkDir,
40
51
  } from "./tools/offload/index.js";
41
52
 
42
53
  const __dirname = dirname(fileURLToPath(import.meta.url));
43
54
  const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
44
55
  if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true });
56
+ // Same retention as the offload dir: this directory collects screenshots, UI
57
+ // dumps, push payloads, build logs and .xcresult bundles, and nothing else
58
+ // ever removed them.
59
+ pruneWorkDir(SCREENSHOT_DIR);
45
60
 
46
61
  // ── Helpers ──
47
62
 
@@ -80,6 +95,27 @@ function run(cmd, opts = {}) {
80
95
  }
81
96
  }
82
97
 
98
+ // run() reports failure by returning a truthy marker string, so `run(a) || run(b)`
99
+ // never falls back. This variant returns null on failure for exactly that shape.
100
+ function runOrNull(cmd, opts = {}) {
101
+ const out = run(cmd, opts);
102
+ return isFailure(out) ? null : out;
103
+ }
104
+
105
+ // A capture that must be fresh: a file left by an earlier run would otherwise
106
+ // pass the existence check and be returned as this run's result.
107
+ function discardStale(path) {
108
+ try { if (statSync(path).isFile()) unlinkSync(path); } catch {}
109
+ }
110
+
111
+ // The files a result promises to the caller; retention never removes them.
112
+ function promisedPaths(result) {
113
+ if (!result || typeof result !== "object") return [];
114
+ if (result.type === "file") return result.files.map((f) => f.path);
115
+ if (result.type === "image") return [result.path, result.source].filter(Boolean);
116
+ return [];
117
+ }
118
+
83
119
  /**
84
120
  * Run a command whose non-zero exit is a RESULT, not a failure.
85
121
  *
@@ -215,6 +251,64 @@ function shq(value) {
215
251
  return `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
216
252
  }
217
253
 
254
+ // `adb shell <cmd>` is parsed twice: once by the host shell, again by the
255
+ // device shell. One shq() layer survives only the first, so the device shell
256
+ // saw `;`, `&` and `$()` in a text or URL as syntax. The inner layer is what
257
+ // the device shell receives.
258
+ function remoteShq(value) {
259
+ return shq(shq(value));
260
+ }
261
+
262
+ // The ui-tree dumper prints its failure as a JSON object on stdout (with the
263
+ // remedy) and exits 1; a plain exit-status error keeps only stderr.
264
+ function dumperErrorOf(text) {
265
+ const s = String(text ?? "");
266
+ try {
267
+ const parsed = JSON.parse(s);
268
+ return parsed && typeof parsed.error === "string" ? parsed.error : null;
269
+ } catch {}
270
+ const m = s.match(/"error"\s*:\s*("(?:[^"\\]|\\.)*")/);
271
+ if (!m) return null;
272
+ try { return JSON.parse(m[1]); } catch { return null; }
273
+ }
274
+
275
+ const DUMPER_SOURCE = join(__dirname, "ui-tree-dumper.swift");
276
+
277
+ // `swift <script>` re-parses and JITs the dumper on every call (0.9-1.2 s); the
278
+ // -O binary answers in 0.05 s. Compiled on first use, never at server start,
279
+ // and keyed by source + toolchain so an edit or an Xcode update rebuilds it.
280
+ // The directory comes from the environment and the home directory only, never
281
+ // from a tool argument, the same rule the offload dir follows.
282
+ function dumperCacheDir() {
283
+ const xdg = process.env.XDG_CACHE_HOME;
284
+ const base = xdg && isAbsolute(xdg) ? xdg
285
+ : process.platform === "darwin" ? join(homedir(), "Library", "Caches")
286
+ : join(homedir(), ".cache");
287
+ for (const dir of [join(base, "multi-agent-toolkit-mcp"), join(tmpdir(), "multi-agent-toolkit-mcp")]) {
288
+ try { mkdirSync(dir, { recursive: true }); return dir; } catch {}
289
+ }
290
+ return null;
291
+ }
292
+
293
+ let dumperPrefix = null;
294
+ function dumperCommand() {
295
+ if (dumperPrefix) return dumperPrefix;
296
+ dumperPrefix = `swift ${shq(DUMPER_SOURCE)}`;
297
+ const version = runOrNull("swiftc --version", { timeout: 15000 });
298
+ const dir = version ? dumperCacheDir() : null;
299
+ if (!dir) return dumperPrefix;
300
+ const key = createHash("sha256").update(readFileSync(DUMPER_SOURCE)).update(version).digest("hex").slice(0, 16);
301
+ const binary = join(dir, `ui-tree-dumper-${key}`);
302
+ if (!existsSync(binary)) {
303
+ const staging = `${binary}.${process.pid}`;
304
+ const built = run(`swiftc -O -o ${shq(staging)} ${shq(DUMPER_SOURCE)}`, { timeout: 120000 });
305
+ if (isFailure(built) || !existsSync(staging)) return dumperPrefix;
306
+ try { renameSync(staging, binary); } catch { return dumperPrefix; }
307
+ }
308
+ dumperPrefix = shq(binary);
309
+ return dumperPrefix;
310
+ }
311
+
218
312
  // Numeric coordinates / scales: reject anything that is not a plain number so it
219
313
  // can be interpolated bare.
220
314
  function num(value, label) {
@@ -249,6 +343,39 @@ function hasCommand(cmd) {
249
343
  }
250
344
 
251
345
  const HAS_XCRUN = hasCommand("xcrun");
346
+ const HAS_SIPS = hasCommand("sips");
347
+
348
+ // The inline image goes to the model on every capture, and a simulator PNG is
349
+ // ~2.9 MB (3.9 MB as base64). sips turns it into an 800 px JPEG of ~60 KB in
350
+ // 0.12 s, so that is the default; the full-resolution file stays on disk.
351
+ function captureOptions(args) {
352
+ const format = args.format === "png" ? "png" : "jpeg";
353
+ const maxWidth = num(args.max_width ?? 800, "max_width");
354
+ const quality = num(args.quality ?? 80, "quality");
355
+ if (!Number.isInteger(maxWidth) || maxWidth < 0) throw new Error(`Invalid max_width: ${args.max_width} (0 or a positive pixel count)`);
356
+ if (!Number.isInteger(quality) || quality < 1 || quality > 100) throw new Error(`Invalid quality: ${args.quality} (1-100)`);
357
+ return { format, maxWidth, quality };
358
+ }
359
+
360
+ function inlineCapture(f, { format, maxWidth, quality }) {
361
+ const asIs = (note) => ({ type: "image", data: readFileSync(f).toString("base64"), mimeType: "image/png", path: f, note });
362
+ if (format === "png" && maxWidth === 0) return asIs("");
363
+ if (!HAS_SIPS) return asIs(" (sips unavailable, full-resolution PNG returned)");
364
+ const out = f.replace(/\.png$/, "") + (format === "jpeg" ? ".jpg" : `_${maxWidth}.png`);
365
+ discardStale(out);
366
+ const scale = maxWidth ? ` -Z ${maxWidth}` : "";
367
+ const options = format === "jpeg" ? ` -s formatOptions ${quality}` : "";
368
+ const converted = run(`sips${scale} -s format ${format}${options} ${shq(f)} --out ${shq(out)}`);
369
+ if (isFailure(converted) || !existsSync(out)) return asIs(" (sips failed, full-resolution PNG returned)");
370
+ return {
371
+ type: "image",
372
+ data: readFileSync(out).toString("base64"),
373
+ mimeType: format === "jpeg" ? "image/jpeg" : "image/png",
374
+ path: out,
375
+ source: f,
376
+ note: ` (full-resolution PNG: ${f})`,
377
+ };
378
+ }
252
379
 
253
380
  // idb (Facebook) is the real iOS Simulator UI driver - `simctl io` has NO tap/swipe/type
254
381
  // operation, so tap/swipe/type/geometry route through idb. Resolve its binary + set a PATH
@@ -325,9 +452,9 @@ function iosDevice(id) {
325
452
  const IOS_TOOLS = [
326
453
  { name: "ios_list_devices", description: "List all available iOS simulators and their state", inputSchema: { type: "object", properties: {} } },
327
454
  { 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." } } } },
455
+ { 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
456
  { 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"] } },
457
+ { 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
458
  { name: "ios_type_text", description: "Type text on iOS simulator", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
332
459
  { 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
460
  { 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 +484,7 @@ const IOS_TOOLS = [
357
484
  { 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
485
  { 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
486
  { 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"] } },
487
+ { 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
488
  { 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
489
  { 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
490
  { 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 +492,12 @@ const IOS_TOOLS = [
365
492
  { 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
493
  ];
367
494
 
495
+ // Pure-JS members of the ios_* family: comparing two PNGs or listing crash
496
+ // reports needs no simulator, so a missing Xcode must not refuse them.
497
+ const IOS_TOOLS_WITHOUT_XCRUN = new Set(["ios_visual_diff", "ios_list_crashes", "ios_xcresult"]);
498
+
368
499
  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`;
500
+ 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
501
  const did = (n) => { try { return iosDevice(n); } catch (e) { return null; } };
371
502
 
372
503
  switch (name) {
@@ -393,11 +524,13 @@ async function handleIOS(name, args, ctx = {}) {
393
524
  const parent = dirname(f);
394
525
  if (!existsSync(parent)) return `ERROR: directory does not exist: ${parent}`;
395
526
  }
396
- run(`xcrun simctl io ${d} screenshot ${shq(f)}`);
527
+ const inline = args.path ? null : captureOptions(args);
528
+ discardStale(f);
529
+ const shot = run(`xcrun simctl io ${d} screenshot ${shq(f)}`);
530
+ if (isFailure(shot)) return shot;
397
531
  if (!existsSync(f)) return "ERROR: Screenshot failed";
398
532
  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 };
533
+ return inlineCapture(f, inline);
401
534
  }
402
535
  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
536
  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 +549,19 @@ async function handleIOS(name, args, ctx = {}) {
416
549
  }
417
550
  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
551
  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}`; }
552
+ case "ios_set_locale": {
553
+ const d = iosDevice(args.device_id);
554
+ const bid = sanitizeId(args.bundle_id);
555
+ const written = run(`xcrun simctl spawn ${d} defaults write ${bid} AppleLanguages -array ${shq(args.language)}`);
556
+ if (isFailure(written)) return written;
557
+ // An app that is not running has nothing to terminate; that is not a
558
+ // failure of the locale change, which applies at the next launch.
559
+ const stopped = run(`xcrun simctl terminate ${d} ${bid}`);
560
+ if (isFailure(stopped) && !/No such process|found nothing to terminate|not running/i.test(stopped)) return stopped;
561
+ const launched = run(`xcrun simctl launch ${d} ${bid}`);
562
+ if (isFailure(launched)) return launched;
563
+ return `Locale: ${args.language} (${bid} relaunched)`;
564
+ }
420
565
  case "ios_open_url": { const d = iosDevice(args.device_id); return run(`xcrun simctl openurl ${d} ${shq(args.url)}`) || `Opened: ${args.url}`; }
421
566
  case "ios_status_bar": {
422
567
  const d = iosDevice(args.device_id);
@@ -481,11 +626,10 @@ async function handleIOS(name, args, ctx = {}) {
481
626
  }
482
627
  case "ios_add_media": { const d = iosDevice(args.device_id); return run(`xcrun simctl addmedia ${d} ${shq(args.file_path)}`) || "Media added"; }
483
628
  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"}`); }
629
+ 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
630
  case "ios_erase_device": { const d = iosDevice(args.device_id); return run(`xcrun simctl erase ${d}`) || "Device erased"; }
486
631
  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";
632
+ if (!existsSync(DUMPER_SOURCE)) return "ui-tree-dumper.swift not found";
489
633
  // Path check first, matching the Android sibling: the AX dump takes up to
490
634
  // 15s, too expensive to spend before rejecting a bad destination.
491
635
  if (args.path) {
@@ -493,16 +637,18 @@ async function handleIOS(name, args, ctx = {}) {
493
637
  if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
494
638
  }
495
639
  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;
640
+ const tree = runCapture(`${dumperCommand()} ${depth}`, { timeout: 15000 });
641
+ if (isFailure(tree)) return tree;
642
+ const dumperError = dumperErrorOf(tree);
643
+ if (dumperError) return `${ERROR_PREFIX}${dumperError}`;
644
+ if (!args.path) return tree;
498
645
  writeFileSync(String(args.path), tree);
499
646
  return fileResult(`UI tree written: ${args.path}`, String(args.path));
500
647
  }
501
648
  case "ios_accessibility_audit": {
502
- const script = join(__dirname, "ui-tree-dumper.swift");
503
- if (!existsSync(script)) return "ui-tree-dumper.swift not found";
649
+ if (!existsSync(DUMPER_SOURCE)) return "ui-tree-dumper.swift not found";
504
650
  const depth = num(args.max_depth ?? 10, "max_depth");
505
- const treeJson = run(`swift ${shq(script)} ${depth}`, { timeout: 30000 });
651
+ const treeJson = run(`${dumperCommand()} ${depth}`, { timeout: 30000 });
506
652
  let tree = null;
507
653
  try {
508
654
  tree = JSON.parse(treeJson);
@@ -553,14 +699,14 @@ async function handleIOS(name, args, ctx = {}) {
553
699
  const binaryName = appName.replace(".app", "");
554
700
  const binaryPath = `${appDir}/${binaryName}`;
555
701
  // 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" }); }
702
+ 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
703
  // 3. Debug tool leak check
559
704
  const debugSymbols = run(`nm ${shq(binaryPath)} 2>/dev/null | grep -iE "FLEX|Reveal|Stetho|Flipper|CocoaDebug|Pulse" | head -10`);
560
705
  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");
706
+ // 4. Code signing. codesign exits 1 on an unsigned bundle, and run()'s
707
+ // failure marker does not carry its "not signed" line; runCapture does.
708
+ const codesign = runCapture(`codesign -dvv ${shq(appDir)} 2>&1`);
709
+ const hasSignature = codesign && !isFailure(codesign) && !codesign.includes("not signed");
564
710
  findings.push({ check: "code_signing", status: hasSignature ? "pass" : "critical", detail: hasSignature ? "Signed" : "NOT SIGNED - will be rejected" });
565
711
  // 5. Provisioning profile
566
712
  const provProfile = run(`ls ${shq(appDir + "/embedded.mobileprovision")} 2>/dev/null`);
@@ -689,7 +835,16 @@ async function handleIOS(name, args, ctx = {}) {
689
835
  if (args.project && args.workspace) return "ERROR: pass project OR workspace, not both";
690
836
  const action = args.action || "build";
691
837
  const config = args.configuration || "Release";
692
- const dest = args.destination || "generic/platform=iOS Simulator";
838
+ let dest = args.destination;
839
+ if (!dest) {
840
+ if (action === "test") {
841
+ const d = did();
842
+ 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.`;
843
+ dest = `platform=iOS Simulator,id=${d}`;
844
+ } else {
845
+ dest = "generic/platform=iOS Simulator";
846
+ }
847
+ }
693
848
  const id = `xcresult-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
694
849
  const xcresultPath = join(SCREENSHOT_DIR, `${id}.xcresult`);
695
850
  const logPath = join(SCREENSHOT_DIR, `${id}.log`);
@@ -804,13 +959,21 @@ async function handleIOS(name, args, ctx = {}) {
804
959
  return diffImagePath ? fileResult(report, diffImagePath) : report;
805
960
  }
806
961
  case "ios_leaks": {
807
- let pid = Number.isInteger(args.pid) ? args.pid : null;
962
+ // The schema validator accepts a numeric string for `pid`, so coerce
963
+ // rather than re-check the raw type here.
964
+ const pidNum = args.pid === undefined || args.pid === null || args.pid === "" ? NaN : Number(args.pid);
965
+ let pid = Number.isInteger(pidNum) && pidNum > 0 ? pidNum : null;
808
966
  if (!pid && args.bundle_id) {
809
967
  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);
968
+ const id = sanitizeId(args.bundle_id);
969
+ const out = run(`xcrun simctl spawn ${d} launchctl list 2>/dev/null`);
970
+ if (isFailure(out)) return out;
971
+ // Whole-token match: a substring grep of com.x also matched com.xy, and
972
+ // the dots were regex wildcards.
973
+ const label = new RegExp(`(^|[^A-Za-z0-9._-])${id.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}($|[^A-Za-z0-9._-])`);
974
+ const line = out.split("\n").find((l) => /^\d+\s/.test(l) && label.test(l));
975
+ if (!line) return `ERROR: no running process for ${args.bundle_id} on the booted simulator; launch it first`;
976
+ pid = parseInt(line, 10);
814
977
  }
815
978
  if (!pid) return "ERROR: pass pid or bundle_id";
816
979
 
@@ -823,8 +986,12 @@ async function handleIOS(name, args, ctx = {}) {
823
986
  const diffArg = mode === "diff" ? ` --diffFrom=${shq(args.baseline_graph)}` : "";
824
987
  // leaks exits 1 when it FINDS leaks, so a non-zero status is a result and
825
988
  // 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);
989
+ // spawnCollect rather than execSync: a two-minute scan must not block the
990
+ // event loop, and its output is capped instead of killing the child.
991
+ const res = await spawnCollect(`leaks ${pid}${diffArg}${graphOut} 2>&1`, { timeout: 120000, signal: ctx.signal });
992
+ if (res.timedOut) return `${ERROR_PREFIX}leaks did not finish within 120s for pid ${pid}`;
993
+ if (res.aborted) return `${ERROR_PREFIX}leaks cancelled`;
994
+ const parsed = parseLeaksOutput(res.output);
828
995
  return JSON.stringify({
829
996
  pid,
830
997
  mode,
@@ -847,14 +1014,28 @@ async function handleIOS(name, args, ctx = {}) {
847
1014
  const only = args.test_identifier ? ` -only-testing:${shq(args.test_identifier)}` : "";
848
1015
  // xcodebuild exits non-zero when the audit finds anything, because each
849
1016
  // 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
- );
1017
+ // spawnCollect: a 15-minute test run through execSync blocked the event
1018
+ // loop for its whole duration, and its 1MB maxBuffer killed the child on
1019
+ // any real build log.
1020
+ const stopHeartbeat = startHeartbeat(ctx, "xcodebuild test (accessibility audit)");
1021
+ let build;
1022
+ try {
1023
+ build = await spawnCollect(
1024
+ `xcodebuild test ${container} -scheme ${shq(args.scheme)} -destination ${shq(`platform=iOS Simulator,id=${d}`)} -resultBundlePath ${shq(bundle)}${only} 2>&1`,
1025
+ { timeout: 900000, signal: ctx.signal },
1026
+ );
1027
+ } finally {
1028
+ stopHeartbeat();
1029
+ }
854
1030
  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);
1031
+ const reason = build.timedOut
1032
+ ? "xcodebuild did not finish within 900s; no result bundle was written"
1033
+ : build.aborted
1034
+ ? "the request was cancelled before xcodebuild finished"
1035
+ : "xcodebuild produced no result bundle; the build failed before any test ran";
1036
+ return JSON.stringify({ measurable: false, reason, test_ran: false, findings: [] }, null, 2);
856
1037
  }
857
- const json = runCapture(`xcrun xcresulttool get test-results tests --path ${shq(bundle)} --format json 2>&1`, { timeout: 60000 });
1038
+ const json = (await spawnCollect(`xcrun xcresulttool get test-results tests --path ${shq(bundle)} --format json 2>&1`, { timeout: 60000, signal: ctx.signal })).output;
858
1039
  const r = parseAuditResults(json, args.test_identifier || null);
859
1040
  return JSON.stringify({
860
1041
  measurable: r.measurable,
@@ -897,14 +1078,23 @@ async function handleIOS(name, args, ctx = {}) {
897
1078
  // model-controlled device_id a command injection.
898
1079
  function adbFlag(id) { return id ? `-s ${deviceSerial(id)}` : ""; }
899
1080
 
1081
+ // One rule for both launchers: `activity` is the class, relative or fully
1082
+ // qualified, and the component is package/activity. A value that already
1083
+ // carries the package (com.x/.Main) is used as it is.
1084
+ function androidComponent(packageName, activity) {
1085
+ const pkg = sanitizeId(packageName);
1086
+ const act = sanitizeId(activity);
1087
+ return act.includes("/") ? act : `${pkg}/${act}`;
1088
+ }
1089
+
900
1090
  const ANDROID_TOOLS = [
901
1091
  { 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." } } } },
1092
+ { 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
1093
  { 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
1094
  { 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"] } },
1095
+ { 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
1096
  { 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"] } },
1097
+ { 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
1098
  { 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
1099
  { name: "android_list_packages", description: "List installed Android packages", inputSchema: { type: "object", properties: { filter: { type: "string" }, device_id: { type: "string" } } } },
910
1100
  { name: "android_go_home", description: "Press Android home button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
@@ -924,13 +1114,26 @@ const ANDROID_TOOLS = [
924
1114
  { 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
1115
  { 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
1116
  { 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"] } },
1117
+ { 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
1118
  { 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
1119
  { 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
1120
  { 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
1121
  { 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
1122
  ];
933
1123
 
1124
+ // The snapshot shape the tool emits, and the shape diffMeminfo accepts back
1125
+ // as a baseline. One function so the two cannot drift apart again.
1126
+ function meminfoPayload(snapshot) {
1127
+ return {
1128
+ measurable: snapshot.measurable,
1129
+ reason: snapshot.reason,
1130
+ pss_kb: snapshot.pss,
1131
+ total_pss_kb: snapshot.totalPssKb,
1132
+ total_rss_kb: snapshot.totalRssKb,
1133
+ total_swap_kb: snapshot.totalSwapKb,
1134
+ };
1135
+ }
1136
+
934
1137
  async function handleAndroid(name, args, ctx = {}) {
935
1138
  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
1139
  const df = adbFlag(args.device_id);
@@ -943,21 +1146,24 @@ async function handleAndroid(name, args, ctx = {}) {
943
1146
  const parent = dirname(f);
944
1147
  if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
945
1148
  }
946
- run(`adb ${df} shell screencap -p /sdcard/_mcp_screen.png`);
947
- run(`adb ${df} pull /sdcard/_mcp_screen.png ${shq(f)}`);
1149
+ const inline = args.path ? null : captureOptions(args);
1150
+ discardStale(f);
1151
+ const cap = run(`adb ${df} shell screencap -p /sdcard/_mcp_screen.png`);
1152
+ if (isFailure(cap)) return cap;
1153
+ const pulled = run(`adb ${df} pull /sdcard/_mcp_screen.png ${shq(f)}`);
948
1154
  run(`adb ${df} shell rm /sdcard/_mcp_screen.png`);
1155
+ if (isFailure(pulled)) return pulled;
949
1156
  if (!existsSync(f)) return "ERROR: Screenshot failed";
950
1157
  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 };
1158
+ return inlineCapture(f, inline);
953
1159
  }
954
1160
  case "android_tap": return run(`adb ${df} shell input tap ${num(args.x, "x")} ${num(args.y, "y")}`) || `Tapped (${args.x}, ${args.y})`;
955
1161
  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
1162
  // `adb shell input text` wants spaces as %s; single-quote the result so the
957
1163
  // 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}`;
1164
+ case "android_type_text": return run(`adb ${df} shell input text ${remoteShq(String(args.text ?? "").replace(/ /g, "%s"))}`) || `Typed: ${args.text}`;
959
1165
  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`;
1166
+ 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
1167
  case "android_stop_app": return run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`) || "Stopped";
962
1168
  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
1169
  case "android_go_home": return run(`adb ${df} shell input keyevent 3`) || "Home";
@@ -968,9 +1174,12 @@ async function handleAndroid(name, args, ctx = {}) {
968
1174
  const parent = dirname(f);
969
1175
  if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
970
1176
  }
971
- run(`adb ${df} shell uiautomator dump /sdcard/_mcp_ui.xml`);
972
- run(`adb ${df} pull /sdcard/_mcp_ui.xml ${shq(f)}`);
1177
+ discardStale(f);
1178
+ const dumped = run(`adb ${df} shell uiautomator dump /sdcard/_mcp_ui.xml`);
1179
+ if (isFailure(dumped)) return dumped;
1180
+ const pulled = run(`adb ${df} pull /sdcard/_mcp_ui.xml ${shq(f)}`);
973
1181
  run(`adb ${df} shell rm /sdcard/_mcp_ui.xml`);
1182
+ if (isFailure(pulled)) return pulled;
974
1183
  if (!existsSync(f)) return "ERROR: UI dump failed";
975
1184
  if (args.filter === "interactive") {
976
1185
  const elements = interactiveElements(readFileSync(f, "utf-8"));
@@ -1076,7 +1285,7 @@ async function handleAndroid(name, args, ctx = {}) {
1076
1285
  case "android_uninstall_app": return run(`adb ${df} shell pm uninstall ${sanitizeId(args.package_name)}`) || "Uninstalled";
1077
1286
  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
1287
  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}`;
1288
+ case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${remoteShq(args.url)}`) || `Opened: ${args.url}`;
1080
1289
  case "android_clear_app_data": return run(`adb ${df} shell pm clear ${sanitizeId(args.package_name)}`) || "Cleared";
1081
1290
  case "android_accessibility_audit": {
1082
1291
  run(`adb ${df} shell uiautomator dump /sdcard/_mcp_a11y.xml`);
@@ -1101,8 +1310,8 @@ async function handleAndroid(name, args, ctx = {}) {
1101
1310
  }
1102
1311
  case "android_launch_time": {
1103
1312
  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`);
1313
+ const component = androidComponent(args.package_name, args.activity || ".MainActivity");
1314
+ const result = run(`adb ${df} shell am start -W -n ${component} 2>&1`);
1106
1315
  const parsed = parseLaunchOutput(result);
1107
1316
  return JSON.stringify({
1108
1317
  package: args.package_name,
@@ -1122,7 +1331,7 @@ async function handleAndroid(name, args, ctx = {}) {
1122
1331
  if (!existsSync(p)) return `ERROR: APK not found at ${p}`;
1123
1332
  const findings = [];
1124
1333
  // 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`);
1334
+ const aapt = runOrNull(`aapt2 dump badging ${shq(p)} 2>/dev/null`) || runOrNull(`aapt dump badging ${shq(p)} 2>/dev/null`);
1126
1335
  if (aapt) {
1127
1336
  const pkg = aapt.match(/package: name='([^']*)'/)?.[1];
1128
1337
  const versionName = aapt.match(/versionName='([^']*)'/)?.[1];
@@ -1143,19 +1352,29 @@ async function handleAndroid(name, args, ctx = {}) {
1143
1352
  } else {
1144
1353
  findings.push({ check: "aapt", status: "warning", detail: "aapt2/aapt not found - install Android SDK Build-Tools for full audit" });
1145
1354
  }
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");
1355
+ // 2. Signing check. apksigner exits 1 on a failed verification with the
1356
+ // verdict on stdout, so the output is captured whatever the status.
1357
+ const signingInfo = runCapture(`apksigner verify --print-certs ${shq(p)} 2>&1`);
1358
+ if (/DOES NOT VERIFY/.test(signingInfo)) {
1359
+ findings.push({ check: "signing", status: "critical", detail: signingInfo.slice(0, 500) });
1360
+ } else if (isFailure(signingInfo) || /command not found|No such file or directory/i.test(signingInfo)) {
1361
+ findings.push({ check: "signing", status: "warning", detail: "apksigner not found - install Android SDK Build-Tools to verify the signature" });
1362
+ } else if (/^(ERROR|Exception)/m.test(signingInfo)) {
1363
+ findings.push({ check: "signing", status: "warning", detail: signingInfo.slice(0, 500) });
1364
+ } else {
1365
+ const hasV2 = signingInfo.includes("v2 scheme") || (runOrNull(`apksigner verify -v ${shq(p)} 2>&1`) || "").includes("Verified using v2");
1150
1366
  findings.push({ check: "signing", status: "pass", detail: "APK is signed" });
1151
1367
  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
1368
  }
1155
1369
  // 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 {}
1370
+ try {
1371
+ const mb = statSync(p).size / 1048576;
1372
+ 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" });
1373
+ } catch (e) {
1374
+ findings.push({ check: "apk_size", status: "warning", detail: `could not read the file size: ${e.message}` });
1375
+ }
1157
1376
  // 4. ProGuard/R8 check
1158
- const hasMapping = run(`unzip -l ${shq(p)} 2>/dev/null | grep -c "classes.dex"`)?.trim();
1377
+ const hasMapping = runOrNull(`unzip -l ${shq(p)} 2>/dev/null | grep -c "classes.dex"`);
1159
1378
  const dexCount = parseInt(hasMapping) || 0;
1160
1379
  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
1380
  // Summary
@@ -1177,18 +1396,9 @@ async function handleAndroid(name, args, ctx = {}) {
1177
1396
  return "ERROR: baseline_json is not valid JSON";
1178
1397
  }
1179
1398
  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);
1399
+ 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
1400
  }
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);
1401
+ return JSON.stringify({ package: args.package_name, mode, ...meminfoPayload(snapshot) }, null, 2);
1192
1402
  }
1193
1403
  case "android_list_crashes": {
1194
1404
  const lines = args.lines !== undefined ? num(args.lines, "lines") : 200;
@@ -1224,9 +1434,15 @@ async function handleAndroid(name, args, ctx = {}) {
1224
1434
 
1225
1435
  let _browser = null;
1226
1436
  let _page = null;
1227
-
1228
- async function ensureBrowser(browserType = "chromium") {
1229
- if (_browser && _page) return _page;
1437
+ let _engine = null;
1438
+
1439
+ // A request for a different engine closes the current browser and relaunches;
1440
+ // a partial launch (newContext/newPage threw) is closed rather than left as a
1441
+ // browser with no page that the next call would launch beside.
1442
+ async function ensureBrowser(browserType) {
1443
+ const wanted = browserType || _engine || "chromium";
1444
+ if (_browser && _page && _engine === wanted) return _page;
1445
+ if (_browser || _page) await closeBrowser();
1230
1446
  let pw;
1231
1447
  try {
1232
1448
  pw = await import("playwright");
@@ -1234,10 +1450,17 @@ async function ensureBrowser(browserType = "chromium") {
1234
1450
  throw new Error("Web tools require Playwright. Install once with: npm i -g playwright && npx playwright install chromium");
1235
1451
  }
1236
1452
  const engines = { chromium: pw.chromium, webkit: pw.webkit, firefox: pw.firefox };
1237
- const engine = engines[browserType] || pw.chromium;
1453
+ const engine = engines[wanted];
1454
+ if (!engine) throw new Error(`unknown browser engine: ${wanted}`);
1238
1455
  _browser = await engine.launch({ headless: true });
1239
- const ctx = await _browser.newContext();
1240
- _page = await ctx.newPage();
1456
+ try {
1457
+ const ctx = await _browser.newContext();
1458
+ _page = await ctx.newPage();
1459
+ } catch (e) {
1460
+ await closeBrowser();
1461
+ throw e;
1462
+ }
1463
+ _engine = wanted;
1241
1464
  return _page;
1242
1465
  }
1243
1466
 
@@ -1246,6 +1469,7 @@ async function closeBrowser() {
1246
1469
  try { await _browser?.close(); } catch {}
1247
1470
  _page = null;
1248
1471
  _browser = null;
1472
+ _engine = null;
1249
1473
  }
1250
1474
 
1251
1475
  const WEB_TOOLS = [
@@ -1350,11 +1574,17 @@ const AGENT_TOOLS = [
1350
1574
  // a batch able to nest itself has no recursion bound.
1351
1575
  async function dispatchStep(tool, stepArgs) {
1352
1576
  if (tool.startsWith("agent_")) throw new Error(`Nested batch steps are not supported: ${tool}`);
1577
+ // The same boundary check a direct call gets: a batched step used to reach
1578
+ // the handler with its enum/type/required contract unenforced.
1579
+ const schemaError = validateArgs(tool, stepArgs);
1580
+ if (schemaError) throw new Error(`invalid arguments: ${schemaError}`);
1353
1581
  let result;
1354
1582
  if (tool.startsWith("ios_")) result = await handleIOS(tool, stepArgs);
1355
1583
  else if (tool.startsWith("android_")) result = await handleAndroid(tool, stepArgs);
1356
1584
  else if (tool.startsWith("web_")) result = await handleWeb(tool, stepArgs);
1357
1585
  else if (tool.startsWith("design_")) result = await handleDesign(tool, stepArgs, designCtx);
1586
+ else if (tool.startsWith("code_")) result = await handleCode(tool, stepArgs, {});
1587
+ else if (tool.startsWith("pass_")) result = await handlePass(tool, stepArgs);
1358
1588
  else throw new Error(`Unknown tool: ${tool}`);
1359
1589
  // Every handler's `default:` arm returns null, and that is its only source of
1360
1590
  // null, so null means "I do not recognise this tool". The throw below used to
@@ -1433,7 +1663,15 @@ async function handleAgent(name, args) {
1433
1663
 
1434
1664
  // ── Server ──
1435
1665
 
1436
- const ALL_TOOLS = [...IOS_TOOLS, ...ANDROID_TOOLS, ...WEB_TOOLS, ...AGENT_TOOLS, ...DESIGN_TOOLS];
1666
+ const ALL_TOOLS = [
1667
+ ...IOS_TOOLS,
1668
+ ...ANDROID_TOOLS,
1669
+ ...WEB_TOOLS,
1670
+ ...AGENT_TOOLS,
1671
+ ...DESIGN_TOOLS,
1672
+ ...CODE_TOOLS,
1673
+ ...PASS_TOOLS,
1674
+ ];
1437
1675
 
1438
1676
  // Name -> inputSchema, so the CallTool boundary can enforce the declared shape.
1439
1677
  const TOOL_SCHEMAS = new Map(ALL_TOOLS.map((t) => [t.name, t.inputSchema || {}]));
@@ -1497,6 +1735,11 @@ const READ_ONLY_TOOLS = new Set([
1497
1735
  "android_list_crashes",
1498
1736
  "web_screenshot", "web_get_text",
1499
1737
  "design_mock_detect", "design_scenario_inventory", "design_ui_geometry", "design_visual_compare",
1738
+ // Seven of the eight. Every code_* tool reads source and answers; the one
1739
+ // that is not here stops a cached server.
1740
+ ...CODE_READ_ONLY,
1741
+ // pass_build writes a file the caller named; the other three only read.
1742
+ ...PASS_READ_ONLY,
1500
1743
  ]);
1501
1744
 
1502
1745
  // Irreversible on the target device: data loss the caller cannot undo.
@@ -1518,6 +1761,7 @@ const IDEMPOTENT_TOOLS = new Set([
1518
1761
  "android_set_locale", "android_set_location", "android_grant_permission", "android_revoke_permission",
1519
1762
  "android_set_orientation",
1520
1763
  "web_close",
1764
+ ...CODE_IDEMPOTENT,
1521
1765
  ]);
1522
1766
 
1523
1767
  // Web tools reach arbitrary sites; everything else talks to a local simulator,
@@ -1611,7 +1855,7 @@ const bundleAuditSchema = (pathKey) => ({
1611
1855
  items: {
1612
1856
  type: "object",
1613
1857
  properties: {
1614
- status: { type: "string", enum: ["pass", "warning", "critical"] },
1858
+ status: { type: "string", enum: ["pass", "warning", "critical", "info"] },
1615
1859
  rule: { type: "string" },
1616
1860
  message: { type: "string" },
1617
1861
  },
@@ -1621,6 +1865,10 @@ const bundleAuditSchema = (pathKey) => ({
1621
1865
  });
1622
1866
 
1623
1867
  const OUTPUT_SCHEMAS = {
1868
+ // Every code_* root is an object. A `{type:"array"}` root on ONE tool once
1869
+ // made all 78 unavailable in Claude Code - see the note further down.
1870
+ ...CODE_OUTPUT_SCHEMAS,
1871
+ ...PASS_OUTPUT_SCHEMAS,
1624
1872
  ios_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
1625
1873
  android_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
1626
1874
 
@@ -1664,7 +1912,7 @@ const OUTPUT_SCHEMAS = {
1664
1912
  max_diff_pct: { type: "number" },
1665
1913
  baseline: { type: "string" },
1666
1914
  current: { type: "string" },
1667
- diff_image: { type: "string" },
1915
+ diff_image: { type: ["string", "null"] },
1668
1916
  },
1669
1917
  },
1670
1918
 
@@ -1694,9 +1942,11 @@ const OUTPUT_SCHEMAS = {
1694
1942
  required: ["package", "cold_start"],
1695
1943
  properties: {
1696
1944
  package: { type: "string" },
1697
- cold_start: { type: "boolean" },
1945
+ launch_state: { type: ["string", "null"] },
1946
+ cold_start: { type: ["boolean", "null"] },
1698
1947
  total_time_ms: { type: ["integer", "null"] },
1699
1948
  wait_time_ms: { type: ["integer", "null"] },
1949
+ error: { type: ["string", "null"] },
1700
1950
  raw: { type: "string" },
1701
1951
  },
1702
1952
  },
@@ -1733,9 +1983,11 @@ const OUTPUT_SCHEMAS = {
1733
1983
  items: {
1734
1984
  type: "object",
1735
1985
  properties: {
1986
+ step: { type: "integer" },
1736
1987
  tool: { type: "string" },
1737
1988
  status: { type: "string", enum: ["ok", "error"] },
1738
- output: { type: "string" },
1989
+ result: { type: "string" },
1990
+ error: { type: "string" },
1739
1991
  },
1740
1992
  },
1741
1993
  },
@@ -1773,7 +2025,8 @@ const designCtx = {
1773
2025
  adbFlag,
1774
2026
  idb,
1775
2027
  hasIdb: HAS_IDB,
1776
- dumperScript: join(__dirname, "ui-tree-dumper.swift"),
2028
+ dumperScript: DUMPER_SOURCE,
2029
+ dumperCommand,
1777
2030
  };
1778
2031
 
1779
2032
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: ANNOTATED_TOOLS }));
@@ -1813,6 +2066,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1813
2066
  else if (name.startsWith("web_")) result = await handleWeb(name, args || {});
1814
2067
  else if (name.startsWith("agent_")) result = await handleAgent(name, args || {});
1815
2068
  else if (name.startsWith("design_")) result = await handleDesign(name, args || {}, designCtx);
2069
+ // ...ctx, unlike the design_ line above: code-intel needs `signal` so an
2070
+ // aborted call cancels the in-flight LSP request instead of orphaning it.
2071
+ else if (name.startsWith("code_")) result = await handleCode(name, args || {}, ctx);
2072
+ else if (name.startsWith("pass_")) result = await handlePass(name, args || {});
1816
2073
  else return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
1817
2074
  // Same reason as dispatchStep: a handler returns null only from its
1818
2075
  // `default:` arm, so an unrecognised name that happens to carry a known
@@ -1821,11 +2078,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1821
2078
  if (result === null || result === undefined) {
1822
2079
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
1823
2080
  }
2081
+ pruneWorkDir(SCREENSHOT_DIR, { keep: promisedPaths(result) });
1824
2082
  if (typeof result === "object" && result?.type === "image") {
1825
2083
  return {
1826
2084
  content: [
1827
2085
  { type: "image", data: result.data, mimeType: result.mimeType },
1828
- { type: "text", text: `Screenshot: ${result.path}` },
2086
+ { type: "text", text: `Screenshot: ${result.path}${result.note ?? ""}` },
1829
2087
  ...(result.path ? [{ type: "resource_link", uri: `file://${result.path}`, name: basename(result.path), mimeType: result.mimeType }] : []),
1830
2088
  ],
1831
2089
  };
@@ -1882,8 +2140,8 @@ function stopRecorders() {
1882
2140
  }
1883
2141
  }
1884
2142
 
1885
- process.on("SIGTERM", async () => { stopRecorders(); await closeBrowser(); process.exit(0); });
1886
- process.on("SIGINT", async () => { stopRecorders(); await closeBrowser(); process.exit(0); });
2143
+ process.on("SIGTERM", async () => { stopRecorders(); shutdownAllLsp(); await closeBrowser(); process.exit(0); });
2144
+ process.on("SIGINT", async () => { stopRecorders(); shutdownAllLsp(); await closeBrowser(); process.exit(0); });
1887
2145
 
1888
2146
  const transport = new StdioServerTransport();
1889
2147
  await server.connect(transport);