@mmerterden/dev-toolkit-mcp 2.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +708 -0
  2. package/LICENSE +21 -0
  3. package/README.md +387 -0
  4. package/index.js +1277 -0
  5. package/package.json +80 -0
  6. package/tools/design-check/content-cardinality.js +204 -0
  7. package/tools/design-check/geometry.js +140 -0
  8. package/tools/design-check/index.js +213 -0
  9. package/tools/design-check/mock-detect.js +213 -0
  10. package/tools/design-check/report.js +590 -0
  11. package/tools/design-check/scan.js +91 -0
  12. package/tools/design-check/scenario-inventory.js +598 -0
  13. package/tools/design-check/visual-compare.js +961 -0
  14. package/tools/ios-app-store-audit/context.js +180 -0
  15. package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
  16. package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
  17. package/tools/ios-app-store-audit/index.js +164 -0
  18. package/tools/ios-app-store-audit/models.js +47 -0
  19. package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
  20. package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
  21. package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
  22. package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
  23. package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
  24. package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
  25. package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
  26. package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
  27. package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
  28. package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
  29. package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
  30. package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
  31. package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
  32. package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
  33. package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
  34. package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
  35. package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
  36. package/tools/ios-app-store-audit/rules/team-id.js +62 -0
  37. package/tools/ios-testflight/index.js +479 -0
  38. package/ui-tree-dumper.swift +122 -0
