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