@mmerterden/multi-agent-toolkit-mcp 3.7.1 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +275 -0
- package/README.md +86 -2
- package/README.tr.md +13 -3
- package/index.js +345 -87
- package/package.json +3 -3
- package/tools/code-intel/index.js +665 -0
- package/tools/code-intel/kotlin.js +159 -0
- package/tools/code-intel/lsp-client.js +422 -0
- package/tools/code-intel/pool.js +273 -0
- package/tools/code-intel/positions.js +195 -0
- package/tools/code-intel/swift.js +249 -0
- package/tools/design-check/index.js +3 -2
- package/tools/design-check/report.js +15 -5
- package/tools/ios-app-store-audit/index.js +16 -2
- package/tools/ios-testflight/index.js +54 -11
- package/tools/memory/index.js +18 -3
- package/tools/offload/index.js +39 -13
- package/tools/pass-kit/index.js +432 -0
- package/tools/pass-kit/sign.js +255 -0
- package/tools/pass-kit/spec.js +317 -0
- package/tools/pass-kit/validate.js +329 -0
package/index.js
CHANGED
|
@@ -16,9 +16,10 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
16
16
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
17
17
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
18
18
|
import { execSync, exec, spawn } from "child_process";
|
|
19
|
-
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync } from "fs";
|
|
19
|
+
import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync, renameSync } from "fs";
|
|
20
20
|
import { join, dirname, basename, isAbsolute, resolve, sep } from "path";
|
|
21
|
-
import { homedir } from "os";
|
|
21
|
+
import { homedir, tmpdir } from "os";
|
|
22
|
+
import { createHash } from "crypto";
|
|
22
23
|
import { fileURLToPath } from "url";
|
|
23
24
|
import { runAudit as runAppStoreAudit } from "./tools/ios-app-store-audit/index.js";
|
|
24
25
|
import {
|
|
@@ -28,6 +29,15 @@ import {
|
|
|
28
29
|
validateApp,
|
|
29
30
|
} from "./tools/ios-testflight/index.js";
|
|
30
31
|
import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
|
|
32
|
+
import {
|
|
33
|
+
CODE_TOOLS,
|
|
34
|
+
handleCode,
|
|
35
|
+
CODE_READ_ONLY,
|
|
36
|
+
CODE_IDEMPOTENT,
|
|
37
|
+
CODE_OUTPUT_SCHEMAS,
|
|
38
|
+
shutdownAllLsp,
|
|
39
|
+
} from "./tools/code-intel/index.js";
|
|
40
|
+
import { PASS_TOOLS, handlePass, PASS_READ_ONLY, PASS_OUTPUT_SCHEMAS } from "./tools/pass-kit/index.js";
|
|
31
41
|
import { parseLaunchOutput } from "./tools/launch-time/index.js";
|
|
32
42
|
import { parseLeaksOutput, parseMeminfoOutput, diffMeminfo } from "./tools/memory/index.js";
|
|
33
43
|
import { auditIosTree, auditAndroidDump, parseAuditResults } from "./tools/a11y/index.js";
|
|
@@ -37,11 +47,16 @@ import {
|
|
|
37
47
|
offloadLargeText,
|
|
38
48
|
queryOffloadedOutput,
|
|
39
49
|
offloadedErrorSummary,
|
|
50
|
+
pruneWorkDir,
|
|
40
51
|
} from "./tools/offload/index.js";
|
|
41
52
|
|
|
42
53
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
43
54
|
const SCREENSHOT_DIR = join(process.env.TMPDIR || "/tmp", "mobile-dev-mcp");
|
|
44
55
|
if (!existsSync(SCREENSHOT_DIR)) mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
56
|
+
// Same retention as the offload dir: this directory collects screenshots, UI
|
|
57
|
+
// dumps, push payloads, build logs and .xcresult bundles, and nothing else
|
|
58
|
+
// ever removed them.
|
|
59
|
+
pruneWorkDir(SCREENSHOT_DIR);
|
|
45
60
|
|
|
46
61
|
// ── Helpers ──
|
|
47
62
|
|
|
@@ -80,6 +95,27 @@ function run(cmd, opts = {}) {
|
|
|
80
95
|
}
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
// run() reports failure by returning a truthy marker string, so `run(a) || run(b)`
|
|
99
|
+
// never falls back. This variant returns null on failure for exactly that shape.
|
|
100
|
+
function runOrNull(cmd, opts = {}) {
|
|
101
|
+
const out = run(cmd, opts);
|
|
102
|
+
return isFailure(out) ? null : out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A capture that must be fresh: a file left by an earlier run would otherwise
|
|
106
|
+
// pass the existence check and be returned as this run's result.
|
|
107
|
+
function discardStale(path) {
|
|
108
|
+
try { if (statSync(path).isFile()) unlinkSync(path); } catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The files a result promises to the caller; retention never removes them.
|
|
112
|
+
function promisedPaths(result) {
|
|
113
|
+
if (!result || typeof result !== "object") return [];
|
|
114
|
+
if (result.type === "file") return result.files.map((f) => f.path);
|
|
115
|
+
if (result.type === "image") return [result.path, result.source].filter(Boolean);
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
|
|
83
119
|
/**
|
|
84
120
|
* Run a command whose non-zero exit is a RESULT, not a failure.
|
|
85
121
|
*
|
|
@@ -215,6 +251,64 @@ function shq(value) {
|
|
|
215
251
|
return `'${String(value ?? "").replace(/'/g, "'\\''")}'`;
|
|
216
252
|
}
|
|
217
253
|
|
|
254
|
+
// `adb shell <cmd>` is parsed twice: once by the host shell, again by the
|
|
255
|
+
// device shell. One shq() layer survives only the first, so the device shell
|
|
256
|
+
// saw `;`, `&` and `$()` in a text or URL as syntax. The inner layer is what
|
|
257
|
+
// the device shell receives.
|
|
258
|
+
function remoteShq(value) {
|
|
259
|
+
return shq(shq(value));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The ui-tree dumper prints its failure as a JSON object on stdout (with the
|
|
263
|
+
// remedy) and exits 1; a plain exit-status error keeps only stderr.
|
|
264
|
+
function dumperErrorOf(text) {
|
|
265
|
+
const s = String(text ?? "");
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(s);
|
|
268
|
+
return parsed && typeof parsed.error === "string" ? parsed.error : null;
|
|
269
|
+
} catch {}
|
|
270
|
+
const m = s.match(/"error"\s*:\s*("(?:[^"\\]|\\.)*")/);
|
|
271
|
+
if (!m) return null;
|
|
272
|
+
try { return JSON.parse(m[1]); } catch { return null; }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const DUMPER_SOURCE = join(__dirname, "ui-tree-dumper.swift");
|
|
276
|
+
|
|
277
|
+
// `swift <script>` re-parses and JITs the dumper on every call (0.9-1.2 s); the
|
|
278
|
+
// -O binary answers in 0.05 s. Compiled on first use, never at server start,
|
|
279
|
+
// and keyed by source + toolchain so an edit or an Xcode update rebuilds it.
|
|
280
|
+
// The directory comes from the environment and the home directory only, never
|
|
281
|
+
// from a tool argument, the same rule the offload dir follows.
|
|
282
|
+
function dumperCacheDir() {
|
|
283
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
284
|
+
const base = xdg && isAbsolute(xdg) ? xdg
|
|
285
|
+
: process.platform === "darwin" ? join(homedir(), "Library", "Caches")
|
|
286
|
+
: join(homedir(), ".cache");
|
|
287
|
+
for (const dir of [join(base, "multi-agent-toolkit-mcp"), join(tmpdir(), "multi-agent-toolkit-mcp")]) {
|
|
288
|
+
try { mkdirSync(dir, { recursive: true }); return dir; } catch {}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let dumperPrefix = null;
|
|
294
|
+
function dumperCommand() {
|
|
295
|
+
if (dumperPrefix) return dumperPrefix;
|
|
296
|
+
dumperPrefix = `swift ${shq(DUMPER_SOURCE)}`;
|
|
297
|
+
const version = runOrNull("swiftc --version", { timeout: 15000 });
|
|
298
|
+
const dir = version ? dumperCacheDir() : null;
|
|
299
|
+
if (!dir) return dumperPrefix;
|
|
300
|
+
const key = createHash("sha256").update(readFileSync(DUMPER_SOURCE)).update(version).digest("hex").slice(0, 16);
|
|
301
|
+
const binary = join(dir, `ui-tree-dumper-${key}`);
|
|
302
|
+
if (!existsSync(binary)) {
|
|
303
|
+
const staging = `${binary}.${process.pid}`;
|
|
304
|
+
const built = run(`swiftc -O -o ${shq(staging)} ${shq(DUMPER_SOURCE)}`, { timeout: 120000 });
|
|
305
|
+
if (isFailure(built) || !existsSync(staging)) return dumperPrefix;
|
|
306
|
+
try { renameSync(staging, binary); } catch { return dumperPrefix; }
|
|
307
|
+
}
|
|
308
|
+
dumperPrefix = shq(binary);
|
|
309
|
+
return dumperPrefix;
|
|
310
|
+
}
|
|
311
|
+
|
|
218
312
|
// Numeric coordinates / scales: reject anything that is not a plain number so it
|
|
219
313
|
// can be interpolated bare.
|
|
220
314
|
function num(value, label) {
|
|
@@ -249,6 +343,39 @@ function hasCommand(cmd) {
|
|
|
249
343
|
}
|
|
250
344
|
|
|
251
345
|
const HAS_XCRUN = hasCommand("xcrun");
|
|
346
|
+
const HAS_SIPS = hasCommand("sips");
|
|
347
|
+
|
|
348
|
+
// The inline image goes to the model on every capture, and a simulator PNG is
|
|
349
|
+
// ~2.9 MB (3.9 MB as base64). sips turns it into an 800 px JPEG of ~60 KB in
|
|
350
|
+
// 0.12 s, so that is the default; the full-resolution file stays on disk.
|
|
351
|
+
function captureOptions(args) {
|
|
352
|
+
const format = args.format === "png" ? "png" : "jpeg";
|
|
353
|
+
const maxWidth = num(args.max_width ?? 800, "max_width");
|
|
354
|
+
const quality = num(args.quality ?? 80, "quality");
|
|
355
|
+
if (!Number.isInteger(maxWidth) || maxWidth < 0) throw new Error(`Invalid max_width: ${args.max_width} (0 or a positive pixel count)`);
|
|
356
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 100) throw new Error(`Invalid quality: ${args.quality} (1-100)`);
|
|
357
|
+
return { format, maxWidth, quality };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function inlineCapture(f, { format, maxWidth, quality }) {
|
|
361
|
+
const asIs = (note) => ({ type: "image", data: readFileSync(f).toString("base64"), mimeType: "image/png", path: f, note });
|
|
362
|
+
if (format === "png" && maxWidth === 0) return asIs("");
|
|
363
|
+
if (!HAS_SIPS) return asIs(" (sips unavailable, full-resolution PNG returned)");
|
|
364
|
+
const out = f.replace(/\.png$/, "") + (format === "jpeg" ? ".jpg" : `_${maxWidth}.png`);
|
|
365
|
+
discardStale(out);
|
|
366
|
+
const scale = maxWidth ? ` -Z ${maxWidth}` : "";
|
|
367
|
+
const options = format === "jpeg" ? ` -s formatOptions ${quality}` : "";
|
|
368
|
+
const converted = run(`sips${scale} -s format ${format}${options} ${shq(f)} --out ${shq(out)}`);
|
|
369
|
+
if (isFailure(converted) || !existsSync(out)) return asIs(" (sips failed, full-resolution PNG returned)");
|
|
370
|
+
return {
|
|
371
|
+
type: "image",
|
|
372
|
+
data: readFileSync(out).toString("base64"),
|
|
373
|
+
mimeType: format === "jpeg" ? "image/jpeg" : "image/png",
|
|
374
|
+
path: out,
|
|
375
|
+
source: f,
|
|
376
|
+
note: ` (full-resolution PNG: ${f})`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
252
379
|
|
|
253
380
|
// idb (Facebook) is the real iOS Simulator UI driver - `simctl io` has NO tap/swipe/type
|
|
254
381
|
// operation, so tap/swipe/type/geometry route through idb. Resolve its binary + set a PATH
|
|
@@ -325,9 +452,9 @@ function iosDevice(id) {
|
|
|
325
452
|
const IOS_TOOLS = [
|
|
326
453
|
{ name: "ios_list_devices", description: "List all available iOS simulators and their state", inputSchema: { type: "object", properties: {} } },
|
|
327
454
|
{ name: "ios_boot_device", description: "Boot an iOS simulator by UDID or name", inputSchema: { type: "object", properties: { device: { type: "string", description: "Device UDID or name" } }, required: ["device"] } },
|
|
328
|
-
{ name: "ios_screenshot", description: "Capture an iOS simulator screenshot.
|
|
455
|
+
{ name: "ios_screenshot", description: "Capture an iOS simulator screenshot. Without `path` the image comes back inline, downscaled to 800 px on the longest side as a quality-80 JPEG (~60 KB instead of the ~3 MB full-resolution PNG, which is ~4 MB as base64 on every capture); `format: \"png\"` with `max_width: 0` returns the original PNG bytes. The full-resolution PNG stays on disk either way. Pass `path` to write the full-resolution PNG there and return only its location, which is what a run taking many captures needs - and what design-check and ios_visual_diff read. Use when you need to see the screen, or to collect captures for a later comparison.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, path: { type: "string", description: "Absolute file path to write the PNG to. The parent directory must already exist. Returns the path instead of the image." }, max_width: { type: "integer", description: "Longest side of the inline image in pixels (default 800). 0 keeps the original size. Ignored with `path`." }, format: { type: "string", enum: ["jpeg", "png"], description: "Inline image format (default jpeg). Ignored with `path`." }, quality: { type: "integer", description: "JPEG quality 1-100 (default 80). Ignored for png and with `path`." } } } },
|
|
329
456
|
{ name: "ios_tap", description: "Tap at coordinates on iOS simulator", inputSchema: { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, device_id: { type: "string" } }, required: ["x", "y"] } },
|
|
330
|
-
{ name: "ios_swipe", description: "Swipe on iOS simulator", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
|
|
457
|
+
{ name: "ios_swipe", description: "Swipe on iOS simulator", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, duration_ms: { type: "number", description: "Swipe duration in milliseconds" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
|
|
331
458
|
{ name: "ios_type_text", description: "Type text on iOS simulator", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
|
|
332
459
|
{ name: "ios_launch_app", description: "Launch iOS app by bundle ID", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
|
|
333
460
|
{ name: "ios_terminate_app", description: "Terminate iOS app", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, device_id: { type: "string" } }, required: ["bundle_id"] } },
|
|
@@ -357,7 +484,7 @@ const IOS_TOOLS = [
|
|
|
357
484
|
{ name: "ios_export_ipa", description: "Export a .xcarchive to a signed .ipa via xcodebuild -exportArchive. Generates the exportOptions.plist from the arguments (method defaults to app-store-connect), so callers do not have to hand-maintain one. Returns the .ipa path plus parsed errors; a run that exits 0 without producing an .ipa is reported as a failure. Pair with ios_testflight_validate for the pre-submission gate.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive" }, output_dir: { type: "string", description: "Directory to write the .ipa into" }, method: { type: "string", description: "Export method: app-store-connect (default) | release-testing | enterprise | development" }, team_id: { type: "string", description: "Apple Developer team ID" }, provisioning_profiles: { type: "object", description: "Map of bundleId -> provisioning profile name (manual signing)" }, signing_style: { type: "string", description: "automatic | manual" }, upload_symbols: { type: "boolean", description: "Include symbols (default true)" }, allow_provisioning_updates: { type: "boolean", description: "Off by default. Lets xcodebuild register devices and create/modify provisioning profiles in the developer account - a change on Apple's side, so it is opt-in" }, timeout_sec: { type: "number", description: "Default 900" } }, required: ["archive_path", "output_dir"] } },
|
|
358
485
|
{ name: "ios_testflight_validate", description: "Run Apple's own pre-submission validation on an .ipa via `xcrun altool --validate-app`, then map returned ITMS error codes onto the App Store rule each one implies. This is the authoritative gate: unlike the static ios_app_store_audit it can catch an unregistered bundle ID, a profile that does not match the App Store Connect app record, a version+build pair already used, and entitlements not provisioned for the app ID. Auth is a 3-tier chain: ASC API key (api_key_id + api_issuer_id), else Apple ID + app-specific password referenced indirectly through a keychain item or env var (never passed by value), else the gate returns verdict SKIPPED with a reason. SKIPPED is not a pass - it means Apple was never asked. Set list_providers=true for a pre-flight that reports which teams the credentials can deliver for (needed when a corporate Apple ID belongs to several).", inputSchema: { type: "object", properties: { ipa_path: { type: "string", description: "Absolute path to the .ipa" }, platform: { type: "string", description: "ios (default) | appletvos | visionos | macos" }, api_key_id: { type: "string", description: "App Store Connect API key ID (tier 1)" }, api_issuer_id: { type: "string", description: "App Store Connect issuer ID (tier 1)" }, p8_path: { type: "string", description: "Explicit path to AuthKey_<id>.p8; otherwise altool's search dirs are used" }, apple_id: { type: "string", description: "Apple ID (tier 2)" }, keychain_item: { type: "string", description: "Keychain item holding the app-specific password (tier 2, preferred)" }, password_env_var: { type: "string", description: "Env var holding the app-specific password (tier 2 fallback)" }, provider_public_id: { type: "string", description: "Required when the account belongs to multiple providers" }, list_providers: { type: "boolean", description: "Pre-flight only: list deliverable providers and return" }, timeout_sec: { type: "number", description: "Default 900" } }, required: [] } },
|
|
359
486
|
{ name: "ios_app_store_audit", description: "Deep App Store Review compliance audit for .xcarchive bundles. 18-rule catalog covering privacy manifest, code signing, embedded SDKs, entitlements, asset hygiene, IPv6 compliance, debug-tool leak detection, Swift ABI compatibility, SDK floor (ITMS-90725), and more. Returns structured JSON with severity-ranked violations and ITMS error code mappings. Replaces ios_archive_audit (deprecated). Pass rules='core' for the 6 baseline checks (code-signing, entitlements, info-plist, privacy-manifest, binary-size, sdk-floor); 'all'|'deep' for the full 18-rule scan; CSV like 'binary-size,ipv6-compliance' for an explicit subset.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive bundle" }, rules: { type: "string", description: "'all' (default) | 'core' | 'deep' | comma-separated ruleIDs" } }, required: ["archive_path"] } },
|
|
360
|
-
{ name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
|
|
487
|
+
{ name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator; for action test, the booted simulator (xcodebuild refuses a generic destination for test)" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
|
|
361
488
|
{ name: "ios_xcresult", description: "Drill into a previous ios_xcodebuild result by xcresult ID. Modes: summary (counts), errors (file:line + message), warnings, log (last N lines), tests (failed), metrics (XCTMetric performance results as JSON). Use this instead of dumping the whole build log into context.", inputSchema: { type: "object", properties: { id: { type: "string", description: "xcresult ID returned by ios_xcodebuild" }, mode: { type: "string", enum: ["summary", "errors", "warnings", "log", "tests", "metrics"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" }, test_id: { type: "string", description: "mode=metrics only: scope to one test case or suite instead of every measured test" } }, required: ["id"] } },
|
|
362
489
|
{ name: "ios_visual_diff", description: "Compare two PNG screenshots. Returns JSON with diff_pct, pass/fail vs threshold, and an optional diff image. Use for snapshot regression checks across light/dark, locale, dynamic type variants.", inputSchema: { type: "object", properties: { baseline: { type: "string", description: "Path to baseline PNG" }, current: { type: "string", description: "Path to current PNG" }, threshold: { type: "number", description: "Per-pixel color threshold 0..1 (default 0.1, lower = stricter)" }, max_diff_pct: { type: "number", description: "Fail if diff exceeds this percent (default 1.0)" }, output: { type: "string", description: "Path to write diff PNG (optional)" } }, required: ["baseline", "current"] } },
|
|
363
490
|
{ name: "ios_leaks", description: "Look for leaked memory in a running simulator (or host) process with /usr/bin/leaks. mode=snapshot reports the current leak count and bytes; mode=diff reports only leaks new since a saved memory graph, which is the shape a regression gate wants. Reports measurable:false when the target lacks get-task-allow rather than reporting it as clean - leaks exits 0 in that case, so an unmeasurable target and a clean one are indistinguishable by exit status. Debug builds are debuggable; Apple-signed apps are not.", inputSchema: { type: "object", properties: { pid: { type: "number", description: "Process id. Either this or bundle_id." }, bundle_id: { type: "string", description: "Bundle id of an app running on the booted simulator; its pid is resolved for you." }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_graph: { type: "string", description: "mode=diff: path to the memory graph saved by an earlier call" }, output_graph: { type: "string", description: "Save a memory graph here to use as a later baseline" } }, required: [] } },
|
|
@@ -365,8 +492,12 @@ const IOS_TOOLS = [
|
|
|
365
492
|
{ name: "ios_list_crashes", description: "List recent crash reports from the host's ~/Library/Logs/DiagnosticReports - where simulator app crashes land. Filter by process name, bound by age and count.", inputSchema: { type: "object", properties: { app: { type: "string", description: "Only reports whose file name (the crashed process) contains this substring" }, since_min: { type: "number", description: "Only reports newer than this many minutes" }, limit: { type: "number", description: "Max reports returned, newest first (default 20)" } } } },
|
|
366
493
|
];
|
|
367
494
|
|
|
495
|
+
// Pure-JS members of the ios_* family: comparing two PNGs or listing crash
|
|
496
|
+
// reports needs no simulator, so a missing Xcode must not refuse them.
|
|
497
|
+
const IOS_TOOLS_WITHOUT_XCRUN = new Set(["ios_visual_diff", "ios_list_crashes", "ios_xcresult"]);
|
|
498
|
+
|
|
368
499
|
async function handleIOS(name, args, ctx = {}) {
|
|
369
|
-
if (!HAS_XCRUN) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
|
|
500
|
+
if (!HAS_XCRUN && !IOS_TOOLS_WITHOUT_XCRUN.has(name)) return `${ERROR_PREFIX}Xcode not installed - iOS tools unavailable. Install Xcode and run: xcode-select --install`;
|
|
370
501
|
const did = (n) => { try { return iosDevice(n); } catch (e) { return null; } };
|
|
371
502
|
|
|
372
503
|
switch (name) {
|
|
@@ -393,11 +524,13 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
393
524
|
const parent = dirname(f);
|
|
394
525
|
if (!existsSync(parent)) return `ERROR: directory does not exist: ${parent}`;
|
|
395
526
|
}
|
|
396
|
-
|
|
527
|
+
const inline = args.path ? null : captureOptions(args);
|
|
528
|
+
discardStale(f);
|
|
529
|
+
const shot = run(`xcrun simctl io ${d} screenshot ${shq(f)}`);
|
|
530
|
+
if (isFailure(shot)) return shot;
|
|
397
531
|
if (!existsSync(f)) return "ERROR: Screenshot failed";
|
|
398
532
|
if (args.path) return fileResult(`Screenshot written: ${f}`, f);
|
|
399
|
-
|
|
400
|
-
return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
|
|
533
|
+
return inlineCapture(f, inline);
|
|
401
534
|
}
|
|
402
535
|
case "ios_tap": { const d = iosDevice(args.device_id); const r = idb(`ui tap --udid ${d} ${num(args.x, "x")} ${num(args.y, "y")}`); return (typeof r === "string" && r.startsWith("ERROR")) ? r : `Tapped (${args.x}, ${args.y})`; }
|
|
403
536
|
case "ios_swipe": { const d = iosDevice(args.device_id); const dur = args.duration_ms ? ` --duration ${(args.duration_ms / 1000).toFixed(2)}` : ""; const r = idb(`ui swipe --udid ${d} ${num(args.x1, "x1")} ${num(args.y1, "y1")} ${num(args.x2, "x2")} ${num(args.y2, "y2")}${dur}`); return isFailure(r) ? r : "Swiped"; }
|
|
@@ -416,7 +549,19 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
416
549
|
}
|
|
417
550
|
case "ios_set_appearance": { const d = iosDevice(args.device_id); return run(`xcrun simctl ui ${d} appearance ${token(args.mode, "appearance mode")}`) || `Appearance: ${args.mode}`; }
|
|
418
551
|
case "ios_set_content_size": { const d = iosDevice(args.device_id); return run(`xcrun simctl ui ${d} content_size ${token(args.size, "content size")}`) || `Content size: ${args.size}`; }
|
|
419
|
-
case "ios_set_locale": {
|
|
552
|
+
case "ios_set_locale": {
|
|
553
|
+
const d = iosDevice(args.device_id);
|
|
554
|
+
const bid = sanitizeId(args.bundle_id);
|
|
555
|
+
const written = run(`xcrun simctl spawn ${d} defaults write ${bid} AppleLanguages -array ${shq(args.language)}`);
|
|
556
|
+
if (isFailure(written)) return written;
|
|
557
|
+
// An app that is not running has nothing to terminate; that is not a
|
|
558
|
+
// failure of the locale change, which applies at the next launch.
|
|
559
|
+
const stopped = run(`xcrun simctl terminate ${d} ${bid}`);
|
|
560
|
+
if (isFailure(stopped) && !/No such process|found nothing to terminate|not running/i.test(stopped)) return stopped;
|
|
561
|
+
const launched = run(`xcrun simctl launch ${d} ${bid}`);
|
|
562
|
+
if (isFailure(launched)) return launched;
|
|
563
|
+
return `Locale: ${args.language} (${bid} relaunched)`;
|
|
564
|
+
}
|
|
420
565
|
case "ios_open_url": { const d = iosDevice(args.device_id); return run(`xcrun simctl openurl ${d} ${shq(args.url)}`) || `Opened: ${args.url}`; }
|
|
421
566
|
case "ios_status_bar": {
|
|
422
567
|
const d = iosDevice(args.device_id);
|
|
@@ -481,11 +626,10 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
481
626
|
}
|
|
482
627
|
case "ios_add_media": { const d = iosDevice(args.device_id); return run(`xcrun simctl addmedia ${d} ${shq(args.file_path)}`) || "Media added"; }
|
|
483
628
|
case "ios_keychain_reset": { const d = iosDevice(args.device_id); return run(`xcrun simctl keychain ${d} reset`) || "Keychain reset"; }
|
|
484
|
-
case "ios_get_app_container": { const d = iosDevice(args.device_id); return run(`xcrun simctl get_app_container ${d} ${sanitizeId(args.bundle_id)} ${args.container || "app"}`); }
|
|
629
|
+
case "ios_get_app_container": { const d = iosDevice(args.device_id); return run(`xcrun simctl get_app_container ${d} ${sanitizeId(args.bundle_id)} ${token(args.container || "app", "container")}`); }
|
|
485
630
|
case "ios_erase_device": { const d = iosDevice(args.device_id); return run(`xcrun simctl erase ${d}`) || "Device erased"; }
|
|
486
631
|
case "ios_get_ui_tree": {
|
|
487
|
-
|
|
488
|
-
if (!existsSync(script)) return "ui-tree-dumper.swift not found";
|
|
632
|
+
if (!existsSync(DUMPER_SOURCE)) return "ui-tree-dumper.swift not found";
|
|
489
633
|
// Path check first, matching the Android sibling: the AX dump takes up to
|
|
490
634
|
// 15s, too expensive to spend before rejecting a bad destination.
|
|
491
635
|
if (args.path) {
|
|
@@ -493,16 +637,18 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
493
637
|
if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
|
|
494
638
|
}
|
|
495
639
|
const depth = num(args.max_depth ?? 10, "max_depth");
|
|
496
|
-
const tree =
|
|
497
|
-
if (
|
|
640
|
+
const tree = runCapture(`${dumperCommand()} ${depth}`, { timeout: 15000 });
|
|
641
|
+
if (isFailure(tree)) return tree;
|
|
642
|
+
const dumperError = dumperErrorOf(tree);
|
|
643
|
+
if (dumperError) return `${ERROR_PREFIX}${dumperError}`;
|
|
644
|
+
if (!args.path) return tree;
|
|
498
645
|
writeFileSync(String(args.path), tree);
|
|
499
646
|
return fileResult(`UI tree written: ${args.path}`, String(args.path));
|
|
500
647
|
}
|
|
501
648
|
case "ios_accessibility_audit": {
|
|
502
|
-
|
|
503
|
-
if (!existsSync(script)) return "ui-tree-dumper.swift not found";
|
|
649
|
+
if (!existsSync(DUMPER_SOURCE)) return "ui-tree-dumper.swift not found";
|
|
504
650
|
const depth = num(args.max_depth ?? 10, "max_depth");
|
|
505
|
-
const treeJson = run(
|
|
651
|
+
const treeJson = run(`${dumperCommand()} ${depth}`, { timeout: 30000 });
|
|
506
652
|
let tree = null;
|
|
507
653
|
try {
|
|
508
654
|
tree = JSON.parse(treeJson);
|
|
@@ -553,14 +699,14 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
553
699
|
const binaryName = appName.replace(".app", "");
|
|
554
700
|
const binaryPath = `${appDir}/${binaryName}`;
|
|
555
701
|
// 2. Binary size
|
|
556
|
-
const
|
|
557
|
-
if (sizeOut) { const sizeMB = parseInt(sizeOut) / 1048576; findings.push({ check: "binary_size", value: `${sizeMB.toFixed(1)} MB`, status: sizeMB > 500 ? "warning" : "pass", detail: sizeMB > 500 ? "Binary exceeds 500MB - may hit App Store limits" : "OK" }); }
|
|
702
|
+
try { const sizeMB = statSync(binaryPath).size / 1048576; findings.push({ check: "binary_size", value: `${sizeMB.toFixed(1)} MB`, status: sizeMB > 500 ? "warning" : "pass", detail: sizeMB > 500 ? "Binary exceeds 500MB - may hit App Store limits" : "OK" }); } catch {}
|
|
558
703
|
// 3. Debug tool leak check
|
|
559
704
|
const debugSymbols = run(`nm ${shq(binaryPath)} 2>/dev/null | grep -iE "FLEX|Reveal|Stetho|Flipper|CocoaDebug|Pulse" | head -10`);
|
|
560
705
|
findings.push({ check: "debug_tools", status: debugSymbols ? "critical" : "pass", detail: debugSymbols ? `Debug tools found in binary: ${debugSymbols}` : "No debug tools detected" });
|
|
561
|
-
// 4. Code signing
|
|
562
|
-
|
|
563
|
-
const
|
|
706
|
+
// 4. Code signing. codesign exits 1 on an unsigned bundle, and run()'s
|
|
707
|
+
// failure marker does not carry its "not signed" line; runCapture does.
|
|
708
|
+
const codesign = runCapture(`codesign -dvv ${shq(appDir)} 2>&1`);
|
|
709
|
+
const hasSignature = codesign && !isFailure(codesign) && !codesign.includes("not signed");
|
|
564
710
|
findings.push({ check: "code_signing", status: hasSignature ? "pass" : "critical", detail: hasSignature ? "Signed" : "NOT SIGNED - will be rejected" });
|
|
565
711
|
// 5. Provisioning profile
|
|
566
712
|
const provProfile = run(`ls ${shq(appDir + "/embedded.mobileprovision")} 2>/dev/null`);
|
|
@@ -689,7 +835,16 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
689
835
|
if (args.project && args.workspace) return "ERROR: pass project OR workspace, not both";
|
|
690
836
|
const action = args.action || "build";
|
|
691
837
|
const config = args.configuration || "Release";
|
|
692
|
-
|
|
838
|
+
let dest = args.destination;
|
|
839
|
+
if (!dest) {
|
|
840
|
+
if (action === "test") {
|
|
841
|
+
const d = did();
|
|
842
|
+
if (!d) return `${ERROR_PREFIX}action "test" needs a booted simulator: xcodebuild refuses generic/platform=iOS Simulator for test. Boot one with ios_boot_device, or pass destination explicitly.`;
|
|
843
|
+
dest = `platform=iOS Simulator,id=${d}`;
|
|
844
|
+
} else {
|
|
845
|
+
dest = "generic/platform=iOS Simulator";
|
|
846
|
+
}
|
|
847
|
+
}
|
|
693
848
|
const id = `xcresult-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
694
849
|
const xcresultPath = join(SCREENSHOT_DIR, `${id}.xcresult`);
|
|
695
850
|
const logPath = join(SCREENSHOT_DIR, `${id}.log`);
|
|
@@ -804,13 +959,21 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
804
959
|
return diffImagePath ? fileResult(report, diffImagePath) : report;
|
|
805
960
|
}
|
|
806
961
|
case "ios_leaks": {
|
|
807
|
-
|
|
962
|
+
// The schema validator accepts a numeric string for `pid`, so coerce
|
|
963
|
+
// rather than re-check the raw type here.
|
|
964
|
+
const pidNum = args.pid === undefined || args.pid === null || args.pid === "" ? NaN : Number(args.pid);
|
|
965
|
+
let pid = Number.isInteger(pidNum) && pidNum > 0 ? pidNum : null;
|
|
808
966
|
if (!pid && args.bundle_id) {
|
|
809
967
|
const d = iosDevice(args.device_id);
|
|
810
|
-
const
|
|
811
|
-
const
|
|
812
|
-
if (
|
|
813
|
-
|
|
968
|
+
const id = sanitizeId(args.bundle_id);
|
|
969
|
+
const out = run(`xcrun simctl spawn ${d} launchctl list 2>/dev/null`);
|
|
970
|
+
if (isFailure(out)) return out;
|
|
971
|
+
// Whole-token match: a substring grep of com.x also matched com.xy, and
|
|
972
|
+
// the dots were regex wildcards.
|
|
973
|
+
const label = new RegExp(`(^|[^A-Za-z0-9._-])${id.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}($|[^A-Za-z0-9._-])`);
|
|
974
|
+
const line = out.split("\n").find((l) => /^\d+\s/.test(l) && label.test(l));
|
|
975
|
+
if (!line) return `ERROR: no running process for ${args.bundle_id} on the booted simulator; launch it first`;
|
|
976
|
+
pid = parseInt(line, 10);
|
|
814
977
|
}
|
|
815
978
|
if (!pid) return "ERROR: pass pid or bundle_id";
|
|
816
979
|
|
|
@@ -823,8 +986,12 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
823
986
|
const diffArg = mode === "diff" ? ` --diffFrom=${shq(args.baseline_graph)}` : "";
|
|
824
987
|
// leaks exits 1 when it FINDS leaks, so a non-zero status is a result and
|
|
825
988
|
// not a failure. Everything below is decided from the parsed output.
|
|
826
|
-
|
|
827
|
-
|
|
989
|
+
// spawnCollect rather than execSync: a two-minute scan must not block the
|
|
990
|
+
// event loop, and its output is capped instead of killing the child.
|
|
991
|
+
const res = await spawnCollect(`leaks ${pid}${diffArg}${graphOut} 2>&1`, { timeout: 120000, signal: ctx.signal });
|
|
992
|
+
if (res.timedOut) return `${ERROR_PREFIX}leaks did not finish within 120s for pid ${pid}`;
|
|
993
|
+
if (res.aborted) return `${ERROR_PREFIX}leaks cancelled`;
|
|
994
|
+
const parsed = parseLeaksOutput(res.output);
|
|
828
995
|
return JSON.stringify({
|
|
829
996
|
pid,
|
|
830
997
|
mode,
|
|
@@ -847,14 +1014,28 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
847
1014
|
const only = args.test_identifier ? ` -only-testing:${shq(args.test_identifier)}` : "";
|
|
848
1015
|
// xcodebuild exits non-zero when the audit finds anything, because each
|
|
849
1016
|
// finding is an XCTest failure. That is a result, not a failure to run.
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
);
|
|
1017
|
+
// spawnCollect: a 15-minute test run through execSync blocked the event
|
|
1018
|
+
// loop for its whole duration, and its 1MB maxBuffer killed the child on
|
|
1019
|
+
// any real build log.
|
|
1020
|
+
const stopHeartbeat = startHeartbeat(ctx, "xcodebuild test (accessibility audit)");
|
|
1021
|
+
let build;
|
|
1022
|
+
try {
|
|
1023
|
+
build = await spawnCollect(
|
|
1024
|
+
`xcodebuild test ${container} -scheme ${shq(args.scheme)} -destination ${shq(`platform=iOS Simulator,id=${d}`)} -resultBundlePath ${shq(bundle)}${only} 2>&1`,
|
|
1025
|
+
{ timeout: 900000, signal: ctx.signal },
|
|
1026
|
+
);
|
|
1027
|
+
} finally {
|
|
1028
|
+
stopHeartbeat();
|
|
1029
|
+
}
|
|
854
1030
|
if (!existsSync(bundle)) {
|
|
855
|
-
|
|
1031
|
+
const reason = build.timedOut
|
|
1032
|
+
? "xcodebuild did not finish within 900s; no result bundle was written"
|
|
1033
|
+
: build.aborted
|
|
1034
|
+
? "the request was cancelled before xcodebuild finished"
|
|
1035
|
+
: "xcodebuild produced no result bundle; the build failed before any test ran";
|
|
1036
|
+
return JSON.stringify({ measurable: false, reason, test_ran: false, findings: [] }, null, 2);
|
|
856
1037
|
}
|
|
857
|
-
const json =
|
|
1038
|
+
const json = (await spawnCollect(`xcrun xcresulttool get test-results tests --path ${shq(bundle)} --format json 2>&1`, { timeout: 60000, signal: ctx.signal })).output;
|
|
858
1039
|
const r = parseAuditResults(json, args.test_identifier || null);
|
|
859
1040
|
return JSON.stringify({
|
|
860
1041
|
measurable: r.measurable,
|
|
@@ -897,14 +1078,23 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
897
1078
|
// model-controlled device_id a command injection.
|
|
898
1079
|
function adbFlag(id) { return id ? `-s ${deviceSerial(id)}` : ""; }
|
|
899
1080
|
|
|
1081
|
+
// One rule for both launchers: `activity` is the class, relative or fully
|
|
1082
|
+
// qualified, and the component is package/activity. A value that already
|
|
1083
|
+
// carries the package (com.x/.Main) is used as it is.
|
|
1084
|
+
function androidComponent(packageName, activity) {
|
|
1085
|
+
const pkg = sanitizeId(packageName);
|
|
1086
|
+
const act = sanitizeId(activity);
|
|
1087
|
+
return act.includes("/") ? act : `${pkg}/${act}`;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
900
1090
|
const ANDROID_TOOLS = [
|
|
901
1091
|
{ name: "android_list_devices", description: "List connected Android devices and emulators", inputSchema: { type: "object", properties: {} } },
|
|
902
|
-
{ name: "android_screenshot", description: "Capture an Android screenshot.
|
|
1092
|
+
{ name: "android_screenshot", description: "Capture an Android screenshot. Without `path` the image comes back inline, downscaled to 800 px on the longest side as a quality-80 JPEG (~60 KB instead of a multi-MB full-resolution PNG on every capture); `format: \"png\"` with `max_width: 0` returns the original PNG bytes. Downscaling needs sips (macOS); without it the original PNG is returned and the result says so. Pass `path` to write the full-resolution PNG there and return only its location - what a multi-capture run and the design tools need.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, path: { type: "string", description: "Absolute file path to write the PNG to. The parent directory must already exist. Returns the path instead of the image." }, max_width: { type: "integer", description: "Longest side of the inline image in pixels (default 800). 0 keeps the original size. Ignored with `path`." }, format: { type: "string", enum: ["jpeg", "png"], description: "Inline image format (default jpeg). Ignored with `path`." }, quality: { type: "integer", description: "JPEG quality 1-100 (default 80). Ignored for png and with `path`." } } } },
|
|
903
1093
|
{ name: "android_tap", description: "Tap at coordinates on Android", inputSchema: { type: "object", properties: { x: { type: "number" }, y: { type: "number" }, device_id: { type: "string" } }, required: ["x", "y"] } },
|
|
904
1094
|
{ name: "android_swipe", description: "Swipe on Android", inputSchema: { type: "object", properties: { x1: { type: "number" }, y1: { type: "number" }, x2: { type: "number" }, y2: { type: "number" }, duration_ms: { type: "number" }, device_id: { type: "string" } }, required: ["x1", "y1", "x2", "y2"] } },
|
|
905
|
-
{ name: "android_type_text", description: "Type text on Android", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
|
|
1095
|
+
{ name: "android_type_text", description: "Type text on Android via `adb shell input text`. The text reaches the device shell as a quoted literal, so shell characters are typed, not interpreted. Platform limit: `input text` drops non-ASCII characters and some symbols; use the keyboard for those.", inputSchema: { type: "object", properties: { text: { type: "string" }, device_id: { type: "string" } }, required: ["text"] } },
|
|
906
1096
|
{ name: "android_key_event", description: "Send Android key event (HOME=3, BACK=4, ENTER=66)", inputSchema: { type: "object", properties: { keycode: { type: "number" }, device_id: { type: "string" } }, required: ["keycode"] } },
|
|
907
|
-
{ name: "android_launch_app", description: "Launch Android app by package name", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
1097
|
+
{ name: "android_launch_app", description: "Launch Android app by package name", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string", description: "Activity class, relative (.MainActivity) or fully qualified (com.example.MainActivity); started as package_name/activity. Omit to launch through the LAUNCHER intent." }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
908
1098
|
{ name: "android_stop_app", description: "Force-stop Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
909
1099
|
{ name: "android_list_packages", description: "List installed Android packages", inputSchema: { type: "object", properties: { filter: { type: "string" }, device_id: { type: "string" } } } },
|
|
910
1100
|
{ name: "android_go_home", description: "Press Android home button", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
|
|
@@ -924,13 +1114,26 @@ const ANDROID_TOOLS = [
|
|
|
924
1114
|
{ name: "android_open_url", description: "Open URL or deep link on Android", inputSchema: { type: "object", properties: { url: { type: "string" }, device_id: { type: "string" } }, required: ["url"] } },
|
|
925
1115
|
{ name: "android_clear_app_data", description: "Clear all data for Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
926
1116
|
{ name: "android_accessibility_audit", description: "Audit Android app accessibility on the connected device: missing contentDescription, clickable nodes TalkBack cannot focus, touch targets under 48dp, missing resource-ids, and whether the reading order follows the visual layout. Reports measurable:false with a reason rather than a clean result when the dump could not be read.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, scope: { type: "string", description: "Filter: only audit elements whose resource-id contains this prefix (e.g. 'login_', 'com.example:id/login_'). Omit to audit all." }, rtl: { type: "boolean", description: "Expect right-to-left reading within a row. Default false." } } } },
|
|
927
|
-
{ name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
1117
|
+
{ name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string", description: "Activity class, relative (.MainActivity) or fully qualified (com.example.MainActivity); started as package_name/activity. Default .MainActivity." }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
928
1118
|
{ name: "android_apk_audit", description: "Audit APK/AAB for Play Store compliance: debug flag, target SDK, permissions, signing, ProGuard", inputSchema: { type: "object", properties: { apk_path: { type: "string", description: "Path to .apk file" } }, required: ["apk_path"] } },
|
|
929
1119
|
{ name: "android_meminfo", description: "Read an Android app's memory via `adb shell dumpsys meminfo` (KB, no root). mode=snapshot returns the App Summary rows and totals; mode=diff compares two snapshots so growth across the same flow is visible, which is the signal a leak actually produces - a single absolute number says almost nothing. Reports measurable:false when the package has no running process rather than returning zeros.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_json: { type: "string", description: "mode=diff: the JSON returned by an earlier snapshot call" } }, required: ["package_name"] } },
|
|
930
1120
|
{ name: "android_list_crashes", description: "Dump the Android crash log buffer (`adb logcat -b crash -d`), tail-bounded. Empty output means no crashes since the buffer was last cleared.", inputSchema: { type: "object", properties: { lines: { type: "number", description: "Max lines returned, from the end (default 200)" }, device_id: { type: "string" } } } },
|
|
931
1121
|
{ name: "android_set_orientation", description: "Rotate the Android screen to portrait or landscape. Disables accelerometer rotation and pins user_rotation, so the device stays put until rotation is re-enabled. Accounts for the device's natural orientation (detected via wm size), so landscape-natural tablets rotate correctly too. No iOS counterpart: simctl exposes no rotation lever.", inputSchema: { type: "object", properties: { orientation: { type: "string", enum: ["portrait", "landscape"] }, device_id: { type: "string" } }, required: ["orientation"] } },
|
|
932
1122
|
];
|
|
933
1123
|
|
|
1124
|
+
// The snapshot shape the tool emits, and the shape diffMeminfo accepts back
|
|
1125
|
+
// as a baseline. One function so the two cannot drift apart again.
|
|
1126
|
+
function meminfoPayload(snapshot) {
|
|
1127
|
+
return {
|
|
1128
|
+
measurable: snapshot.measurable,
|
|
1129
|
+
reason: snapshot.reason,
|
|
1130
|
+
pss_kb: snapshot.pss,
|
|
1131
|
+
total_pss_kb: snapshot.totalPssKb,
|
|
1132
|
+
total_rss_kb: snapshot.totalRssKb,
|
|
1133
|
+
total_swap_kb: snapshot.totalSwapKb,
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
934
1137
|
async function handleAndroid(name, args, ctx = {}) {
|
|
935
1138
|
if (!HAS_ADB) return `${ERROR_PREFIX}Android SDK not installed - adb not on PATH, so Android tools cannot run. Install platform-tools and ensure adb is on PATH.`;
|
|
936
1139
|
const df = adbFlag(args.device_id);
|
|
@@ -943,21 +1146,24 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
943
1146
|
const parent = dirname(f);
|
|
944
1147
|
if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
|
|
945
1148
|
}
|
|
946
|
-
|
|
947
|
-
|
|
1149
|
+
const inline = args.path ? null : captureOptions(args);
|
|
1150
|
+
discardStale(f);
|
|
1151
|
+
const cap = run(`adb ${df} shell screencap -p /sdcard/_mcp_screen.png`);
|
|
1152
|
+
if (isFailure(cap)) return cap;
|
|
1153
|
+
const pulled = run(`adb ${df} pull /sdcard/_mcp_screen.png ${shq(f)}`);
|
|
948
1154
|
run(`adb ${df} shell rm /sdcard/_mcp_screen.png`);
|
|
1155
|
+
if (isFailure(pulled)) return pulled;
|
|
949
1156
|
if (!existsSync(f)) return "ERROR: Screenshot failed";
|
|
950
1157
|
if (args.path) return fileResult(`Screenshot written: ${f}`, f);
|
|
951
|
-
|
|
952
|
-
return { type: "image", data: buf.toString("base64"), mimeType: "image/png", path: f };
|
|
1158
|
+
return inlineCapture(f, inline);
|
|
953
1159
|
}
|
|
954
1160
|
case "android_tap": return run(`adb ${df} shell input tap ${num(args.x, "x")} ${num(args.y, "y")}`) || `Tapped (${args.x}, ${args.y})`;
|
|
955
1161
|
case "android_swipe": return run(`adb ${df} shell input swipe ${num(args.x1, "x1")} ${num(args.y1, "y1")} ${num(args.x2, "x2")} ${num(args.y2, "y2")} ${num(args.duration_ms || 300, "duration_ms")}`) || "Swiped";
|
|
956
1162
|
// `adb shell input text` wants spaces as %s; single-quote the result so the
|
|
957
1163
|
// remaining characters cannot reach the shell as syntax.
|
|
958
|
-
case "android_type_text": return run(`adb ${df} shell input text ${
|
|
1164
|
+
case "android_type_text": return run(`adb ${df} shell input text ${remoteShq(String(args.text ?? "").replace(/ /g, "%s"))}`) || `Typed: ${args.text}`;
|
|
959
1165
|
case "android_key_event": return run(`adb ${df} shell input keyevent ${token(args.keycode, "keycode")}`) || `Key ${args.keycode}`;
|
|
960
|
-
case "android_launch_app": return args.activity ? run(`adb ${df} shell am start -n ${
|
|
1166
|
+
case "android_launch_app": return args.activity ? run(`adb ${df} shell am start -n ${androidComponent(args.package_name, args.activity)}`) : run(`adb ${df} shell monkey -p ${sanitizeId(args.package_name)} -c android.intent.category.LAUNCHER 1`) || `Launched`;
|
|
961
1167
|
case "android_stop_app": return run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`) || "Stopped";
|
|
962
1168
|
case "android_list_packages": { const out = run(`adb ${df} shell pm list packages`); return args.filter ? out.split("\n").filter(l => l.toLowerCase().includes(args.filter.toLowerCase())).join("\n") : out; }
|
|
963
1169
|
case "android_go_home": return run(`adb ${df} shell input keyevent 3`) || "Home";
|
|
@@ -968,9 +1174,12 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
968
1174
|
const parent = dirname(f);
|
|
969
1175
|
if (!existsSync(parent)) return `${ERROR_PREFIX}directory does not exist: ${parent}`;
|
|
970
1176
|
}
|
|
971
|
-
|
|
972
|
-
run(`adb ${df}
|
|
1177
|
+
discardStale(f);
|
|
1178
|
+
const dumped = run(`adb ${df} shell uiautomator dump /sdcard/_mcp_ui.xml`);
|
|
1179
|
+
if (isFailure(dumped)) return dumped;
|
|
1180
|
+
const pulled = run(`adb ${df} pull /sdcard/_mcp_ui.xml ${shq(f)}`);
|
|
973
1181
|
run(`adb ${df} shell rm /sdcard/_mcp_ui.xml`);
|
|
1182
|
+
if (isFailure(pulled)) return pulled;
|
|
974
1183
|
if (!existsSync(f)) return "ERROR: UI dump failed";
|
|
975
1184
|
if (args.filter === "interactive") {
|
|
976
1185
|
const elements = interactiveElements(readFileSync(f, "utf-8"));
|
|
@@ -1076,7 +1285,7 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1076
1285
|
case "android_uninstall_app": return run(`adb ${df} shell pm uninstall ${sanitizeId(args.package_name)}`) || "Uninstalled";
|
|
1077
1286
|
case "android_logcat": { const lines = args.lines !== undefined ? num(args.lines, "lines") : 50; const tf = args.tag ? `| grep -i ${shq(args.tag)}` : ""; return run(`adb ${df} logcat -d ${tf} | tail -${lines}`); }
|
|
1078
1287
|
case "android_get_screen_size": return run(`adb ${df} shell wm size`);
|
|
1079
|
-
case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${
|
|
1288
|
+
case "android_open_url": return run(`adb ${df} shell am start -a android.intent.action.VIEW -d ${remoteShq(args.url)}`) || `Opened: ${args.url}`;
|
|
1080
1289
|
case "android_clear_app_data": return run(`adb ${df} shell pm clear ${sanitizeId(args.package_name)}`) || "Cleared";
|
|
1081
1290
|
case "android_accessibility_audit": {
|
|
1082
1291
|
run(`adb ${df} shell uiautomator dump /sdcard/_mcp_a11y.xml`);
|
|
@@ -1101,8 +1310,8 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1101
1310
|
}
|
|
1102
1311
|
case "android_launch_time": {
|
|
1103
1312
|
run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
|
|
1104
|
-
const
|
|
1105
|
-
const result = run(`adb ${df} shell am start -W -n ${
|
|
1313
|
+
const component = androidComponent(args.package_name, args.activity || ".MainActivity");
|
|
1314
|
+
const result = run(`adb ${df} shell am start -W -n ${component} 2>&1`);
|
|
1106
1315
|
const parsed = parseLaunchOutput(result);
|
|
1107
1316
|
return JSON.stringify({
|
|
1108
1317
|
package: args.package_name,
|
|
@@ -1122,7 +1331,7 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1122
1331
|
if (!existsSync(p)) return `ERROR: APK not found at ${p}`;
|
|
1123
1332
|
const findings = [];
|
|
1124
1333
|
// 1. Basic info via aapt2
|
|
1125
|
-
const aapt =
|
|
1334
|
+
const aapt = runOrNull(`aapt2 dump badging ${shq(p)} 2>/dev/null`) || runOrNull(`aapt dump badging ${shq(p)} 2>/dev/null`);
|
|
1126
1335
|
if (aapt) {
|
|
1127
1336
|
const pkg = aapt.match(/package: name='([^']*)'/)?.[1];
|
|
1128
1337
|
const versionName = aapt.match(/versionName='([^']*)'/)?.[1];
|
|
@@ -1143,19 +1352,29 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1143
1352
|
} else {
|
|
1144
1353
|
findings.push({ check: "aapt", status: "warning", detail: "aapt2/aapt not found - install Android SDK Build-Tools for full audit" });
|
|
1145
1354
|
}
|
|
1146
|
-
// 2. Signing check
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1355
|
+
// 2. Signing check. apksigner exits 1 on a failed verification with the
|
|
1356
|
+
// verdict on stdout, so the output is captured whatever the status.
|
|
1357
|
+
const signingInfo = runCapture(`apksigner verify --print-certs ${shq(p)} 2>&1`);
|
|
1358
|
+
if (/DOES NOT VERIFY/.test(signingInfo)) {
|
|
1359
|
+
findings.push({ check: "signing", status: "critical", detail: signingInfo.slice(0, 500) });
|
|
1360
|
+
} else if (isFailure(signingInfo) || /command not found|No such file or directory/i.test(signingInfo)) {
|
|
1361
|
+
findings.push({ check: "signing", status: "warning", detail: "apksigner not found - install Android SDK Build-Tools to verify the signature" });
|
|
1362
|
+
} else if (/^(ERROR|Exception)/m.test(signingInfo)) {
|
|
1363
|
+
findings.push({ check: "signing", status: "warning", detail: signingInfo.slice(0, 500) });
|
|
1364
|
+
} else {
|
|
1365
|
+
const hasV2 = signingInfo.includes("v2 scheme") || (runOrNull(`apksigner verify -v ${shq(p)} 2>&1`) || "").includes("Verified using v2");
|
|
1150
1366
|
findings.push({ check: "signing", status: "pass", detail: "APK is signed" });
|
|
1151
1367
|
findings.push({ check: "signing_v2", status: hasV2 ? "pass" : "warning", detail: hasV2 ? "v2+ signature present" : "Only v1 signature - consider v2+ for tamper protection" });
|
|
1152
|
-
} else {
|
|
1153
|
-
findings.push({ check: "signing", status: signingInfo?.includes("DOES NOT VERIFY") ? "critical" : "warning", detail: signingInfo || "apksigner not found" });
|
|
1154
1368
|
}
|
|
1155
1369
|
// 3. File size
|
|
1156
|
-
try {
|
|
1370
|
+
try {
|
|
1371
|
+
const mb = statSync(p).size / 1048576;
|
|
1372
|
+
findings.push({ check: "apk_size", value: `${mb.toFixed(1)} MB`, status: mb > 150 ? "warning" : "pass", detail: mb > 150 ? "APK > 150MB - consider App Bundle (.aab)" : "OK" });
|
|
1373
|
+
} catch (e) {
|
|
1374
|
+
findings.push({ check: "apk_size", status: "warning", detail: `could not read the file size: ${e.message}` });
|
|
1375
|
+
}
|
|
1157
1376
|
// 4. ProGuard/R8 check
|
|
1158
|
-
const hasMapping =
|
|
1377
|
+
const hasMapping = runOrNull(`unzip -l ${shq(p)} 2>/dev/null | grep -c "classes.dex"`);
|
|
1159
1378
|
const dexCount = parseInt(hasMapping) || 0;
|
|
1160
1379
|
findings.push({ check: "dex_files", value: `${dexCount} dex file(s)`, status: dexCount > 3 ? "warning" : "info", detail: dexCount > 3 ? "Many DEX files - ensure R8/ProGuard minification is enabled" : "OK" });
|
|
1161
1380
|
// Summary
|
|
@@ -1177,18 +1396,9 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1177
1396
|
return "ERROR: baseline_json is not valid JSON";
|
|
1178
1397
|
}
|
|
1179
1398
|
const d = diffMeminfo(before, snapshot);
|
|
1180
|
-
return JSON.stringify({ package: args.package_name, mode, comparable: d.comparable, reason: d.reason, delta_kb: d.deltaKb, total_pss_delta_kb: d.totalPssDeltaKb, after: snapshot }, null, 2);
|
|
1399
|
+
return JSON.stringify({ package: args.package_name, mode, comparable: d.comparable, reason: d.reason, delta_kb: d.deltaKb, total_pss_delta_kb: d.totalPssDeltaKb, after: meminfoPayload(snapshot) }, null, 2);
|
|
1181
1400
|
}
|
|
1182
|
-
return JSON.stringify({
|
|
1183
|
-
package: args.package_name,
|
|
1184
|
-
mode,
|
|
1185
|
-
measurable: snapshot.measurable,
|
|
1186
|
-
reason: snapshot.reason,
|
|
1187
|
-
pss_kb: snapshot.pss,
|
|
1188
|
-
total_pss_kb: snapshot.totalPssKb,
|
|
1189
|
-
total_rss_kb: snapshot.totalRssKb,
|
|
1190
|
-
total_swap_kb: snapshot.totalSwapKb,
|
|
1191
|
-
}, null, 2);
|
|
1401
|
+
return JSON.stringify({ package: args.package_name, mode, ...meminfoPayload(snapshot) }, null, 2);
|
|
1192
1402
|
}
|
|
1193
1403
|
case "android_list_crashes": {
|
|
1194
1404
|
const lines = args.lines !== undefined ? num(args.lines, "lines") : 200;
|
|
@@ -1224,9 +1434,15 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1224
1434
|
|
|
1225
1435
|
let _browser = null;
|
|
1226
1436
|
let _page = null;
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1437
|
+
let _engine = null;
|
|
1438
|
+
|
|
1439
|
+
// A request for a different engine closes the current browser and relaunches;
|
|
1440
|
+
// a partial launch (newContext/newPage threw) is closed rather than left as a
|
|
1441
|
+
// browser with no page that the next call would launch beside.
|
|
1442
|
+
async function ensureBrowser(browserType) {
|
|
1443
|
+
const wanted = browserType || _engine || "chromium";
|
|
1444
|
+
if (_browser && _page && _engine === wanted) return _page;
|
|
1445
|
+
if (_browser || _page) await closeBrowser();
|
|
1230
1446
|
let pw;
|
|
1231
1447
|
try {
|
|
1232
1448
|
pw = await import("playwright");
|
|
@@ -1234,10 +1450,17 @@ async function ensureBrowser(browserType = "chromium") {
|
|
|
1234
1450
|
throw new Error("Web tools require Playwright. Install once with: npm i -g playwright && npx playwright install chromium");
|
|
1235
1451
|
}
|
|
1236
1452
|
const engines = { chromium: pw.chromium, webkit: pw.webkit, firefox: pw.firefox };
|
|
1237
|
-
const engine = engines[
|
|
1453
|
+
const engine = engines[wanted];
|
|
1454
|
+
if (!engine) throw new Error(`unknown browser engine: ${wanted}`);
|
|
1238
1455
|
_browser = await engine.launch({ headless: true });
|
|
1239
|
-
|
|
1240
|
-
|
|
1456
|
+
try {
|
|
1457
|
+
const ctx = await _browser.newContext();
|
|
1458
|
+
_page = await ctx.newPage();
|
|
1459
|
+
} catch (e) {
|
|
1460
|
+
await closeBrowser();
|
|
1461
|
+
throw e;
|
|
1462
|
+
}
|
|
1463
|
+
_engine = wanted;
|
|
1241
1464
|
return _page;
|
|
1242
1465
|
}
|
|
1243
1466
|
|
|
@@ -1246,6 +1469,7 @@ async function closeBrowser() {
|
|
|
1246
1469
|
try { await _browser?.close(); } catch {}
|
|
1247
1470
|
_page = null;
|
|
1248
1471
|
_browser = null;
|
|
1472
|
+
_engine = null;
|
|
1249
1473
|
}
|
|
1250
1474
|
|
|
1251
1475
|
const WEB_TOOLS = [
|
|
@@ -1350,11 +1574,17 @@ const AGENT_TOOLS = [
|
|
|
1350
1574
|
// a batch able to nest itself has no recursion bound.
|
|
1351
1575
|
async function dispatchStep(tool, stepArgs) {
|
|
1352
1576
|
if (tool.startsWith("agent_")) throw new Error(`Nested batch steps are not supported: ${tool}`);
|
|
1577
|
+
// The same boundary check a direct call gets: a batched step used to reach
|
|
1578
|
+
// the handler with its enum/type/required contract unenforced.
|
|
1579
|
+
const schemaError = validateArgs(tool, stepArgs);
|
|
1580
|
+
if (schemaError) throw new Error(`invalid arguments: ${schemaError}`);
|
|
1353
1581
|
let result;
|
|
1354
1582
|
if (tool.startsWith("ios_")) result = await handleIOS(tool, stepArgs);
|
|
1355
1583
|
else if (tool.startsWith("android_")) result = await handleAndroid(tool, stepArgs);
|
|
1356
1584
|
else if (tool.startsWith("web_")) result = await handleWeb(tool, stepArgs);
|
|
1357
1585
|
else if (tool.startsWith("design_")) result = await handleDesign(tool, stepArgs, designCtx);
|
|
1586
|
+
else if (tool.startsWith("code_")) result = await handleCode(tool, stepArgs, {});
|
|
1587
|
+
else if (tool.startsWith("pass_")) result = await handlePass(tool, stepArgs);
|
|
1358
1588
|
else throw new Error(`Unknown tool: ${tool}`);
|
|
1359
1589
|
// Every handler's `default:` arm returns null, and that is its only source of
|
|
1360
1590
|
// null, so null means "I do not recognise this tool". The throw below used to
|
|
@@ -1433,7 +1663,15 @@ async function handleAgent(name, args) {
|
|
|
1433
1663
|
|
|
1434
1664
|
// ── Server ──
|
|
1435
1665
|
|
|
1436
|
-
const ALL_TOOLS = [
|
|
1666
|
+
const ALL_TOOLS = [
|
|
1667
|
+
...IOS_TOOLS,
|
|
1668
|
+
...ANDROID_TOOLS,
|
|
1669
|
+
...WEB_TOOLS,
|
|
1670
|
+
...AGENT_TOOLS,
|
|
1671
|
+
...DESIGN_TOOLS,
|
|
1672
|
+
...CODE_TOOLS,
|
|
1673
|
+
...PASS_TOOLS,
|
|
1674
|
+
];
|
|
1437
1675
|
|
|
1438
1676
|
// Name -> inputSchema, so the CallTool boundary can enforce the declared shape.
|
|
1439
1677
|
const TOOL_SCHEMAS = new Map(ALL_TOOLS.map((t) => [t.name, t.inputSchema || {}]));
|
|
@@ -1497,6 +1735,11 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
1497
1735
|
"android_list_crashes",
|
|
1498
1736
|
"web_screenshot", "web_get_text",
|
|
1499
1737
|
"design_mock_detect", "design_scenario_inventory", "design_ui_geometry", "design_visual_compare",
|
|
1738
|
+
// Seven of the eight. Every code_* tool reads source and answers; the one
|
|
1739
|
+
// that is not here stops a cached server.
|
|
1740
|
+
...CODE_READ_ONLY,
|
|
1741
|
+
// pass_build writes a file the caller named; the other three only read.
|
|
1742
|
+
...PASS_READ_ONLY,
|
|
1500
1743
|
]);
|
|
1501
1744
|
|
|
1502
1745
|
// Irreversible on the target device: data loss the caller cannot undo.
|
|
@@ -1518,6 +1761,7 @@ const IDEMPOTENT_TOOLS = new Set([
|
|
|
1518
1761
|
"android_set_locale", "android_set_location", "android_grant_permission", "android_revoke_permission",
|
|
1519
1762
|
"android_set_orientation",
|
|
1520
1763
|
"web_close",
|
|
1764
|
+
...CODE_IDEMPOTENT,
|
|
1521
1765
|
]);
|
|
1522
1766
|
|
|
1523
1767
|
// Web tools reach arbitrary sites; everything else talks to a local simulator,
|
|
@@ -1611,7 +1855,7 @@ const bundleAuditSchema = (pathKey) => ({
|
|
|
1611
1855
|
items: {
|
|
1612
1856
|
type: "object",
|
|
1613
1857
|
properties: {
|
|
1614
|
-
status: { type: "string", enum: ["pass", "warning", "critical"] },
|
|
1858
|
+
status: { type: "string", enum: ["pass", "warning", "critical", "info"] },
|
|
1615
1859
|
rule: { type: "string" },
|
|
1616
1860
|
message: { type: "string" },
|
|
1617
1861
|
},
|
|
@@ -1621,6 +1865,10 @@ const bundleAuditSchema = (pathKey) => ({
|
|
|
1621
1865
|
});
|
|
1622
1866
|
|
|
1623
1867
|
const OUTPUT_SCHEMAS = {
|
|
1868
|
+
// Every code_* root is an object. A `{type:"array"}` root on ONE tool once
|
|
1869
|
+
// made all 78 unavailable in Claude Code - see the note further down.
|
|
1870
|
+
...CODE_OUTPUT_SCHEMAS,
|
|
1871
|
+
...PASS_OUTPUT_SCHEMAS,
|
|
1624
1872
|
ios_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
|
|
1625
1873
|
android_accessibility_audit: ACCESSIBILITY_AUDIT_SCHEMA,
|
|
1626
1874
|
|
|
@@ -1664,7 +1912,7 @@ const OUTPUT_SCHEMAS = {
|
|
|
1664
1912
|
max_diff_pct: { type: "number" },
|
|
1665
1913
|
baseline: { type: "string" },
|
|
1666
1914
|
current: { type: "string" },
|
|
1667
|
-
diff_image: { type: "string" },
|
|
1915
|
+
diff_image: { type: ["string", "null"] },
|
|
1668
1916
|
},
|
|
1669
1917
|
},
|
|
1670
1918
|
|
|
@@ -1694,9 +1942,11 @@ const OUTPUT_SCHEMAS = {
|
|
|
1694
1942
|
required: ["package", "cold_start"],
|
|
1695
1943
|
properties: {
|
|
1696
1944
|
package: { type: "string" },
|
|
1697
|
-
|
|
1945
|
+
launch_state: { type: ["string", "null"] },
|
|
1946
|
+
cold_start: { type: ["boolean", "null"] },
|
|
1698
1947
|
total_time_ms: { type: ["integer", "null"] },
|
|
1699
1948
|
wait_time_ms: { type: ["integer", "null"] },
|
|
1949
|
+
error: { type: ["string", "null"] },
|
|
1700
1950
|
raw: { type: "string" },
|
|
1701
1951
|
},
|
|
1702
1952
|
},
|
|
@@ -1733,9 +1983,11 @@ const OUTPUT_SCHEMAS = {
|
|
|
1733
1983
|
items: {
|
|
1734
1984
|
type: "object",
|
|
1735
1985
|
properties: {
|
|
1986
|
+
step: { type: "integer" },
|
|
1736
1987
|
tool: { type: "string" },
|
|
1737
1988
|
status: { type: "string", enum: ["ok", "error"] },
|
|
1738
|
-
|
|
1989
|
+
result: { type: "string" },
|
|
1990
|
+
error: { type: "string" },
|
|
1739
1991
|
},
|
|
1740
1992
|
},
|
|
1741
1993
|
},
|
|
@@ -1773,7 +2025,8 @@ const designCtx = {
|
|
|
1773
2025
|
adbFlag,
|
|
1774
2026
|
idb,
|
|
1775
2027
|
hasIdb: HAS_IDB,
|
|
1776
|
-
dumperScript:
|
|
2028
|
+
dumperScript: DUMPER_SOURCE,
|
|
2029
|
+
dumperCommand,
|
|
1777
2030
|
};
|
|
1778
2031
|
|
|
1779
2032
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: ANNOTATED_TOOLS }));
|
|
@@ -1813,6 +2066,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
1813
2066
|
else if (name.startsWith("web_")) result = await handleWeb(name, args || {});
|
|
1814
2067
|
else if (name.startsWith("agent_")) result = await handleAgent(name, args || {});
|
|
1815
2068
|
else if (name.startsWith("design_")) result = await handleDesign(name, args || {}, designCtx);
|
|
2069
|
+
// ...ctx, unlike the design_ line above: code-intel needs `signal` so an
|
|
2070
|
+
// aborted call cancels the in-flight LSP request instead of orphaning it.
|
|
2071
|
+
else if (name.startsWith("code_")) result = await handleCode(name, args || {}, ctx);
|
|
2072
|
+
else if (name.startsWith("pass_")) result = await handlePass(name, args || {});
|
|
1816
2073
|
else return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
1817
2074
|
// Same reason as dispatchStep: a handler returns null only from its
|
|
1818
2075
|
// `default:` arm, so an unrecognised name that happens to carry a known
|
|
@@ -1821,11 +2078,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
|
1821
2078
|
if (result === null || result === undefined) {
|
|
1822
2079
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
1823
2080
|
}
|
|
2081
|
+
pruneWorkDir(SCREENSHOT_DIR, { keep: promisedPaths(result) });
|
|
1824
2082
|
if (typeof result === "object" && result?.type === "image") {
|
|
1825
2083
|
return {
|
|
1826
2084
|
content: [
|
|
1827
2085
|
{ type: "image", data: result.data, mimeType: result.mimeType },
|
|
1828
|
-
{ type: "text", text: `Screenshot: ${result.path}` },
|
|
2086
|
+
{ type: "text", text: `Screenshot: ${result.path}${result.note ?? ""}` },
|
|
1829
2087
|
...(result.path ? [{ type: "resource_link", uri: `file://${result.path}`, name: basename(result.path), mimeType: result.mimeType }] : []),
|
|
1830
2088
|
],
|
|
1831
2089
|
};
|
|
@@ -1882,8 +2140,8 @@ function stopRecorders() {
|
|
|
1882
2140
|
}
|
|
1883
2141
|
}
|
|
1884
2142
|
|
|
1885
|
-
process.on("SIGTERM", async () => { stopRecorders(); await closeBrowser(); process.exit(0); });
|
|
1886
|
-
process.on("SIGINT", async () => { stopRecorders(); await closeBrowser(); process.exit(0); });
|
|
2143
|
+
process.on("SIGTERM", async () => { stopRecorders(); shutdownAllLsp(); await closeBrowser(); process.exit(0); });
|
|
2144
|
+
process.on("SIGINT", async () => { stopRecorders(); shutdownAllLsp(); await closeBrowser(); process.exit(0); });
|
|
1887
2145
|
|
|
1888
2146
|
const transport = new StdioServerTransport();
|
|
1889
2147
|
await server.connect(transport);
|