package/index.js ADDED
@@ -0,0 +1,1277 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * dev-toolkit-mcp - iOS Simulator & Android Emulator MCP Server
5
+ *
6
+ * 78 tools for mobile device control: screenshot, tap, swipe, type, UI tree,
7
+ * dark mode, accessibility testing, push notifications, location simulation,
8
+ * mock-mode vs Figma design audit (design-check family), and more.
9
+ *
10
+ * Works with: Claude Code, Claude Desktop, Cursor, Windsurf, Copilot CLI, Cline, Zed
11
+ */
12
+
13
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
16
+ import { execSync } from "child_process";
17
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from "fs";
18
+ import { join, dirname } from "path";
19
+ import { fileURLToPath } from "url";
20
+ import { runAudit as runAppStoreAudit } from "./tools/ios-app-store-audit/index.js";
21
+ import {
22
+ exportIpa,
23
+ listProviders,
24
+ resolveAuth,
25
+ validateApp,
26
+ } from "./tools/ios-testflight/index.js";
27
+ import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
31
+ if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true });
32
+
33
+ // ── Helpers ──
34
+
35
+ // Marker prefix for command failures. The CallTool dispatch turns any result
36
+ // carrying it into an MCP error result (isError: true), so a host - and the
37
+ // pipeline gates reading these results - can tell failure from success.
38
+ const ERROR_PREFIX = "ERROR: ";
39
+
40
+ // A failing CLI often dumps its entire usage text (simctl's is ~3 KB). Inlining
41
+ // that spends the caller's context on a help page, so keep only the actionable
42
+ // head of the message.
43
+ const ERROR_MAX_CHARS = 600;
44
+
45
+ function truncateError(msg) {
46
+ const flat = String(msg).trim();
47
+ if (flat.length <= ERROR_MAX_CHARS) return flat;
48
+ return `${flat.slice(0, ERROR_MAX_CHARS)}\n... [${flat.length - ERROR_MAX_CHARS} more chars truncated]`;
49
+ }
50
+
51
+ function isFailure(value) {
52
+ return typeof value === "string" && value.startsWith(ERROR_PREFIX);
53
+ }
54
+
55
+ function run(cmd, opts = {}) {
56
+ try {
57
+ return execSync(cmd, { encoding: "utf-8", timeout: 30000, ...opts }).trim();
58
+ } catch (e) {
59
+ return `${ERROR_PREFIX}${truncateError(e.message)}`;
60
+ }
61
+ }
62
+
63
+ function sanitizeId(id) {
64
+ // Bundle IDs / package names: only allow alphanumeric, dots, underscores, hyphens
65
+ if (!id) return id;
66
+ if (!/^[a-zA-Z0-9._\-/]+$/.test(id)) throw new Error(`Invalid identifier: ${id}`);
67
+ return id;
68
+ }
69
+
70
+ // Single-quote a value for POSIX sh. Every command here is built as a string and
71
+ // handed to execSync, i.e. to a shell - and $( ) expands inside DOUBLE quotes,
72
+ // so double-quoting a caller-supplied value does not contain it. Single quotes
73
+ // suppress all expansion; an embedded single quote is closed, escaped, reopened.
74
+ function shq(value) {
75
+ return `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
76
+ }
77
+
78
+ // Numeric coordinates / scales: reject anything that is not a plain number so it
79
+ // can be interpolated bare.
80
+ function num(value, label) {
81
+ const n = Number(value);
82
+ if (!Number.isFinite(n)) throw new Error(`Invalid numeric value for ${label}: ${value}`);
83
+ return n;
84
+ }
85
+
86
+ // Enum-shaped values (permission names, privacy services, keycodes) that are
87
+ // interpolated bare: allow only the shell-inert identifier charset.
88
+ function token(value, label) {
89
+ const s = String(value ?? "");
90
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) throw new Error(`Invalid ${label}: ${value}`);
91
+ return s;
92
+ }
93
+
94
+ function hasCommand(cmd) {
95
+ try {
96
+ execSync(`which ${cmd}`, { encoding: "utf-8", timeout: 5000 });
97
+ return true;
98
+ } catch { return false; }
99
+ }
100
+
101
+ const HAS_XCRUN = hasCommand("xcrun");
102
+
103
+ // idb (Facebook) is the real iOS Simulator UI driver - `simctl io` has NO tap/swipe/type
104
+ // operation, so tap/swipe/type/geometry route through idb. Resolve its binary + set a PATH
105
+ // that includes idb_companion (Homebrew) so the CLI can spawn the companion.
106
+ function resolveIdb() {
107
+ try { const p = execSync("command -v idb 2>/dev/null", { encoding: "utf-8" }).trim(); if (p) return p; } catch {}
108
+ try { const g = execSync("ls -d \"$HOME\"/Library/Python/*/bin/idb 2>/dev/null | head -1", { encoding: "utf-8", shell: "/bin/bash" }).trim(); if (g) return g; } catch {}
109
+ return null;
110
+ }
111
+ // Every user-base bin directory pip could have installed idb into, newest
112
+ // first. resolveIdb globs any Python version, so pinning the companion PATH to
113
+ // one was wrong: on a machine where fb-idb landed under 3.11 the binary
114
+ // resolved fine while this PATH pointed at a 3.9 directory that need not
115
+ // exist, leaving idb_companion findable only if it happened to be on the
116
+ // inherited PATH.
117
+ function pythonUserBins() {
118
+ try {
119
+ const out = execSync('ls -d "$HOME"/Library/Python/*/bin 2>/dev/null', {
120
+ encoding: "utf-8",
121
+ shell: "/bin/bash",
122
+ }).trim();
123
+ return out ? out.split("\n").filter(Boolean).sort().reverse() : [];
124
+ } catch { return []; }
125
+ }
126
+
127
+ const IDB_BIN = resolveIdb();
128
+ const HAS_IDB = !!IDB_BIN;
129
+ // The resolved binary's own directory leads, so whichever install actually won
130
+ // is the one whose siblings (idb_companion) are reachable.
131
+ const IDB_PATH = [...new Set([
132
+ ...(IDB_BIN ? [dirname(IDB_BIN)] : []),
133
+ "/opt/homebrew/bin",
134
+ ...pythonUserBins(),
135
+ ...(process.env.PATH || "").split(":"),
136
+ ].filter(Boolean))].join(":");
137
+ function idb(args, opts = {}) {
138
+ if (!IDB_BIN) return "ERROR: idb not installed. Install: brew install facebook/fb/idb-companion && pip3 install --user fb-idb";
139
+ return run(`"${IDB_BIN}" ${args}`, { env: { ...process.env, PATH: IDB_PATH }, ...opts });
140
+ }
141
+ const HAS_ADB = hasCommand("adb");
142
+
143
+ // ── iOS Tools ──
144
+
145
+ function iosDevice(id) {
146
+ if (id) return id;
147
+ const out = run("xcrun simctl list devices booted -j");
148
+ try {
149
+ const data = JSON.parse(out);
150
+ for (const devs of Object.values(data.devices || {})) {
151
+ for (const d of devs) { if (d.state === "Booted") return d.udid; }
152
+ }
153
+ } catch {}
154
+ throw new Error("No booted iOS Simulator. Use ios_list_devices and ios_boot_device first.");
155
+ }
156
+
157
+ const IOS_TOOLS = [
158
+ { name: "ios_list_devices", description: "List all available iOS simulators and their state", inputSchema: { type: "object", properties: {} } },
159
+ { 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"] } },
160
+ { 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." } } } },
161
+ { 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"] } },
162
+ { 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"] } },
163
+ { name: "ios_type_text", description: "Type text on iOS simulator", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
164
+ { 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"] } },
165
+ { name: "ios_terminate_app", description: "Terminate iOS app", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
166
+ { name: "ios_list_apps", description: "List installed iOS apps", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
167
+ { name: "ios_go_home", description: "Press iOS home button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
168
+ { name: "ios_set_appearance", description: "Set iOS light/dark mode", inputSchema: { type: "object", properties: { mode: { type: "string", enum: ["light", "dark"] }, device_id: { type: "string" } }, required: ["mode"] } },
169
+ { name: "ios_set_content_size", description: "Set iOS Dynamic Type size for accessibility testing", inputSchema: { type: "object", properties: { size: { type: "string", enum: ["extra-small", "small", "medium", "large", "extra-large", "extra-extra-large", "extra-extra-extra-large", "accessibility-medium", "accessibility-large", "accessibility-extra-large", "accessibility-extra-extra-large", "accessibility-extra-extra-extra-large"] }, device_id: { type: "string" } }, required: ["size"] } },
170
+ { name: "ios_set_locale", description: "Change iOS app language (requires restart)", inputSchema: { type: "object", properties: { language: { type: "string", description: "Language code (e.g. tr, en, de, ar)" }, bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["language", "bundle_id"] } },
171
+ { name: "ios_open_url", description: "Open URL or deep link on iOS", inputSchema: { type: "object", properties: { url: { type: "string" }, device_id: { type: "string" } }, required: ["url"] } },
172
+ { name: "ios_status_bar", description: "Override iOS status bar. Use 'preset' for App Store screenshot defaults (clean=9:41/100%, testing=11:11/50%, low-battery=20%, airplane=offline) or 'clear' to revert. Manual time/battery_level override the preset.", inputSchema: { type: "object", properties: { preset: { type: "string", enum: ["clean", "testing", "low-battery", "airplane", "clear"], description: "Predefined status bar preset" }, time: { type: "string" }, battery_level: { type: "number" }, device_id: { type: "string" } } } },
173
+ { name: "ios_push_notification", description: "Send simulated iOS push notification", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, title: { type: "string" }, body: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id", "title", "body"] } },
174
+ { name: "ios_grant_permission", description: "Grant iOS privacy permission", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, service: { type: "string", enum: ["all", "calendar", "contacts-limited", "contacts", "location", "location-always", "photos-add", "photos", "media-library", "microphone", "motion", "reminders", "siri"] }, device_id: { type: "string" } }, required: ["bundle_id", "service"] } },
175
+ { name: "ios_revoke_permission", description: "Revoke iOS privacy permission", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, service: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id", "service"] } },
176
+ { name: "ios_reset_permissions", description: "Reset all iOS permissions for an app", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
177
+ { name: "ios_set_location", description: "Set simulated iOS GPS location", inputSchema: { type: "object", properties: { latitude: { type: "number" }, longitude: { type: "number" }, device_id: { type: "string" } }, required: ["latitude", "longitude"] } },
178
+ { name: "ios_clear_location", description: "Clear simulated iOS location", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
179
+ { name: "ios_set_increase_contrast", description: "Enable/disable iOS Increase Contrast", inputSchema: { type: "object", properties: { enabled: { type: "boolean" }, device_id: { type: "string" } }, required: ["enabled"] } },
180
+ { name: "ios_record_video", description: "Start iOS screen recording", inputSchema: { type: "object", properties: { filename: { type: "string" }, codec: { type: "string", enum: ["h264", "hevc"] }, device_id: { type: "string" } } } },
181
+ { name: "ios_add_media", description: "Add photo/video to iOS photo library", inputSchema: { type: "object", properties: { file_path: { type: "string" }, device_id: { type: "string" } }, required: ["file_path"] } },
182
+ { name: "ios_keychain_reset", description: "Reset iOS simulator keychain", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
183
+ { name: "ios_get_app_container", description: "Get iOS app container path", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, container: { type: "string", enum: ["app", "data", "groups"] }, device_id: { type: "string" } }, required: ["bundle_id"] } },
184
+ { name: "ios_erase_device", description: "Factory reset iOS simulator", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
185
+ { name: "ios_get_ui_tree", description: "Get iOS accessibility UI tree via macOS AX APIs", inputSchema: { type: "object", properties: { max_depth: { type: "number" } } } },
186
+ { name: "ios_accessibility_audit", description: "Audit iOS app accessibility: missing labels, small tap targets (<44pt), missing identifiers. Use scope to filter by identifier prefix (e.g. 'login_' only checks login screen elements).", inputSchema: { type: "object", properties: { max_depth: { type: "number" }, scope: { type: "string", description: "Filter: only audit elements whose identifier starts with this prefix (e.g. 'login_', 'settings_'). Omit to audit all." } } } },
187
+ { name: "ios_biometric", description: "Simulate Face ID / Touch ID on iOS simulator (match or nomatch)", inputSchema: { type: "object", properties: { match: { type: "boolean", description: "true=success, false=failure" }, device_id: { type: "string" } }, required: ["match"] } },
188
+ { name: "ios_archive_audit", description: "DEPRECATED - use ios_app_store_audit (18-rule deep scan). Lighter 6-check audit kept for backward compatibility; will be removed in the next major.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Path to .xcarchive" } }, required: ["archive_path"] } },
189
+ { 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"] } },
190
+ { 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: [] } },
191
+ { 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"] } },
192
+ { 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"] } },
193
+ { name: "ios_xcresult", description: "Drill into a previous ios_xcodebuild result by xcresult ID. Modes: summary (counts), errors (file:line + message), warnings, log (last N lines), tests (failed). Use this instead of dumping the whole build log into context.", inputSchema: { type: "object", properties: { id: { type: "string", description: "xcresult ID returned by ios_xcodebuild" }, mode: { type: "string", enum: ["summary", "errors", "warnings", "log", "tests"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" } }, required: ["id"] } },
194
+ { 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"] } },
195
+ ];
196
+
197
+ async function handleIOS(name, args) {
198
+ if (!HAS_XCRUN) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
199
+ const did = (n) => { try { return iosDevice(n); } catch (e) { return null; } };
200
+
201
+ switch (name) {
202
+ case "ios_list_devices": {
203
+ const out = run("xcrun simctl list devices available -j");
204
+ try {
205
+ const data = JSON.parse(out);
206
+ const result = [];
207
+ for (const [rt, devs] of Object.entries(data.devices || {}))
208
+ for (const d of devs) result.push({ name: d.name, udid: d.udid, state: d.state, runtime: rt.split(".").pop() });
209
+ return JSON.stringify(result.filter(d => d.name.includes("iPhone") || d.name.includes("iPad")), null, 2);
210
+ } catch { return out; }
211
+ }
212
+ case "ios_boot_device": return run(`xcrun simctl boot ${shq(args.device)}`);
213
+ // `path` returns the file location instead of the image. A design audit takes
214
+ // 100+ captures, and a base64 PNG per capture exhausts the caller's context
215
+ // long before the audit finishes - the run that motivated this shelled out to
216
+ // `xcrun simctl io ... screenshot <path>` directly to avoid it. With `path`
217
+ // the caller keeps the tool and decides which captures to actually look at.
218
+ case "ios_screenshot": {
219
+ const d = iosDevice(args.device_id);
220
+ const f = args.path ? String(args.path) : join(SCREENSHOT_DIR, `ios_${Date.now()}.png`);
221
+ if (args.path) {
222
+ const parent = dirname(f);
223
+ if (!existsSync(parent)) return `ERROR: directory does not exist: ${parent}`;
224
+ }
225
+ run(`xcrun simctl io ${d} screenshot ${shq(f)}`);
226
+ if (!existsSync(f)) return "ERROR: Screenshot failed";
227
+ if (args.path) return `Screenshot written: ${f}`;
228
+ const buf = readFileSync(f);
229
+ return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
230
+ }
231
+ 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})`; }
232
+ 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"; }
233
+ case "ios_type_text": { const d = iosDevice(args.device_id); const r = idb(`ui text --udid ${d} ${shq(args.text)}`); return (typeof r === "string" && r.startsWith("ERROR")) ? r : `Typed: ${args.text}`; }
234
+ case "ios_launch_app": { const d = iosDevice(args.device_id); return run(`xcrun simctl launch ${d} ${sanitizeId(args.bundle_id)}`) || `Launched ${args.bundle_id}`; }
235
+ case "ios_terminate_app": { const d = iosDevice(args.device_id); return run(`xcrun simctl terminate ${d} ${sanitizeId(args.bundle_id)}`) || `Terminated`; }
236
+ case "ios_list_apps": { const d = iosDevice(args.device_id); return run(`xcrun simctl listapps ${d} 2>/dev/null | grep -E "CFBundleIdentifier|CFBundleDisplayName" | paste - - | sort`); }
237
+ // `simctl io <device> pressButton` does not exist - `io` supports only
238
+ // enumerate/poll/recordVideo/screenshot/screenConfig. The home button is an
239
+ // idb UI operation, same as tap/swipe/type.
240
+ case "ios_go_home": {
241
+ const d = iosDevice(args.device_id);
242
+ if (!HAS_IDB) return `${ERROR_PREFIX}ios_go_home needs idb. Install: brew install facebook/fb/idb-companion && pip3 install --user fb-idb`;
243
+ const out = idb(`ui button --udid ${d} HOME`);
244
+ return isFailure(out) ? out : "Home";
245
+ }
246
+ 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}`; }
247
+ 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}`; }
248
+ 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}`; }
249
+ case "ios_open_url": { const d = iosDevice(args.device_id); return run(`xcrun simctl openurl ${d} ${shq(args.url)}`) || `Opened: ${args.url}`; }
250
+ case "ios_status_bar": {
251
+ const d = iosDevice(args.device_id);
252
+ if (args.preset === "clear") return run(`xcrun simctl status_bar ${d} clear`) || "Status bar cleared";
253
+ const PRESETS = {
254
+ clean: { time: "9:41", battery: 100, dataNetwork: "wifi", wifiBars: 3, cellBars: 4, mode: "charged" },
255
+ testing: { time: "11:11", battery: 50, dataNetwork: "wifi", wifiBars: 3, cellBars: 4, mode: "discharging" },
256
+ "low-battery":{ time: "9:41", battery: 20, dataNetwork: "wifi", wifiBars: 3, cellBars: 4, mode: "discharging" },
257
+ airplane: { time: "9:41", battery: 100, dataNetwork: "hide", wifiBars: 0, cellBars: 0, mode: "charged" }
258
+ };
259
+ const p = args.preset ? PRESETS[args.preset] : null;
260
+ const time = args.time || p?.time;
261
+ const battery = args.battery_level !== undefined ? args.battery_level : p?.battery;
262
+ let cmd = `xcrun simctl status_bar ${d} override`;
263
+ if (time) cmd += ` --time "${time}"`;
264
+ if (battery !== undefined) cmd += ` --batteryLevel ${battery} --batteryState ${p?.mode || "charged"}`;
265
+ if (p) {
266
+ cmd += ` --dataNetwork ${p.dataNetwork} --wifiMode active --wifiBars ${p.wifiBars} --cellularMode ${p.cellBars > 0 ? "active" : "notSupported"} --cellularBars ${p.cellBars} --operatorName ""`;
267
+ }
268
+ return run(cmd) || `Status bar set${args.preset ? ` (preset: ${args.preset})` : ""}`;
269
+ }
270
+ case "ios_push_notification": { const d = iosDevice(args.device_id); const payload = { aps: { alert: { title: args.title, body: args.body }, sound: "default" } }; const f = join(SCREENSHOT_DIR, `push_${Date.now()}.json`); writeFileSync(f, JSON.stringify(payload)); return run(`xcrun simctl push ${d} ${sanitizeId(args.bundle_id)} ${shq(f)}`) || `Push sent: ${args.title}`; }
271
+ case "ios_grant_permission": { const d = iosDevice(args.device_id); return run(`xcrun simctl privacy ${d} grant ${token(args.service, "privacy service")} ${sanitizeId(args.bundle_id)}`) || `Granted ${args.service}`; }
272
+ case "ios_revoke_permission": { const d = iosDevice(args.device_id); return run(`xcrun simctl privacy ${d} revoke ${token(args.service, "privacy service")} ${sanitizeId(args.bundle_id)}`) || `Revoked ${args.service}`; }
273
+ case "ios_reset_permissions": { const d = iosDevice(args.device_id); return run(`xcrun simctl privacy ${d} reset all ${sanitizeId(args.bundle_id)}`) || `Reset permissions`; }
274
+ case "ios_set_location": { const d = iosDevice(args.device_id); return run(`xcrun simctl location ${d} set ${num(args.latitude, "latitude")},${num(args.longitude, "longitude")}`) || `Location: ${args.latitude},${args.longitude}`; }
275
+ case "ios_clear_location": { const d = iosDevice(args.device_id); return run(`xcrun simctl location ${d} clear`) || "Location cleared"; }
276
+ case "ios_set_increase_contrast": { const d = iosDevice(args.device_id); return run(`xcrun simctl ui ${d} increase_contrast ${args.enabled ? "enabled" : "disabled"}`) || `Contrast: ${args.enabled}`; }
277
+ case "ios_record_video": { const d = iosDevice(args.device_id); const f = join(SCREENSHOT_DIR, args.filename || `rec_${Date.now()}.mp4`); return `Run: xcrun simctl io ${d} recordVideo --codec=${args.codec || "hevc"} "${f}"\nCtrl+C to stop.`; }
278
+ case "ios_add_media": { const d = iosDevice(args.device_id); return run(`xcrun simctl addmedia ${d} ${shq(args.file_path)}`) || "Media added"; }
279
+ case "ios_keychain_reset": { const d = iosDevice(args.device_id); return run(`xcrun simctl keychain ${d} reset`) || "Keychain reset"; }
280
+ 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"}`); }
281
+ case "ios_erase_device": { const d = iosDevice(args.device_id); return run(`xcrun simctl erase ${d}`) || "Device erased"; }
282
+ case "ios_get_ui_tree": {
283
+ const script = join(__dirname, "ui-tree-dumper.swift");
284
+ if (!existsSync(script)) return "ui-tree-dumper.swift not found";
285
+ const depth = args.max_depth || 10;
286
+ return run(`swift "${script}" ${depth}`, { timeout: 15000 });
287
+ }
288
+ case "ios_accessibility_audit": {
289
+ const script = join(__dirname, "ui-tree-dumper.swift");
290
+ if (!existsSync(script)) return "ui-tree-dumper.swift not found";
291
+ const depth = args.max_depth || 10;
292
+ const scope = args.scope || null;
293
+ const treeJson = run(`swift "${script}" ${depth}`, { timeout: 15000 });
294
+ try {
295
+ const issues = [];
296
+ let totalScanned = 0, totalSkipped = 0;
297
+ function auditNode(node, path = "") {
298
+ const loc = path ? `${path} > ${node.role}` : node.role;
299
+ const w = node.frame?.w || 0, h = node.frame?.h || 0;
300
+ const isInteractive = ["AXButton", "AXLink", "AXTextField", "AXTextArea", "AXCheckBox", "AXRadioButton", "AXSlider", "AXSwitch", "AXTab"].includes(node.role);
301
+ if (isInteractive) {
302
+ // Scope filter: skip elements outside scope
303
+ if (scope && node.identifier && !node.identifier.startsWith(scope)) { totalSkipped++; if (node.children) node.children.forEach(c => auditNode(c, loc)); return; }
304
+ if (scope && !node.identifier) { /* no identifier = can't scope, still audit */ }
305
+ totalScanned++;
306
+ if (!node.title && !node.description && !node.value) issues.push({ severity: "critical", issue: "Missing accessibility label", element: loc, identifier: node.identifier, frame: node.frame });
307
+ if (!node.identifier) issues.push({ severity: "warning", issue: "Missing accessibility identifier (UI testing)", element: loc });
308
+ if (w > 0 && h > 0 && (w < 44 || h < 44)) issues.push({ severity: "important", issue: `Tap target too small: ${w.toFixed(0)}x${h.toFixed(0)}pt (min 44x44)`, element: loc, identifier: node.identifier, frame: node.frame });
309
+ }
310
+ if (node.children) node.children.forEach(c => auditNode(c, loc));
311
+ }
312
+ const tree = JSON.parse(treeJson);
313
+ auditNode(tree);
314
+ return JSON.stringify({ scope: scope || "all", elements_scanned: totalScanned, elements_skipped: totalSkipped, total_issues: issues.length, critical: issues.filter(i => i.severity === "critical").length, important: issues.filter(i => i.severity === "important").length, warning: issues.filter(i => i.severity === "warning").length, issues }, null, 2);
315
+ } catch (e) { return `ERROR parsing UI tree: ${e.message}\n\nRaw output:\n${treeJson?.substring(0, 500)}`; }
316
+ }
317
+ // `simctl keychain <device> biometric-enroll` / `biometric-match` do not
318
+ // exist - `keychain` supports only add-root-cert/add-cert/reset. The only
319
+ // available lever is the (undocumented) BiometricKit notification, which is
320
+ // posted via notifyutil inside the simulator. notifyutil exits 0 even when
321
+ // it cannot set or post the name, so its output has to be inspected: a
322
+ // "Failed with code N" line means the notification did not land, and that
323
+ // must be reported as a failure rather than as a simulated success.
324
+ case "ios_biometric": {
325
+ const d = iosDevice(args.device_id);
326
+ const action = args.match ? "match" : "nomatch";
327
+ const NOTIFYUTIL = "/usr/bin/notifyutil"; // simctl spawn does not resolve PATH
328
+ const enroll = run(`xcrun simctl spawn ${d} ${NOTIFYUTIL} -s com.apple.BiometricKit.enrollmentChanged 1`);
329
+ const post = run(`xcrun simctl spawn ${d} ${NOTIFYUTIL} -p com.apple.BiometricKit_Sim.pearl.${action}`);
330
+ const combined = `${enroll}\n${post}`;
331
+ if (isFailure(enroll) || isFailure(post)) return combined.trim();
332
+ if (/Failed with code \d+/.test(combined)) {
333
+ return `${ERROR_PREFIX}biometric notification not delivered on this simulator/Xcode. ` +
334
+ `notifyutil reported: ${combined.replace(/\s+/g, " ").trim()}. ` +
335
+ `BiometricKit simulation is undocumented by Apple and requires a foreground app ` +
336
+ `registered for these notifications; verify Face ID enrollment in the Simulator ` +
337
+ `Features menu before relying on this tool.`;
338
+ }
339
+ return `Biometric ${action} posted (enrollment set, ${action} delivered)`;
340
+ }
341
+ case "ios_archive_audit": {
342
+ const p = args.archive_path;
343
+ if (!existsSync(p)) return `ERROR: Archive not found at ${p}`;
344
+ const findings = [];
345
+ // 1. Find .app inside archive
346
+ const appDir = run(`find "${p}/Products/Applications" -name "*.app" -maxdepth 1 2>/dev/null | head -1`);
347
+ if (!appDir) return "ERROR: No .app found inside archive";
348
+ const appName = appDir.split("/").pop();
349
+ const binaryName = appName.replace(".app", "");
350
+ const binaryPath = `${appDir}/${binaryName}`;
351
+ // 2. Binary size
352
+ const sizeOut = run(`stat -f "%z" "${binaryPath}" 2>/dev/null`);
353
+ 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" }); }
354
+ // 3. Debug tool leak check
355
+ const debugSymbols = run(`nm "${binaryPath}" 2>/dev/null | grep -iE "FLEX|Reveal|Stetho|Flipper|CocoaDebug|Pulse" | head -10`);
356
+ findings.push({ check: "debug_tools", status: debugSymbols ? "critical" : "pass", detail: debugSymbols ? `Debug tools found in binary: ${debugSymbols}` : "No debug tools detected" });
357
+ // 4. Code signing
358
+ const codesign = run(`codesign -dvv "${appDir}" 2>&1`);
359
+ const hasSignature = codesign && !codesign.includes("not signed");
360
+ findings.push({ check: "code_signing", status: hasSignature ? "pass" : "critical", detail: hasSignature ? "Signed" : "NOT SIGNED - will be rejected" });
361
+ // 5. Provisioning profile
362
+ const provProfile = run(`ls "${appDir}/embedded.mobileprovision" 2>/dev/null`);
363
+ findings.push({ check: "provisioning_profile", status: provProfile ? "pass" : "warning", detail: provProfile ? "Present" : "No embedded.mobileprovision - may be App Store distribution" });
364
+ // 6. Entitlements
365
+ const entitlements = run(`codesign -d --entitlements - "${appDir}" 2>/dev/null`);
366
+ const hasGetTaskAllow = entitlements && entitlements.includes("get-task-allow");
367
+ findings.push({ check: "entitlements_debug", status: hasGetTaskAllow ? "critical" : "pass", detail: hasGetTaskAllow ? "get-task-allow is TRUE - this is a DEBUG build, App Store will reject" : "get-task-allow not set or false - OK" });
368
+ // 7. Info.plist checks
369
+ const plistPath = `${appDir}/Info.plist`;
370
+ const plistJson = run(`plutil -convert json -o - "${plistPath}" 2>/dev/null`);
371
+ if (plistJson) {
372
+ try {
373
+ const plist = JSON.parse(plistJson);
374
+ findings.push({ check: "bundle_version", status: plist.CFBundleShortVersionString ? "pass" : "critical", detail: plist.CFBundleShortVersionString || "MISSING" });
375
+ findings.push({ check: "min_os_version", value: plist.MinimumOSVersion || "unknown", status: "info" });
376
+ const ats = plist.NSAppTransportSecurity;
377
+ findings.push({ check: "app_transport_security", status: ats?.NSAllowsArbitraryLoads ? "warning" : "pass", detail: ats?.NSAllowsArbitraryLoads ? "NSAllowsArbitraryLoads=true - ATS disabled, needs justification" : "ATS enforced" });
378
+ // Privacy permission strings
379
+ const privacyKeys = ["NSCameraUsageDescription", "NSPhotoLibraryUsageDescription", "NSLocationWhenInUseUsageDescription", "NSMicrophoneUsageDescription", "NSContactsUsageDescription", "NSFaceIDUsageDescription"];
380
+ const declaredPerms = privacyKeys.filter(k => plist[k]);
381
+ findings.push({ check: "privacy_strings", value: `${declaredPerms.length} permissions declared`, status: "info", detail: declaredPerms.join(", ") || "None" });
382
+ } catch {}
383
+ }
384
+ // 8. Privacy manifest
385
+ const privacyManifest = run(`find "${appDir}" -name "PrivacyInfo.xcprivacy" 2>/dev/null | head -1`);
386
+ findings.push({ check: "privacy_manifest", status: privacyManifest ? "pass" : "warning", detail: privacyManifest ? "PrivacyInfo.xcprivacy found" : "No privacy manifest - required for apps using Required Reason APIs" });
387
+ // 9. Embedded frameworks check
388
+ const frameworks = run(`ls "${appDir}/Frameworks/" 2>/dev/null`);
389
+ if (frameworks) { findings.push({ check: "embedded_frameworks", value: frameworks.split("\n").length + " frameworks", status: "info", detail: frameworks }); }
390
+ // Summary
391
+ const critical = findings.filter(f => f.status === "critical").length;
392
+ const warnings = findings.filter(f => f.status === "warning").length;
393
+ return JSON.stringify({ archive: p, app: appName, summary: { critical, warnings, passed: findings.filter(f => f.status === "pass").length, total_checks: findings.length }, verdict: critical > 0 ? "FAIL - critical issues must be fixed" : warnings > 0 ? "WARN - review warnings before submission" : "PASS - ready for App Store", findings }, null, 2);
394
+ }
395
+ case "ios_export_ipa": {
396
+ if (!HAS_XCRUN) return "ERROR: xcrun not available - Xcode Command Line Tools required";
397
+ if (!args.archive_path) return "ERROR: archive_path is required";
398
+ if (!args.output_dir) return "ERROR: output_dir is required";
399
+ const res = exportIpa({
400
+ archivePath: args.archive_path,
401
+ outputDir: args.output_dir,
402
+ method: args.method,
403
+ teamId: args.team_id,
404
+ provisioningProfiles: args.provisioning_profiles,
405
+ signingStyle: args.signing_style,
406
+ uploadSymbols: args.upload_symbols,
407
+ allowProvisioningUpdates: args.allow_provisioning_updates,
408
+ timeoutSec: args.timeout_sec,
409
+ });
410
+ // Progressive disclosure: the full xcodebuild log is large and almost
411
+ // never what the caller needs. Return the verdict plus the first errors.
412
+ return JSON.stringify(
413
+ {
414
+ ok: res.ok,
415
+ ipa: res.ipaPath || null,
416
+ exportOptions: res.exportOptionsPath,
417
+ errorCount: res.errors.length,
418
+ errors: res.errors.slice(0, 20),
419
+ hint: res.ok
420
+ ? undefined
421
+ : "Common causes: no App Store distribution certificate in the keychain, a provisioning profile that does not match the bundle ID, or signing_style=manual without provisioning_profiles.",
422
+ },
423
+ null,
424
+ 2,
425
+ );
426
+ }
427
+ case "ios_testflight_validate": {
428
+ if (!HAS_XCRUN) return "ERROR: xcrun not available - Xcode Command Line Tools required";
429
+ const auth = resolveAuth({
430
+ apiKeyId: args.api_key_id,
431
+ apiIssuerId: args.api_issuer_id,
432
+ p8Path: args.p8_path,
433
+ appleId: args.apple_id,
434
+ keychainItem: args.keychain_item,
435
+ passwordEnvVar: args.password_env_var,
436
+ providerPublicId: args.provider_public_id,
437
+ });
438
+ if (args.list_providers) {
439
+ const res = listProviders(auth);
440
+ return JSON.stringify({ authTier: auth.tier, authMethod: auth.method, ...res }, null, 2);
441
+ }
442
+ if (!args.ipa_path) return "ERROR: ipa_path is required (or pass list_providers=true)";
443
+ const res = validateApp({
444
+ ipaPath: args.ipa_path,
445
+ platform: args.platform,
446
+ auth,
447
+ timeoutSec: args.timeout_sec,
448
+ });
449
+ return JSON.stringify(res, null, 2);
450
+ }
451
+ case "ios_app_store_audit": {
452
+ const archivePath = args.archive_path;
453
+ if (!archivePath) return "ERROR: archive_path is required";
454
+ if (!existsSync(archivePath)) return `ERROR: Archive not found at ${archivePath}`;
455
+ try {
456
+ const result = await runAppStoreAudit({
457
+ archivePath,
458
+ rules: args.rules || "all",
459
+ });
460
+ return JSON.stringify(result, null, 2);
461
+ } catch (err) {
462
+ return `ERROR: ${err.message}`;
463
+ }
464
+ }
465
+ case "ios_xcodebuild": {
466
+ if (!HAS_XCRUN) return "ERROR: xcrun not available - Xcode Command Line Tools required";
467
+ if (!args.project && !args.workspace) return "ERROR: project or workspace required";
468
+ if (args.project && args.workspace) return "ERROR: pass project OR workspace, not both";
469
+ const action = args.action || "build";
470
+ const config = args.configuration || "Release";
471
+ const dest = args.destination || "generic/platform=iOS Simulator";
472
+ const id = `xcresult-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
473
+ const xcresultPath = join(SCREENSHOT_DIR, `${id}.xcresult`);
474
+ const logPath = join(SCREENSHOT_DIR, `${id}.log`);
475
+ const target = args.workspace ? `-workspace ${shq(args.workspace)}` : `-project ${shq(args.project)}`;
476
+ const derivedFlag = args.derived_data_path ? `-derivedDataPath ${shq(args.derived_data_path)}` : "";
477
+ const actionMap = { "clean-build": "clean build", build: "build", test: "test", clean: "clean", archive: "archive" };
478
+ const cmd = `xcodebuild ${target} -scheme ${shq(args.scheme)} -configuration ${shq(config)} -destination ${shq(dest)} -resultBundlePath ${shq(xcresultPath)} ${derivedFlag} ${actionMap[action]} ${args.extra_args || ""}`.trim();
479
+ const timeout = (args.timeout_sec || 600) * 1000;
480
+ let exitCode = 0;
481
+ let raw = "";
482
+ try {
483
+ raw = execSync(cmd, { encoding: "utf-8", timeout, maxBuffer: 64 * 1024 * 1024 });
484
+ } catch (e) {
485
+ exitCode = e.status || 1;
486
+ raw = (e.stdout || "") + "\n" + (e.stderr || "") + (e.signal === "SIGTERM" ? "\n[TIMEOUT]" : "");
487
+ }
488
+ writeFileSync(logPath, raw);
489
+ const errMatches = raw.match(/^.*error:.*$/gim) || [];
490
+ const warnMatches = raw.match(/^.*warning:.*$/gim) || [];
491
+ const errors = errMatches.length;
492
+ const warnings = warnMatches.length;
493
+ let testInfo = "";
494
+ const testMatch = raw.match(/Test Suite.*passed.*Executed (\d+) test[s]?, with (\d+) failure/i);
495
+ if (testMatch) testInfo = `, ${testMatch[1]} tests (${testMatch[2]} failed)`;
496
+ const status = exitCode === 0 ? "SUCCESS" : "FAILURE";
497
+ return `Build: ${status} (${errors} errors, ${warnings} warnings${testInfo}) [${id}]\nProject: ${args.scheme} (${action}, ${config})\nLog: ${logPath}\nxcresult: ${xcresultPath}\nDrill in: ios_xcresult({id:"${id}", mode:"errors"})`;
498
+ }
499
+ case "ios_xcresult": {
500
+ const id = args.id;
501
+ if (!id || !/^xcresult-[\w-]+$/.test(id)) return "ERROR: invalid xcresult id";
502
+ const logPath = join(SCREENSHOT_DIR, `${id}.log`);
503
+ const xcresultPath = join(SCREENSHOT_DIR, `${id}.xcresult`);
504
+ if (!existsSync(logPath)) return `ERROR: no log for ${id} at ${logPath}`;
505
+ const raw = readFileSync(logPath, "utf-8");
506
+ const mode = args.mode || "summary";
507
+ if (mode === "log") {
508
+ const n = args.log_lines || 200;
509
+ const lines = raw.split("\n");
510
+ return lines.slice(-n).join("\n");
511
+ }
512
+ if (mode === "errors") {
513
+ const errs = (raw.match(/^.*error:.*$/gim) || []).map(l => l.trim());
514
+ return JSON.stringify({ id, count: errs.length, errors: errs }, null, 2);
515
+ }
516
+ if (mode === "warnings") {
517
+ const warns = (raw.match(/^.*warning:.*$/gim) || []).map(l => l.trim());
518
+ return JSON.stringify({ id, count: warns.length, warnings: warns }, null, 2);
519
+ }
520
+ if (mode === "tests") {
521
+ if (existsSync(xcresultPath)) {
522
+ const out = run(`xcrun xcresulttool get test-results tests --path "${xcresultPath}" --format json 2>/dev/null`, { timeout: 20000 });
523
+ if (out && !out.startsWith("ERROR")) return out;
524
+ }
525
+ const failed = (raw.match(/^.*Test Case.*failed.*$/gim) || []).map(l => l.trim());
526
+ return JSON.stringify({ id, failed_count: failed.length, failed_tests: failed }, null, 2);
527
+ }
528
+ const errCount = (raw.match(/^.*error:.*$/gim) || []).length;
529
+ const warnCount = (raw.match(/^.*warning:.*$/gim) || []).length;
530
+ return JSON.stringify({ id, errors: errCount, warnings: warnCount, log_path: logPath, xcresult_path: xcresultPath }, null, 2);
531
+ }
532
+ case "ios_visual_diff": {
533
+ if (!existsSync(args.baseline)) return `ERROR: baseline not found: ${args.baseline}`;
534
+ if (!existsSync(args.current)) return `ERROR: current not found: ${args.current}`;
535
+ let PNG, pixelmatch;
536
+ try {
537
+ ({ PNG } = await import("pngjs"));
538
+ pixelmatch = (await import("pixelmatch")).default;
539
+ } catch (e) {
540
+ return `ERROR: visual diff requires pixelmatch + pngjs. Install: npm i -g pngjs pixelmatch (or rerun npm install in dev-toolkit-mcp)`;
541
+ }
542
+ const a = PNG.sync.read(readFileSync(args.baseline));
543
+ const b = PNG.sync.read(readFileSync(args.current));
544
+ if (a.width !== b.width || a.height !== b.height) {
545
+ return JSON.stringify({ passed: false, reason: "size_mismatch", baseline_size: `${a.width}x${a.height}`, current_size: `${b.width}x${b.height}` }, null, 2);
546
+ }
547
+ const diff = new PNG({ width: a.width, height: a.height });
548
+ const threshold = args.threshold !== undefined ? args.threshold : 0.1;
549
+ const diffPixels = pixelmatch(a.data, b.data, diff.data, a.width, a.height, { threshold });
550
+ const total = a.width * a.height;
551
+ const diffPct = (diffPixels / total) * 100;
552
+ const maxPct = args.max_diff_pct !== undefined ? args.max_diff_pct : 1.0;
553
+ const passed = diffPct <= maxPct;
554
+ let diffImagePath = null;
555
+ if (args.output || !passed) {
556
+ diffImagePath = args.output || join(SCREENSHOT_DIR, `diff-${Date.now()}.png`);
557
+ writeFileSync(diffImagePath, PNG.sync.write(diff));
558
+ }
559
+ return JSON.stringify({ passed, diff_pct: parseFloat(diffPct.toFixed(4)), diff_pixels: diffPixels, total_pixels: total, threshold, max_diff_pct: maxPct, baseline: args.baseline, current: args.current, diff_image: diffImagePath }, null, 2);
560
+ }
561
+ default: return null;
562
+ }
563
+ }
564
+
565
+ // ── Android Tools ──
566
+
567
+ function adbFlag(id) { return id ? `-s ${id}` : ""; }
568
+
569
+ const ANDROID_TOOLS = [
570
+ { name: "android_list_devices", description: "List connected Android devices and emulators", inputSchema: { type: "object", properties: {} } },
571
+ { name: "android_screenshot", description: "Capture Android screenshot (returns base64 PNG)", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
572
+ { 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"] } },
573
+ { 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"] } },
574
+ { name: "android_type_text", description: "Type text on Android", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
575
+ { 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"] } },
576
+ { 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"] } },
577
+ { name: "android_stop_app", description: "Force-stop Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
578
+ { name: "android_list_packages", description: "List installed Android packages", inputSchema: { type: "object", properties: { filter: { type: "string" }, device_id: { type: "string" } } } },
579
+ { name: "android_go_home", description: "Press Android home button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
580
+ { name: "android_go_back", description: "Press Android back button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
581
+ { name: "android_get_ui_tree", description: "Dump Android UI hierarchy (uiautomator XML with bounds, text, resource-id)", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
582
+ { name: "android_set_dark_mode", description: "Enable/disable Android dark mode", inputSchema: { type: "object", properties: { enabled: { type: "boolean" }, device_id: { type: "string" } }, required: ["enabled"] } },
583
+ { name: "android_set_font_scale", description: "Set Android font scale (1.0=normal, 2.0=extra large)", inputSchema: { type: "object", properties: { scale: { type: "number" }, device_id: { type: "string" } }, required: ["scale"] } },
584
+ { name: "android_set_locale", description: "Set an app's locale (per-app locale, Android 13+/API 33). package_name is needed in practice: the per-app locale API is app-scoped, and device-wide locale change is not reachable over adb without root.", inputSchema: { type: "object", properties: { locale: { type: "string", description: "BCP-47 tag, e.g. tr-TR, en-US" }, package_name: { type: "string", description: "Target app package. Without it the call returns an error instead of silently doing nothing." }, device_id: { type: "string" } }, required: ["locale"] } },
585
+ { name: "android_set_location", description: "Set mock Android GPS location", inputSchema: { type: "object", properties: { latitude: { type: "number" }, longitude: { type: "number" }, device_id: { type: "string" } }, required: ["latitude", "longitude"] } },
586
+ { name: "android_grant_permission", description: "Grant Android runtime permission", inputSchema: { type: "object", properties: { package_name: { type: "string" }, permission: { type: "string" }, device_id: { type: "string" } }, required: ["package_name", "permission"] } },
587
+ { name: "android_revoke_permission", description: "Revoke Android runtime permission", inputSchema: { type: "object", properties: { package_name: { type: "string" }, permission: { type: "string" }, device_id: { type: "string" } }, required: ["package_name", "permission"] } },
588
+ { name: "android_record_screen", description: "Record Android screen", inputSchema: { type: "object", properties: { duration: { type: "number" }, device_id: { type: "string" } } } },
589
+ { name: "android_install_apk", description: "Install APK on Android", inputSchema: { type: "object", properties: { apk_path: { type: "string" }, device_id: { type: "string" } }, required: ["apk_path"] } },
590
+ { name: "android_uninstall_app", description: "Uninstall Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
591
+ { name: "android_logcat", description: "Get Android logcat entries", inputSchema: { type: "object", properties: { tag: { type: "string" }, lines: { type: "number" }, device_id: { type: "string" } } } },
592
+ { name: "android_get_screen_size", description: "Get Android screen resolution", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
593
+ { 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"] } },
594
+ { 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"] } },
595
+ { name: "android_accessibility_audit", description: "Audit Android app accessibility: missing contentDescription, small touch targets (<48dp), missing resource-id. Use scope to filter by resource-id prefix.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, scope: { type: "string", description: "Filter: only audit elements whose resource-id contains this prefix (e.g. 'login_', 'com.example:id/login_'). Omit to audit all." } } } },
596
+ { name: "android_launch_time", description: "Measure Android app cold launch time (TotalTime in ms)", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
597
+ { 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"] } },
598
+ ];
599
+
600
+ async function handleAndroid(name, args) {
601
+ 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.`;
602
+ const df = adbFlag(args.device_id);
603
+
604
+ switch (name) {
605
+ case "android_list_devices": return run("adb devices -l");
606
+ case "android_screenshot": {
607
+ const f = join(SCREENSHOT_DIR, `android_${Date.now()}.png`);
608
+ run(`adb ${df} shell screencap -p /sdcard/_mcp_screen.png`);
609
+ run(`adb ${df} pull /sdcard/_mcp_screen.png "${f}"`);
610
+ run(`adb ${df} shell rm /sdcard/_mcp_screen.png`);
611
+ if (existsSync(f)) { const buf = readFileSync(f); return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f }; }
612
+ return "ERROR: Screenshot failed";
613
+ }
614
+ case "android_tap": return run(`adb ${df} shell input tap ${num(args.x, "x")} ${num(args.y, "y")}`) || `Tapped (${args.x}, ${args.y})`;
615
+ 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";
616
+ // `adb shell input text` wants spaces as %s; single-quote the result so the
617
+ // remaining characters cannot reach the shell as syntax.
618
+ case "android_type_text": return run(`adb ${df} shell input text ${shq(String(args.text ?? "").replace(/ /g, "%s"))}`) || `Typed: ${args.text}`;
619
+ case "android_key_event": return run(`adb ${df} shell input keyevent ${token(args.keycode, "keycode")}`) || `Key ${args.keycode}`;
620
+ 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`;
621
+ case "android_stop_app": return run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`) || "Stopped";
622
+ 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; }
623
+ case "android_go_home": return run(`adb ${df} shell input keyevent 3`) || "Home";
624
+ case "android_go_back": return run(`adb ${df} shell input keyevent 4`) || "Back";
625
+ case "android_get_ui_tree": {
626
+ run(`adb ${df} shell uiautomator dump /sdcard/_mcp_ui.xml`);
627
+ const f = join(SCREENSHOT_DIR, `ui_${Date.now()}.xml`);
628
+ run(`adb ${df} pull /sdcard/_mcp_ui.xml "${f}"`);
629
+ run(`adb ${df} shell rm /sdcard/_mcp_ui.xml`);
630
+ return existsSync(f) ? readFileSync(f, "utf-8") : "ERROR: UI dump failed";
631
+ }
632
+ case "android_set_dark_mode": return run(`adb ${df} shell cmd uimode night ${args.enabled ? "yes" : "no"}`) || `Dark mode: ${args.enabled}`;
633
+ case "android_set_font_scale": return run(`adb ${df} shell settings put system font_scale ${num(args.scale, "scale")}`) || `Font scale: ${args.scale}`;
634
+ // The old SET_LOCALE broadcast is a dead pre-Android-7 mechanism: `am
635
+ // broadcast` exits 0 even when nothing handles the intent, and the previous
636
+ // implementation also sent stderr to /dev/null, so it reported success
637
+ // unconditionally. `cmd locale set-app-locales` is the supported per-app
638
+ // path (API 33+). Device-wide locale needs root and is out of scope.
639
+ case "android_set_locale": {
640
+ const pkg = sanitizeId(args.package_name);
641
+ if (!pkg) return `${ERROR_PREFIX}android_set_locale requires package_name (per-app locale API is app-scoped)`;
642
+ const locale = String(args.locale || "");
643
+ if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(locale)) {
644
+ return `${ERROR_PREFIX}invalid locale tag "${locale}" (expected BCP-47, e.g. tr-TR)`;
645
+ }
646
+ const out = run(`adb ${df} shell cmd locale set-app-locales ${pkg} --locales ${locale}`);
647
+ if (isFailure(out)) return out;
648
+ // `cmd` prints usage or an Exception on an unsupported platform rather
649
+ // than exiting non-zero, so inspect the output before claiming success.
650
+ if (/Unknown command|Exception|usage:/i.test(out)) {
651
+ return `${ERROR_PREFIX}per-app locale not supported on this device (needs API 33+). Device said: ${out.replace(/\s+/g, " ").slice(0, 300)}`;
652
+ }
653
+ // Read the locale back and COMPARE it. The previous form reported the
654
+ // REQUESTED value as fact and put the device's answer in a parenthetical,
655
+ // so a device that accepted the command but applied a different locale (or
656
+ // none at all) still read as success - the same report-without-checking
657
+ // shape the `Unknown command` branch above was added to remove.
658
+ const verify = run(`adb ${df} shell cmd locale get-app-locales ${pkg}`);
659
+ if (isFailure(verify)) {
660
+ return `${ERROR_PREFIX}set ${locale} for ${pkg} but could not read it back to confirm: ${verify}`;
661
+ }
662
+ const reported = verify.replace(/\s+/g, " ").trim();
663
+ // Output looks like `Locales for com.x for user 0 are [tr-TR]`. Compare
664
+ // case-insensitively, and accept the device answering with a more specific
665
+ // tag than asked for (`tr` -> `tr-TR`), which is correct, not a mismatch.
666
+ const bracketed = /\[([^\]]*)\]/.exec(reported)?.[1] ?? reported;
667
+ const want = locale.toLowerCase();
668
+ const got = bracketed.toLowerCase();
669
+ if (!(got === want || got.startsWith(`${want}-`) || want.startsWith(`${got}-`))) {
670
+ return `${ERROR_PREFIX}per-app locale not applied: asked for ${locale}, device reports "${reported.slice(0, 200)}"`;
671
+ }
672
+ return `Locale for ${pkg}: ${locale} (verified: ${bracketed})`;
673
+ }
674
+ case "android_set_location": return run(`adb ${df} emu geo fix ${num(args.longitude, "longitude")} ${num(args.latitude, "latitude")}`) || `Location set`;
675
+ case "android_grant_permission": return run(`adb ${df} shell pm grant ${sanitizeId(args.package_name)} ${token(args.permission, "permission")}`) || "Granted";
676
+ case "android_revoke_permission": return run(`adb ${df} shell pm revoke ${sanitizeId(args.package_name)} ${token(args.permission, "permission")}`) || "Revoked";
677
+ case "android_record_screen": { const dur = Math.min(args.duration || 30, 180); return `Run: adb ${df} shell screenrecord --time-limit ${dur} /sdcard/rec.mp4`; }
678
+ case "android_install_apk": return run(`adb ${df} install -r ${shq(args.apk_path)}`, { timeout: 60000 }) || "Installed";
679
+ case "android_uninstall_app": return run(`adb ${df} shell pm uninstall ${sanitizeId(args.package_name)}`) || "Uninstalled";
680
+ case "android_logcat": { const lines = args.lines || 50; const tf = args.tag ? `| grep -i ${shq(args.tag)}` : ""; return run(`adb ${df} logcat -d ${tf} | tail -${lines}`); }
681
+ case "android_get_screen_size": return run(`adb ${df} shell wm size`);
682
+ case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${shq(args.url)}`) || `Opened: ${args.url}`;
683
+ case "android_clear_app_data": return run(`adb ${df} shell pm clear ${sanitizeId(args.package_name)}`) || "Cleared";
684
+ case "android_accessibility_audit": {
685
+ run(`adb ${df} shell uiautomator dump /sdcard/_mcp_a11y.xml`);
686
+ const f = join(SCREENSHOT_DIR, `a11y_${Date.now()}.xml`);
687
+ run(`adb ${df} pull /sdcard/_mcp_a11y.xml "${f}"`);
688
+ run(`adb ${df} shell rm /sdcard/_mcp_a11y.xml`);
689
+ if (!existsSync(f)) return "ERROR: UI dump failed";
690
+ const xml = readFileSync(f, "utf-8");
691
+ const scope = args.scope || null;
692
+ const issues = [];
693
+ let totalScanned = 0, totalSkipped = 0;
694
+ const nodeRegex = /<node[^>]*>/g;
695
+ let match;
696
+ while ((match = nodeRegex.exec(xml)) !== null) {
697
+ const node = match[0];
698
+ const cls = node.match(/class="([^"]*)"/)?.[1] || "";
699
+ const desc = node.match(/content-desc="([^"]*)"/)?.[1] || "";
700
+ const rid = node.match(/resource-id="([^"]*)"/)?.[1] || "";
701
+ const text = node.match(/text="([^"]*)"/)?.[1] || "";
702
+ const clickable = node.includes('clickable="true"');
703
+ const bounds = node.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
704
+ if (clickable) {
705
+ if (scope && rid && !rid.includes(scope)) { totalSkipped++; continue; }
706
+ totalScanned++;
707
+ if (!desc && !text) issues.push({ severity: "critical", issue: "Missing contentDescription", element: cls, resourceId: rid });
708
+ if (!rid) issues.push({ severity: "warning", issue: "Missing resource-id (UI testing)", element: cls });
709
+ if (bounds) {
710
+ const w = parseInt(bounds[3]) - parseInt(bounds[1]);
711
+ const h = parseInt(bounds[4]) - parseInt(bounds[2]);
712
+ if (w < 48 || h < 48) issues.push({ severity: "important", issue: `Touch target too small: ${w}x${h}dp (min 48x48)`, element: cls, resourceId: rid });
713
+ }
714
+ }
715
+ }
716
+ return JSON.stringify({ scope: scope || "all", elements_scanned: totalScanned, elements_skipped: totalSkipped, total_issues: issues.length, critical: issues.filter(i => i.severity === "critical").length, important: issues.filter(i => i.severity === "important").length, warning: issues.filter(i => i.severity === "warning").length, issues }, null, 2);
717
+ }
718
+ case "android_launch_time": {
719
+ run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
720
+ const activity = sanitizeId(args.activity || `${args.package_name}/.MainActivity`);
721
+ const result = run(`adb ${df} shell am start -W -n ${activity} 2>&1`);
722
+ const totalTime = result.match(/TotalTime:\s*(\d+)/)?.[1];
723
+ const waitTime = result.match(/WaitTime:\s*(\d+)/)?.[1];
724
+ return JSON.stringify({ package: args.package_name, cold_start: true, total_time_ms: totalTime ? parseInt(totalTime) : null, wait_time_ms: waitTime ? parseInt(waitTime) : null, raw: result }, null, 2);
725
+ }
726
+ case "android_apk_audit": {
727
+ const p = args.apk_path;
728
+ if (!existsSync(p)) return `ERROR: APK not found at ${p}`;
729
+ const findings = [];
730
+ // 1. Basic info via aapt2
731
+ const aapt = run(`aapt2 dump badging "${p}" 2>/dev/null`) || run(`aapt dump badging "${p}" 2>/dev/null`);
732
+ if (aapt) {
733
+ const pkg = aapt.match(/package: name='([^']*)'/)?.[1];
734
+ const versionName = aapt.match(/versionName='([^']*)'/)?.[1];
735
+ const versionCode = aapt.match(/versionCode='([^']*)'/)?.[1];
736
+ const targetSdk = aapt.match(/targetSdkVersion:'(\d+)'/)?.[1];
737
+ const minSdk = aapt.match(/sdkVersion:'(\d+)'/)?.[1];
738
+ findings.push({ check: "package", value: pkg, status: "info" });
739
+ findings.push({ check: "version", value: `${versionName} (${versionCode})`, status: "info" });
740
+ findings.push({ check: "target_sdk", value: targetSdk, status: targetSdk && parseInt(targetSdk) >= 34 ? "pass" : "warning", detail: targetSdk && parseInt(targetSdk) < 34 ? `Target SDK ${targetSdk} - Google Play requires 34+ for new apps` : "OK" });
741
+ findings.push({ check: "min_sdk", value: minSdk, status: "info" });
742
+ // Permissions audit
743
+ const perms = [...aapt.matchAll(/uses-permission: name='([^']*)'/g)].map(m => m[1]);
744
+ const dangerousPerms = perms.filter(p => /(CAMERA|CONTACTS|LOCATION|MICROPHONE|PHONE|SMS|STORAGE|CALENDAR)/.test(p));
745
+ findings.push({ check: "permissions", value: `${perms.length} total, ${dangerousPerms.length} dangerous`, status: dangerousPerms.length > 5 ? "warning" : "info", detail: dangerousPerms.join(", ") || "None dangerous" });
746
+ // Debuggable check
747
+ const debuggable = aapt.includes("application-debuggable");
748
+ findings.push({ check: "debuggable", status: debuggable ? "critical" : "pass", detail: debuggable ? "App is DEBUGGABLE - Play Store will reject" : "Not debuggable - OK" });
749
+ } else {
750
+ findings.push({ check: "aapt", status: "warning", detail: "aapt2/aapt not found - install Android SDK Build-Tools for full audit" });
751
+ }
752
+ // 2. Signing check
753
+ const signingInfo = run(`apksigner verify --print-certs "${p}" 2>&1`);
754
+ if (signingInfo && !signingInfo.includes("ERROR")) {
755
+ const hasV2 = signingInfo.includes("v2 scheme") || run(`apksigner verify -v "${p}" 2>&1`)?.includes("Verified using v2");
756
+ findings.push({ check: "signing", status: "pass", detail: "APK is signed" });
757
+ findings.push({ check: "signing_v2", status: hasV2 ? "pass" : "warning", detail: hasV2 ? "v2+ signature present" : "Only v1 signature - consider v2+ for tamper protection" });
758
+ } else {
759
+ findings.push({ check: "signing", status: signingInfo?.includes("DOES NOT VERIFY") ? "critical" : "warning", detail: signingInfo || "apksigner not found" });
760
+ }
761
+ // 3. File size
762
+ try { const stat = run(`stat -f "%z" "${p}" 2>/dev/null`) || run(`stat -c "%s" "${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 {}
763
+ // 4. ProGuard/R8 check
764
+ const hasMapping = run(`unzip -l "${p}" 2>/dev/null | grep -c "classes.dex"`)?.trim();
765
+ const dexCount = parseInt(hasMapping) || 0;
766
+ 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" });
767
+ // Summary
768
+ const critical = findings.filter(f => f.status === "critical").length;
769
+ const warnings = findings.filter(f => f.status === "warning").length;
770
+ return JSON.stringify({ apk: p, summary: { critical, warnings, passed: findings.filter(f => f.status === "pass").length, total_checks: findings.length }, verdict: critical > 0 ? "FAIL - critical issues must be fixed" : warnings > 0 ? "WARN - review warnings" : "PASS - ready for Play Store", findings }, null, 2);
771
+ }
772
+ default: return null;
773
+ }
774
+ }
775
+
776
+ // ── Web Automation Tools (Playwright-powered) ──
777
+
778
+ // Playwright is an optional peer dependency. Single browser instance, lazy-loaded.
779
+ // Tools return a clear message if Playwright is not installed.
780
+
781
+ let _browser = null;
782
+ let _page = null;
783
+
784
+ async function ensureBrowser(browserType = "chromium") {
785
+ if (_browser && _page) return _page;
786
+ let pw;
787
+ try {
788
+ pw = await import("playwright");
789
+ } catch {
790
+ throw new Error("Web tools require Playwright. Install once with: npm i -g playwright && npx playwright install chromium");
791
+ }
792
+ const engines = { chromium: pw.chromium, webkit: pw.webkit, firefox: pw.firefox };
793
+ const engine = engines[browserType] || pw.chromium;
794
+ _browser = await engine.launch({ headless: true });
795
+ const ctx = await _browser.newContext();
796
+ _page = await ctx.newPage();
797
+ return _page;
798
+ }
799
+
800
+ async function closeBrowser() {
801
+ try { await _page?.context()?.close(); } catch {}
802
+ try { await _browser?.close(); } catch {}
803
+ _page = null;
804
+ _browser = null;
805
+ }
806
+
807
+ const WEB_TOOLS = [
808
+ { name: "web_goto", description: "Open a URL in a headless browser (Playwright). Reuses a single browser instance across calls.", inputSchema: { type: "object", properties: { url: { type: "string" }, browser: { type: "string", enum: ["chromium", "webkit", "firefox"], description: "Default chromium; use webkit for Safari-like behavior" }, wait_until: { type: "string", enum: ["load", "domcontentloaded", "networkidle"] } }, required: ["url"] } },
809
+ { name: "web_screenshot", description: "Capture a full-page screenshot of the current page (PNG base64).", inputSchema: { type: "object", properties: { full_page: { type: "boolean" } } } },
810
+ { name: "web_click", description: "Click an element by CSS selector or text. Auto-waits for element.", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector, or 'text=...' / 'role=...'" }, timeout_ms: { type: "number" } }, required: ["selector"] } },
811
+ { name: "web_type", description: "Type text into an input matched by selector.", inputSchema: { type: "object", properties: { selector: { type: "string" }, text: { type: "string" }, clear_first: { type: "boolean" } }, required: ["selector", "text"] } },
812
+ { name: "web_eval", description: "Run arbitrary JavaScript in the page context and return the result as JSON.", inputSchema: { type: "object", properties: { script: { type: "string", description: "JS expression or function body (use `return ...`)" } }, required: ["script"] } },
813
+ { name: "web_wait_for", description: "Wait for a selector to appear (or a timeout).", inputSchema: { type: "object", properties: { selector: { type: "string" }, timeout_ms: { type: "number" }, state: { type: "string", enum: ["attached", "detached", "visible", "hidden"] } }, required: ["selector"] } },
814
+ { name: "web_get_text", description: "Extract textContent of the first match of a selector.", inputSchema: { type: "object", properties: { selector: { type: "string" } }, required: ["selector"] } },
815
+ { name: "web_close", description: "Close the current browser context and release resources.", inputSchema: { type: "object", properties: {} } },
816
+ ];
817
+
818
+ async function handleWeb(name, args) {
819
+ if (name === "web_close") { await closeBrowser(); return "Browser closed"; }
820
+ const page = await ensureBrowser(args.browser);
821
+ switch (name) {
822
+ case "web_goto": {
823
+ await page.goto(args.url, { waitUntil: args.wait_until || "load", timeout: 30000 });
824
+ return `Opened ${args.url} (title: "${await page.title()}")`;
825
+ }
826
+ case "web_screenshot": {
827
+ const buf = await page.screenshot({ fullPage: args.full_page ?? true });
828
+ const path = join(SCREENSHOT_DIR, `web_${Date.now()}.png`);
829
+ writeFileSync(path, buf);
830
+ return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path };
831
+ }
832
+ case "web_click": {
833
+ await page.click(args.selector, { timeout: args.timeout_ms || 5000 });
834
+ return `Clicked: ${args.selector}`;
835
+ }
836
+ case "web_type": {
837
+ if (args.clear_first) await page.fill(args.selector, "");
838
+ await page.fill(args.selector, args.text);
839
+ return `Typed into ${args.selector}: ${args.text.length} chars`;
840
+ }
841
+ case "web_eval": {
842
+ const fn = args.script.includes("return ") ? `(() => { ${args.script} })()` : args.script;
843
+ const result = await page.evaluate(fn);
844
+ return JSON.stringify(result);
845
+ }
846
+ case "web_wait_for": {
847
+ await page.waitForSelector(args.selector, { timeout: args.timeout_ms || 5000, state: args.state || "visible" });
848
+ return `${args.selector} is ${args.state || "visible"}`;
849
+ }
850
+ case "web_get_text": {
851
+ const text = await page.locator(args.selector).first().textContent();
852
+ return text ?? "";
853
+ }
854
+ default: return null;
855
+ }
856
+ }
857
+
858
+ // ── Autonomous Agent DSL (batch step execution) ──
859
+
860
+ const AGENT_TOOLS = [
861
+ {
862
+ name: "agent_run_steps",
863
+ description: "Execute a sequence of device/web/design actions as a single batch. Each step is {tool: <ios_* | android_* | web_* | design_* tool name>, args: {...}, continue_on_error?: bool, wait_ms?: number}. Returns per-step status plus a verdict. A step whose command fails is reported status \"error\"; unless it sets continue_on_error the batch stops there and the result is flagged as an error. Nested agent_* steps are refused. Use this to script multi-step flows (login, navigation, form fill) without multiple MCP round trips.",
864
+ inputSchema: {
865
+ type: "object",
866
+ properties: {
867
+ steps: {
868
+ type: "array",
869
+ items: {
870
+ type: "object",
871
+ properties: {
872
+ tool: { type: "string", description: "Name of an ios_* / android_* / web_* / design_* tool" },
873
+ args: { type: "object" },
874
+ continue_on_error: { type: "boolean", description: "If true, record the error and move on. Default false." },
875
+ wait_ms: { type: "number", description: "Sleep this many ms after this step before the next one." },
876
+ },
877
+ required: ["tool"],
878
+ },
879
+ description: "Ordered list of steps",
880
+ },
881
+ stop_on_first_error: { type: "boolean", description: "Default true. Set false to run every step regardless." },
882
+ },
883
+ required: ["steps"],
884
+ },
885
+ },
886
+ ];
887
+
888
+ // design_* is dispatched here too: the tool description offers it, and a design
889
+ // audit is a legitimate step in a scripted flow. agent_* is refused on purpose -
890
+ // a batch able to nest itself has no recursion bound.
891
+ async function dispatchStep(tool, stepArgs) {
892
+ if (tool.startsWith("agent_")) throw new Error(`Nested batch steps are not supported: ${tool}`);
893
+ let result;
894
+ if (tool.startsWith("ios_")) result = await handleIOS(tool, stepArgs);
895
+ else if (tool.startsWith("android_")) result = await handleAndroid(tool, stepArgs);
896
+ else if (tool.startsWith("web_")) result = await handleWeb(tool, stepArgs);
897
+ else if (tool.startsWith("design_")) result = await handleDesign(tool, stepArgs, designCtx);
898
+ else throw new Error(`Unknown tool: ${tool}`);
899
+ // Every handler's `default:` arm returns null, and that is its only source of
900
+ // null, so null means "I do not recognise this tool". The throw below used to
901
+ // fire only for a name matching no prefix at all, so a typo that kept the
902
+ // family - ios_taap, design_reprot - fell through to the handler, came back
903
+ // null, and was recorded as a step with status "ok" and result "null". Same
904
+ // false-success class as the batch runner not applying isFailure().
905
+ if (result === null || result === undefined) throw new Error(`Unknown tool: ${tool}`);
906
+ return result;
907
+ }
908
+
909
+ async function handleAgent(name, args) {
910
+ if (name !== "agent_run_steps") return null;
911
+ const steps = Array.isArray(args.steps) ? args.steps : [];
912
+ const stopOnError = args.stop_on_first_error !== false;
913
+ const results = [];
914
+ let aborted = false;
915
+ for (let i = 0; i < steps.length; i++) {
916
+ const step = steps[i];
917
+ const stepArgs = step.args || {};
918
+ let outcome;
919
+ try {
920
+ const result = await dispatchStep(step.tool, stepArgs);
921
+ // A failing CLI does not throw: run() returns the ERROR_PREFIX marker
922
+ // string instead. The CallTool handler makes that judgement with
923
+ // isFailure() before answering the host, and the batch has to make the
924
+ // same one - reading the marker as a successful result reported a broken
925
+ // step as status "ok", left errors at 0, published verdict "all_ok", and
926
+ // silently defeated stop_on_first_error.
927
+ if (isFailure(result)) {
928
+ outcome = {
929
+ step: i + 1,
930
+ tool: step.tool,
931
+ status: "error",
932
+ error: String(result).slice(ERROR_PREFIX.length),
933
+ };
934
+ } else {
935
+ outcome = {
936
+ step: i + 1,
937
+ tool: step.tool,
938
+ status: "ok",
939
+ result: typeof result === "object" ? "(binary)" : String(result).slice(0, 500),
940
+ };
941
+ }
942
+ } catch (e) {
943
+ outcome = { step: i + 1, tool: step.tool, status: "error", error: truncateError(e.message) };
944
+ }
945
+ results.push(outcome);
946
+ if (outcome.status === "error" && !step.continue_on_error && stopOnError) {
947
+ aborted = true;
948
+ break;
949
+ }
950
+ if (step.wait_ms) await new Promise((r) => setTimeout(r, step.wait_ms));
951
+ }
952
+ const errors = results.filter((r) => r.status === "error").length;
953
+ const text = JSON.stringify(
954
+ {
955
+ total_steps: steps.length,
956
+ executed: results.length,
957
+ errors,
958
+ verdict: errors === 0 ? "all_ok" : errors === results.length ? "all_failed" : "partial",
959
+ aborted,
960
+ results,
961
+ },
962
+ null,
963
+ 2,
964
+ );
965
+ // An abort is an unambiguous failure of the batch as requested, so the host
966
+ // sees isError. Errors the caller opted into with continue_on_error are not -
967
+ // there the verdict carries the nuance and the call itself succeeded.
968
+ return { type: "batch", text, isError: aborted };
969
+ }
970
+
971
+ // ── Server ──
972
+
973
+ const ALL_TOOLS = [...IOS_TOOLS, ...ANDROID_TOOLS, ...WEB_TOOLS, ...AGENT_TOOLS, ...DESIGN_TOOLS];
974
+
975
+ // Tool annotations (MCP 2025-11-25). Hosts use these for permission prompts and
976
+ // for deciding what may run unattended, so the classification lives here in one
977
+ // auditable place rather than inline on 78 literals.
978
+ const READ_ONLY_TOOLS = new Set([
979
+ "ios_list_devices", "ios_screenshot", "ios_list_apps", "ios_get_ui_tree", "ios_get_app_container",
980
+ "ios_accessibility_audit", "ios_archive_audit", "ios_app_store_audit", "ios_xcresult", "ios_visual_diff",
981
+ "android_list_devices", "android_screenshot", "android_get_ui_tree", "android_list_packages",
982
+ "android_logcat", "android_get_screen_size", "android_accessibility_audit", "android_apk_audit",
983
+ "web_screenshot", "web_get_text",
984
+ "design_mock_detect", "design_scenario_inventory", "design_ui_geometry", "design_visual_compare",
985
+ ]);
986
+
987
+ // Irreversible on the target device: data loss the caller cannot undo.
988
+ const DESTRUCTIVE_TOOLS = new Set([
989
+ "ios_erase_device", "ios_keychain_reset", "ios_reset_permissions",
990
+ "android_clear_app_data", "android_uninstall_app",
991
+ ]);
992
+
993
+ // Repeating the call lands the device in the same state.
994
+ const IDEMPOTENT_TOOLS = new Set([
995
+ "ios_boot_device", "ios_go_home", "ios_terminate_app", "ios_set_appearance", "ios_set_content_size",
996
+ "ios_set_locale", "ios_set_location", "ios_clear_location", "ios_set_increase_contrast",
997
+ "ios_status_bar", "ios_grant_permission", "ios_revoke_permission",
998
+ "android_go_home", "android_stop_app", "android_set_dark_mode", "android_set_font_scale",
999
+ "android_set_locale", "android_set_location", "android_grant_permission", "android_revoke_permission",
1000
+ "web_close",
1001
+ ]);
1002
+
1003
+ // Web tools reach arbitrary sites; everything else talks to a local simulator,
1004
+ // emulator, archive or build output.
1005
+ const withAnnotations = (tool) => ({
1006
+ ...tool,
1007
+ annotations: {
1008
+ readOnlyHint: READ_ONLY_TOOLS.has(tool.name),
1009
+ destructiveHint: DESTRUCTIVE_TOOLS.has(tool.name),
1010
+ idempotentHint: IDEMPOTENT_TOOLS.has(tool.name),
1011
+ openWorldHint: tool.name.startsWith("web_"),
1012
+ },
1013
+ });
1014
+
1015
+ // -- outputSchema + structuredContent --
1016
+ //
1017
+ // The 2026-07-28 spec makes outputSchema a full JSON Schema 2020-12 and lets
1018
+ // structuredContent be any JSON value. Declaring it turns a payload from "text
1019
+ // the model has to parse" into a contract the host can validate, and it is what
1020
+ // lets a caller rely on a field being there.
1021
+ //
1022
+ // Only tools whose shape was read off the return statement are listed. A schema
1023
+ // that does not match the payload is worse than no schema, because the host
1024
+ // rejects a perfectly good result, so these are deliberately absent until their
1025
+ // shape is pinned:
1026
+ //
1027
+ // ios_xcresult four shapes depending on mode
1028
+ // ios_app_store_audit the object is built in tools/ios-app-store-audit
1029
+ // design_mock_detect, design_scenario_inventory, design_visual_compare,
1030
+ // design_report built in tools/design-check
1031
+ // web_eval returns whatever the evaluated expression produced
1032
+ //
1033
+ // Text content stays on every result, so a host that ignores structuredContent
1034
+ // sees no change.
1035
+
1036
+ const ISSUE_LIST = {
1037
+ type: "array",
1038
+ items: {
1039
+ type: "object",
1040
+ properties: {
1041
+ severity: { type: "string", enum: ["critical", "important", "warning"] },
1042
+ message: { type: "string" },
1043
+ },
1044
+ },
1045
+ };
1046
+
1047
+ const ACCESSIBILITY_AUDIT_SCHEMA = {
1048
+ type: "object",
1049
+ required: ["total_issues", "critical", "important", "warning", "issues"],
1050
+ properties: {
1051
+ scope: { type: "string" },
1052
+ elements_scanned: { type: "integer" },
1053
+ elements_skipped: { type: "integer" },
1054
+ total_issues: { type: "integer" },
1055
+ critical: { type: "integer" },
1056
+ important: { type: "integer" },
1057
+ warning: { type: "integer" },
1058
+ issues: ISSUE_LIST,
1059
+ },
1060
+ };
1061
+
1062
+ const AUDIT_SUMMARY = {
1063
+ type: "object",
1064
+ properties: {
1065
+ critical: { type: "integer" },
1066
+ warnings: { type: "integer" },
1067
+ passed: { type: "integer" },
1068
+ total_checks: { type: "integer" },
1069
+ },
1070
+ };
1071
+
1072
+ const bundleAuditSchema = (pathKey) => ({
1073
+ type: "object",
1074
+ required: ["summary", "verdict", "findings"],
1075
+ properties: {
1076
+ [pathKey]: { type: "string" },
1077
+ app: { type: "string" },
1078
+ summary: AUDIT_SUMMARY,
1079
+ verdict: { type: "string" },
1080
+ findings: {
1081
+ type: "array",
1082
+ items: {
1083
+ type: "object",
1084
+ properties: {
1085
+ status: { type: "string", enum: ["pass", "warning", "critical"] },
1086
+ rule: { type: "string" },
1087
+ message: { type: "string" },
1088
+ },
1089
+ },
1090
+ },
1091
+ },
1092
+ });
1093
+
1094
+ const OUTPUT_SCHEMAS = {
1095
+ ios_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
1096
+ android_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
1097
+
1098
+ ios_archive_audit: bundleAuditSchema("archive"),
1099
+ android_apk_audit: bundleAuditSchema("apk"),
1100
+
1101
+ // ios_list_devices deliberately has NO outputSchema.
1102
+ //
1103
+ // It declared `{ type: "array", items: {...} }`, which is not a legal
1104
+ // outputSchema: MCP's `structuredContent` is an OBJECT, so the schema that
1105
+ // describes it must be `type: "object"`. Claude Code validates the entire
1106
+ // tools/list response, so ONE bad schema on the FIRST tool took down all 78:
1107
+ //
1108
+ // Reconnected to dev-toolkit, but fetching tools failed:
1109
+ // path: [tools, 0, outputSchema, type], message: Invalid input: expected "object"
1110
+ //
1111
+ // The server read "connected - tools fetch failed" with every tool unavailable,
1112
+ // and nothing in this repo objected: gate 9 checked that declared schemas come
1113
+ // WITH structuredContent, never that a schema is spec-legal. Gate 9b now does.
1114
+ //
1115
+ // Not fixed by wrapping the payload as `{ devices: [...] }`: this tool's text
1116
+ // output is a JSON array that callers parse, so re-shaping it is a breaking
1117
+ // change bought for structured output nobody requested. Dropping the
1118
+ // declaration restores a legal tools/list and leaves the text byte-identical.
1119
+
1120
+ // Two shapes, both keyed on `passed`: a size mismatch short-circuits before
1121
+ // any pixel comparison, so the diff fields are absent in that one.
1122
+ ios_visual_diff: {
1123
+ type: "object",
1124
+ required: ["passed"],
1125
+ properties: {
1126
+ passed: { type: "boolean" },
1127
+ reason: { type: "string" },
1128
+ baseline_size: { type: "string" },
1129
+ current_size: { type: "string" },
1130
+ diff_pct: { type: "number" },
1131
+ diff_pixels: { type: "integer" },
1132
+ total_pixels: { type: "integer" },
1133
+ threshold: { type: "number" },
1134
+ max_diff_pct: { type: "number" },
1135
+ baseline: { type: "string" },
1136
+ current: { type: "string" },
1137
+ diff_image: { type: "string" },
1138
+ },
1139
+ },
1140
+
1141
+ android_launch_time: {
1142
+ type: "object",
1143
+ required: ["package", "cold_start"],
1144
+ properties: {
1145
+ package: { type: "string" },
1146
+ cold_start: { type: "boolean" },
1147
+ total_time_ms: { type: ["integer", "null"] },
1148
+ wait_time_ms: { type: ["integer", "null"] },
1149
+ raw: { type: "string" },
1150
+ },
1151
+ },
1152
+
1153
+ design_ui_geometry: {
1154
+ type: "object",
1155
+ required: ["platform", "unit", "count", "elements"],
1156
+ properties: {
1157
+ platform: { type: "string", enum: ["ios", "android"] },
1158
+ unit: { type: "string", enum: ["points", "pixels"] },
1159
+ source: { type: "string" },
1160
+ screen: {
1161
+ type: ["object", "null"],
1162
+ properties: { w: { type: "number" }, h: { type: "number" } },
1163
+ },
1164
+ count: { type: "integer" },
1165
+ elements: { type: "array", items: { type: "object" } },
1166
+ },
1167
+ },
1168
+
1169
+ // verdict is the field the pipeline gates read, so it is required and
1170
+ // enumerated rather than a free string.
1171
+ agent_run_steps: {
1172
+ type: "object",
1173
+ required: ["total_steps", "executed", "errors", "verdict", "aborted", "results"],
1174
+ properties: {
1175
+ total_steps: { type: "integer" },
1176
+ executed: { type: "integer" },
1177
+ errors: { type: "integer" },
1178
+ verdict: { type: "string", enum: ["all_ok", "all_failed", "partial"] },
1179
+ aborted: { type: "boolean" },
1180
+ results: {
1181
+ type: "array",
1182
+ items: {
1183
+ type: "object",
1184
+ properties: {
1185
+ tool: { type: "string" },
1186
+ status: { type: "string", enum: ["ok", "error"] },
1187
+ output: { type: "string" },
1188
+ },
1189
+ },
1190
+ },
1191
+ },
1192
+ },
1193
+ };
1194
+
1195
+ const withOutputSchema = (tool) =>
1196
+ OUTPUT_SCHEMAS[tool.name] ? { ...tool, outputSchema: OUTPUT_SCHEMAS[tool.name] } : tool;
1197
+
1198
+ // structuredContent is attached only when the tool declares a schema and its
1199
+ // payload really is JSON. A tool that unexpectedly returned prose still gets
1200
+ // its text content rather than a malformed structured field.
1201
+ function withStructured(name, text) {
1202
+ if (!OUTPUT_SCHEMAS[name]) return { content: [{ type: "text", text }] };
1203
+ try {
1204
+ return { content: [{ type: "text", text }], structuredContent: JSON.parse(text) };
1205
+ } catch {
1206
+ return { content: [{ type: "text", text }] };
1207
+ }
1208
+ }
1209
+
1210
+ const ANNOTATED_TOOLS = ALL_TOOLS.map(withAnnotations).map(withOutputSchema);
1211
+
1212
+ const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
1213
+
1214
+ const server = new Server(
1215
+ { name: "dev-toolkit-mcp", version: PKG.version },
1216
+ { capabilities: { tools: {} } }
1217
+ );
1218
+
1219
+ const designCtx = {
1220
+ run,
1221
+ iosDevice,
1222
+ adbFlag,
1223
+ idb,
1224
+ hasIdb: HAS_IDB,
1225
+ dumperScript: join(__dirname, "ui-tree-dumper.swift"),
1226
+ };
1227
+
1228
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: ANNOTATED_TOOLS }));
1229
+
1230
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1231
+ const { name, arguments: args } = request.params;
1232
+ try {
1233
+ let result;
1234
+ if (name.startsWith("ios_")) result = await handleIOS(name, args || {});
1235
+ else if (name.startsWith("android_")) result = await handleAndroid(name, args || {});
1236
+ else if (name.startsWith("web_")) result = await handleWeb(name, args || {});
1237
+ else if (name.startsWith("agent_")) result = await handleAgent(name, args || {});
1238
+ else if (name.startsWith("design_")) result = await handleDesign(name, args || {}, designCtx);
1239
+ else return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
1240
+ // Same reason as dispatchStep: a handler returns null only from its
1241
+ // `default:` arm, so an unrecognised name that happens to carry a known
1242
+ // family prefix used to answer the host with the literal text "null" and no
1243
+ // isError flag - a failure that reads as success.
1244
+ if (result === null || result === undefined) {
1245
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
1246
+ }
1247
+ if (typeof result === "object" && result?.type === "image") {
1248
+ return { content: [{ type: "image", data: result.data, mimeType: result.mimeType }, { type: "text", text: `Screenshot: ${result.path}` }] };
1249
+ }
1250
+ // The batch report is always the payload; whether the envelope is an error
1251
+ // is decided inside handleAgent (abort yes, opted-into failures no).
1252
+ if (typeof result === "object" && result?.type === "batch") {
1253
+ // The report is JSON either way, so it carries structuredContent even on
1254
+ // the error envelope - a caller reading `verdict` needs it most when the
1255
+ // batch failed.
1256
+ const envelope = withStructured(name, result.text);
1257
+ return result.isError ? { ...envelope, isError: true } : envelope;
1258
+ }
1259
+ // A command failure must surface as an MCP error result, not as text that
1260
+ // reads like success to the host and to the pipeline gates.
1261
+ if (isFailure(result)) {
1262
+ return { content: [{ type: "text", text: String(result) }], isError: true };
1263
+ }
1264
+ return withStructured(name, String(result));
1265
+ } catch (e) {
1266
+ return {
1267
+ content: [{ type: "text", text: `${ERROR_PREFIX}${truncateError(e.message)}` }],
1268
+ isError: true,
1269
+ };
1270
+ }
1271
+ });
1272
+
1273
+ process.on("SIGTERM", async () => { await closeBrowser(); process.exit(0); });
1274
+ process.on("SIGINT", async () => { await closeBrowser(); process.exit(0); });
1275
+
1276
+ const transport = new StdioServerTransport();
1277
+ await server.connect(transport);