@mmerterden/multi-agent-toolkit-mcp 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -15,6 +15,76 @@ Releases before this file exists are recorded in the git tags and commit history
15
15
 
16
16
  ---
17
17
 
18
+ ## Unreleased
19
+
20
+ ---
21
+
22
+ ## 3.12.0
23
+
24
+ The CI matrix had been red for five runs. Every fix below is something the red
25
+ leg was pointing at; three of them are defects a green local run could not see,
26
+ because the thing that broke was the assumption that the host is a Mac.
27
+
28
+ ### Fixed
29
+
30
+ - **`ios_app_store_audit` answered PASS on an archive it never read.** Plist
31
+ parsing went entirely through `plutil`, a macOS binary. Off macOS every read
32
+ returned `null`, all 18 rules saw an empty archive, found nothing, and the
33
+ verdict came back clean - on a fixture built to be non-compliant. There are
34
+ now three answers instead of two: `plutil` when it is there, a small XML plist
35
+ reader when it is not, and an explicit record of what could be read by
36
+ neither. An audit with a non-empty `unreadablePlists` reports
37
+ `measurable: false` and an `archive-readable` error, so it can no longer
38
+ return PASS. An unreadable archive and a compliant one now look different.
39
+ - **Argument rules were unreachable on a host without Xcode.** `handleIOS`
40
+ refused everything with "Xcode not installed" before any handler ran, so the
41
+ checks that answer from the arguments alone - `extra_args` shell
42
+ metacharacters, `project` XOR `workspace`, `mode=diff` without
43
+ `baseline_graph`, `ios_leaks` with neither `pid` nor `bundle_id`,
44
+ `ios_testflight_validate` with neither `ipa_path` nor `list_providers` - never
45
+ ran there, and the tests that prove them could not run either. They are
46
+ evaluated before the capability gate now, so a malformed call is named as
47
+ malformed on any host. The `extra_args` character class is additionally
48
+ declared as `pattern` on the tool schema, where a host can enforce it too.
49
+ - **`build-stack-plugins.mjs`-style "works on the author's machine" in the
50
+ test harness.** Gate 10 printed a bare exit code when a suite failed, so a red
51
+ CI leg said which file failed and nothing about why. It now prints the failing
52
+ assertions, matching both reporter shapes `node:test` uses (TAP when piped,
53
+ spec on a terminal).
54
+
55
+ ### Changed
56
+
57
+ - **`validateArgs` enforces the whole schema, not three keywords of it.** It
58
+ checked `type`, `required` and `enum`; `pattern`, `minimum`, `maximum`,
59
+ `minLength`, `maxLength`, `minItems`, `maxItems` and `items` were declarable
60
+ and unenforced. A schema the server does not keep is worse than no schema,
61
+ because the host shows the caller a contract that is not real. Array `items`
62
+ are validated element by element, including nested `required` and property
63
+ types. No existing tool declared any of the newly-enforced keywords, so
64
+ nothing that used to be accepted is now refused.
65
+ - **`design-check` tests are `node:test` cases.** 1002 lines and 143 assertions
66
+ ran inside a hand-rolled tally that ended in `process.exit(1)`: the runner saw
67
+ one opaque suite, coverage could not attribute a line, and a failure named the
68
+ file rather than the check. Same assertions, now 143 reported cases. The two
69
+ that need `ripgrep` register as skips with a reason instead of printing a
70
+ warning nobody reads.
71
+ - **Dependency advisories closed by upgrade, not by allowlist.** `fast-uri`,
72
+ `hono` and `qs` reach this tree only through `@modelcontextprotocol/sdk`;
73
+ bounded `overrides` resolve all three. `scripts/audit-allowlist.mjs` now has
74
+ an empty accepted list, and its stdio-only assertion runs on every invocation
75
+ rather than only when an advisory happens to be open.
76
+ - **Repo tooling only, nothing in the published package.** A `PostToolUse` hook
77
+ (`.claude/settings.json` -> `scripts/hooks/stdout-hygiene.sh`) greps the file
78
+ that was just edited for `console.log` under `index.js` and `tools/`, and
79
+ fails the edit back to the author when it finds one. stdout is this server's
80
+ JSON-RPC channel, so a stray log corrupts the protocol stream and the client
81
+ reports only a closed connection. Gate 5 in `scripts/gates.sh` remains the
82
+ authority - it decides whether the package ships; the hook only moves the
83
+ catch from ship-day to edit-second, and gate 5 now also asserts that the hook
84
+ exists, parses and is wired.
85
+
86
+ ---
87
+
18
88
  ## 3.11.0
19
89
 
20
90
  ### Added
package/index.js CHANGED
@@ -484,7 +484,7 @@ const IOS_TOOLS = [
484
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"] } },
485
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: [] } },
486
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"] } },
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"] } },
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. Plain flags and values only - shell metacharacters are refused.", pattern: "^[^;&|`$(){}<>\\\\\\n]*$", patternHint: "may contain only plain flags and values (no shell metacharacters ; & | ` $ ( ) { } < > \\\\)" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
488
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"] } },
489
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"] } },
490
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: [] } },
@@ -496,7 +496,38 @@ const IOS_TOOLS = [
496
496
  // reports needs no simulator, so a missing Xcode must not refuse them.
497
497
  const IOS_TOOLS_WITHOUT_XCRUN = new Set(["ios_visual_diff", "ios_list_crashes", "ios_xcresult"]);
498
498
 
499
+ // Argument rules a JSON Schema cannot state, because they relate two fields
500
+ // rather than constrain one. They ran inside each handler, which put them
501
+ // BEHIND the "Xcode not installed" gate: on a host without Xcode every
502
+ // malformed call got the same answer as a well-formed one, and the tests that
503
+ // prove these rules could not run there at all. That is how the Linux CI leg
504
+ // found them. They answer from the arguments alone, so they belong in front of
505
+ // the capability gate - a caller learns their call is wrong whether or not this
506
+ // machine could have run it.
507
+ const IOS_ARG_RULES = {
508
+ ios_leaks: (a) => {
509
+ if (!a.pid && !a.bundle_id) return "pass pid or bundle_id";
510
+ if ((a.mode || "snapshot") === "diff" && !a.baseline_graph) {
511
+ return "mode=diff needs baseline_graph, the memory graph saved by an earlier call";
512
+ }
513
+ return null;
514
+ },
515
+ ios_xcodebuild: (a) => {
516
+ if (!a.project && !a.workspace) return "project or workspace required";
517
+ if (a.project && a.workspace) return "pass project OR workspace, not both";
518
+ return null;
519
+ },
520
+ ios_accessibility_audit_deep: (a) => (!a.project && !a.workspace ? "pass project or workspace" : null),
521
+ ios_testflight_validate: (a) =>
522
+ !a.list_providers && !a.ipa_path ? "ipa_path is required (or pass list_providers=true)" : null,
523
+ };
524
+
499
525
  async function handleIOS(name, args, ctx = {}) {
526
+ const argRule = IOS_ARG_RULES[name];
527
+ if (argRule) {
528
+ const bad = argRule(args);
529
+ if (bad) return `${ERROR_PREFIX}${bad}`;
530
+ }
500
531
  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`;
501
532
  const did = (n) => { try { return iosDevice(n); } catch (e) { return null; } };
502
533
 
@@ -798,7 +829,6 @@ async function handleIOS(name, args, ctx = {}) {
798
829
  const res = await listProviders(auth, ctx.signal);
799
830
  return JSON.stringify({ authTier: auth.tier, authMethod: auth.method, ...res }, null, 2);
800
831
  }
801
- if (!args.ipa_path) return "ERROR: ipa_path is required (or pass list_providers=true)";
802
832
  // Same finally rationale as ios_export_ipa: never leak the heartbeat.
803
833
  const stopHeartbeat = startHeartbeat(ctx, "altool --validate-app");
804
834
  let res;
@@ -831,8 +861,6 @@ async function handleIOS(name, args, ctx = {}) {
831
861
  }
832
862
  case "ios_xcodebuild": {
833
863
  if (!HAS_XCRUN) return "ERROR: xcrun not available - Xcode Command Line Tools required";
834
- if (!args.project && !args.workspace) return "ERROR: project or workspace required";
835
- if (args.project && args.workspace) return "ERROR: pass project OR workspace, not both";
836
864
  const action = args.action || "build";
837
865
  const config = args.configuration || "Release";
838
866
  let dest = args.destination;
@@ -852,9 +880,13 @@ async function handleIOS(name, args, ctx = {}) {
852
880
  const derivedFlag = args.derived_data_path ? `-derivedDataPath ${shq(args.derived_data_path)}` : "";
853
881
  const actionMap = { "clean-build": "clean build", build: "build", test: "test", clean: "clean", archive: "archive" };
854
882
  // extra_args is appended verbatim (multiple flags, so it can't be a single
855
- // shq'd token), which makes it a shell passthrough. Reject the metacharacters
856
- // that enable command chaining or substitution so it stays "extra xcodebuild
857
- // flags" and cannot become an arbitrary-command tool.
883
+ // shq'd token), which makes it a shell passthrough. The same character class
884
+ // is declared as `pattern` on the tool schema, so the CallTool boundary
885
+ // refuses the payload before dispatch; this is the second line, kept because
886
+ // the string is one concatenation away from a shell. It lived only here
887
+ // until the Linux CI run showed why that was not enough: handleIOS returns
888
+ // "Xcode not installed" first, so on any host without Xcode the guard was
889
+ // never reached and the test that proves it works could not run.
858
890
  const extra = args.extra_args || "";
859
891
  if (/[;&|`$(){}<>\n\\]/.test(extra)) {
860
892
  return "ERROR: extra_args may contain only plain flags and values (no shell metacharacters ; & | ` $ ( ) { } < > \\).";
@@ -980,7 +1012,6 @@ async function handleIOS(name, args, ctx = {}) {
980
1012
  const mode = args.mode || "snapshot";
981
1013
  const graphOut = args.output_graph ? ` --outputGraph=${shq(args.output_graph)}` : "";
982
1014
  if (mode === "diff") {
983
- if (!args.baseline_graph) return "ERROR: mode=diff needs baseline_graph, the memory graph saved by an earlier call";
984
1015
  if (!existsSync(args.baseline_graph)) return `ERROR: baseline graph not found at ${args.baseline_graph}`;
985
1016
  }
986
1017
  const diffArg = mode === "diff" ? ` --diffFrom=${shq(args.baseline_graph)}` : "";
@@ -1007,7 +1038,6 @@ async function handleIOS(name, args, ctx = {}) {
1007
1038
  }, null, 2);
1008
1039
  }
1009
1040
  case "ios_accessibility_audit_deep": {
1010
- if (!args.project && !args.workspace) return "ERROR: pass project or workspace";
1011
1041
  const container = args.workspace ? `-workspace ${shq(args.workspace)}` : `-project ${shq(args.project)}`;
1012
1042
  const d = iosDevice(args.device_id);
1013
1043
  const bundle = join(SCREENSHOT_DIR, `a11ydeep_${Date.now()}.xcresult`);
@@ -1354,17 +1384,29 @@ async function handleAndroid(name, args, ctx = {}) {
1354
1384
  }
1355
1385
  // 2. Signing check. apksigner exits 1 on a failed verification with the
1356
1386
  // verdict on stdout, so the output is captured whatever the status.
1387
+ //
1388
+ // "signed" needs positive evidence - apksigner's own `Verifies` line or a
1389
+ // printed certificate. The branch used to be the else of three negative
1390
+ // tests, so ANY output it did not recognise became "APK is signed": on a
1391
+ // host with apksigner installed, 1KB of zero bytes audited as a correctly
1392
+ // signed APK. It read as correct on a Mac only because apksigner is not
1393
+ // there, and "command not found" is one of the strings it did recognise.
1357
1394
  const signingInfo = runCapture(`apksigner verify --print-certs ${shq(p)} 2>&1`);
1395
+ const verifies = /^Verifies\b/m.test(signingInfo) || /Signer #\d+ certificate DN:/.test(signingInfo);
1358
1396
  if (/DOES NOT VERIFY/.test(signingInfo)) {
1359
1397
  findings.push({ check: "signing", status: "critical", detail: signingInfo.slice(0, 500) });
1360
1398
  } else if (isFailure(signingInfo) || /command not found|No such file or directory/i.test(signingInfo)) {
1361
1399
  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 {
1400
+ } else if (verifies) {
1365
1401
  const hasV2 = signingInfo.includes("v2 scheme") || (runOrNull(`apksigner verify -v ${shq(p)} 2>&1`) || "").includes("Verified using v2");
1366
1402
  findings.push({ check: "signing", status: "pass", detail: "APK is signed" });
1367
1403
  findings.push({ check: "signing_v2", status: hasV2 ? "pass" : "warning", detail: hasV2 ? "v2+ signature present" : "Only v1 signature - consider v2+ for tamper protection" });
1404
+ } else {
1405
+ findings.push({
1406
+ check: "signing",
1407
+ status: "warning",
1408
+ detail: `apksigner did not report a verified signature; treat this as unverified, not as signed: ${signingInfo.slice(0, 400) || "(no output)"}`,
1409
+ });
1368
1410
  }
1369
1411
  // 3. File size
1370
1412
  try {
@@ -1701,6 +1743,74 @@ function schemaTypeOk(value, type) {
1701
1743
  }
1702
1744
  }
1703
1745
 
1746
+ // Constraint keywords beyond type/required/enum. A schema that declares
1747
+ // `pattern` or `maximum` and has nobody enforcing it is worse than one that
1748
+ // declares nothing: the host shows the caller a contract the server does not
1749
+ // keep. Everything a tool literal is allowed to write is checked here.
1750
+ //
1751
+ // `patternHint` is ours, not JSON Schema. A regex in an error message tells a
1752
+ // human nothing; the hint says what the rule is in words. Hosts ignore unknown
1753
+ // keywords, so it rides along in inputSchema harmlessly.
1754
+ function checkConstraints(key, value, spec) {
1755
+ if (spec.type && !schemaTypeOk(value, spec.type)) {
1756
+ return `argument '${key}' must be ${spec.type}, got ${Array.isArray(value) ? "array" : typeof value}`;
1757
+ }
1758
+ if (Array.isArray(spec.enum) && !spec.enum.includes(value)) {
1759
+ return `argument '${key}' must be one of ${JSON.stringify(spec.enum)}`;
1760
+ }
1761
+ if (typeof spec.pattern === "string" && !new RegExp(spec.pattern).test(String(value))) {
1762
+ return spec.patternHint
1763
+ ? `argument '${key}' ${spec.patternHint}`
1764
+ : `argument '${key}' must match ${spec.pattern}`;
1765
+ }
1766
+ if (typeof value === "string" || typeof value === "number") {
1767
+ const str = String(value);
1768
+ if (Number.isFinite(spec.minLength) && str.length < spec.minLength) {
1769
+ return `argument '${key}' must be at least ${spec.minLength} characters`;
1770
+ }
1771
+ if (Number.isFinite(spec.maxLength) && str.length > spec.maxLength) {
1772
+ return `argument '${key}' must be at most ${spec.maxLength} characters`;
1773
+ }
1774
+ }
1775
+ if (spec.type === "number" || spec.type === "integer") {
1776
+ const num = Number(value);
1777
+ if (Number.isFinite(spec.minimum) && num < spec.minimum) {
1778
+ return `argument '${key}' must be >= ${spec.minimum}`;
1779
+ }
1780
+ if (Number.isFinite(spec.maximum) && num > spec.maximum) {
1781
+ return `argument '${key}' must be <= ${spec.maximum}`;
1782
+ }
1783
+ }
1784
+ if (Array.isArray(value)) {
1785
+ if (Number.isFinite(spec.minItems) && value.length < spec.minItems) {
1786
+ return `argument '${key}' must have at least ${spec.minItems} item(s)`;
1787
+ }
1788
+ if (Number.isFinite(spec.maxItems) && value.length > spec.maxItems) {
1789
+ return `argument '${key}' must have at most ${spec.maxItems} item(s)`;
1790
+ }
1791
+ if (spec.items) {
1792
+ for (let i = 0; i < value.length; i++) {
1793
+ const err = checkConstraints(`${key}[${i}]`, value[i], spec.items);
1794
+ if (err) return err;
1795
+ for (const req of spec.items.required || []) {
1796
+ const el = value[i];
1797
+ if (el === null || typeof el !== "object" || el[req] === undefined || el[req] === null) {
1798
+ return `argument '${key}[${i}]' is missing required field: ${req}`;
1799
+ }
1800
+ }
1801
+ for (const [k, sub] of Object.entries(spec.items.properties || {})) {
1802
+ const el = value[i];
1803
+ if (el === null || typeof el !== "object") continue;
1804
+ if (el[k] === undefined || el[k] === null) continue;
1805
+ const subErr = checkConstraints(`${key}[${i}].${k}`, el[k], sub);
1806
+ if (subErr) return subErr;
1807
+ }
1808
+ }
1809
+ }
1810
+ }
1811
+ return null;
1812
+ }
1813
+
1704
1814
  function validateArgs(name, args) {
1705
1815
  const schema = TOOL_SCHEMAS.get(name);
1706
1816
  if (!schema || schema.type !== "object") return null;
@@ -1712,12 +1822,8 @@ function validateArgs(name, args) {
1712
1822
  }
1713
1823
  for (const [key, spec] of Object.entries(props)) {
1714
1824
  if (args[key] === undefined || args[key] === null) continue;
1715
- if (spec.type && !schemaTypeOk(args[key], spec.type)) {
1716
- return `argument '${key}' must be ${spec.type}, got ${Array.isArray(args[key]) ? "array" : typeof args[key]}`;
1717
- }
1718
- if (Array.isArray(spec.enum) && !spec.enum.includes(args[key])) {
1719
- return `argument '${key}' must be one of ${JSON.stringify(spec.enum)}`;
1720
- }
1825
+ const err = checkConstraints(key, args[key], spec);
1826
+ if (err) return err;
1721
1827
  }
1722
1828
  return null;
1723
1829
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 99 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -73,6 +73,12 @@
73
73
  "CHANGELOG.md",
74
74
  "LICENSE"
75
75
  ],
76
+ "_overridesReadme": "Three advisories reach this tree only through @modelcontextprotocol/sdk, and all three are fixed inside the major their consumer already depends on. The upper bound on fast-uri is load-bearing: ajv@8 declares ^3, and an unbounded >=3.1.6 resolves to 4.x, which is a different major under a dependency that never asked for it.",
77
+ "overrides": {
78
+ "fast-uri": ">=3.1.6 <4",
79
+ "hono": ">=4.13.5 <5",
80
+ "qs": ">=6.16.0 <7"
81
+ },
76
82
  "dependencies": {
77
83
  "@modelcontextprotocol/sdk": "^1.30.0",
78
84
  "pixelmatch": "^7.2.0",
@@ -3,14 +3,22 @@
3
3
  * 18 rules consume. Direct port of XCArchiveParser.swift +
4
4
  * InfoPlistParser.swift + PrivacyManifestParser.swift + EntitlementParser.swift.
5
5
  *
6
- * Plist parsing is delegated to `plutil -convert json` (system binary on
7
- * macOS, ships with the OS) - more reliable than any Node `plist` package
8
- * across the long tail of Apple plist quirks (binary plists, XML-with-DTD,
9
- * mixed array/dict roots).
6
+ * Plist parsing prefers `plutil -convert json` (system binary on macOS, ships
7
+ * with the OS) - more reliable than any Node `plist` package across the long
8
+ * tail of Apple plist quirks (binary plists, XML-with-DTD, mixed array/dict
9
+ * roots).
10
+ *
11
+ * When plutil is absent it falls back to a small XML reader, and when even that
12
+ * cannot apply - a binary plist off macOS - it records the miss rather than
13
+ * returning an empty object. That distinction is the whole point: every rule
14
+ * reads `infoPlist`, so a silent `{}` makes an unreadable archive audit exactly
15
+ * like a compliant one, and the tool answers PASS because it saw nothing. It
16
+ * shipped that way; a run with no plutil on PATH reported zero violations on a
17
+ * deliberately non-compliant fixture.
10
18
  */
11
19
 
12
20
  import { execSync } from "child_process";
13
- import { existsSync, readdirSync, statSync } from "fs";
21
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
14
22
  import { shq } from "./models.js";
15
23
  import { join, basename } from "path";
16
24
 
@@ -23,16 +31,159 @@ function run(cmd) {
23
31
  }
24
32
  }
25
33
 
26
- /** Parse a binary or XML plist file into a JS object via `plutil`. */
34
+ /** True when the `plutil` binary is on PATH. Probed once. */
35
+ let _hasPlutil = null;
36
+ export function hasPlutil() {
37
+ if (_hasPlutil === null) _hasPlutil = run("command -v plutil") !== "";
38
+ return _hasPlutil;
39
+ }
40
+
41
+ /**
42
+ * Plists this process could not read, as absolute paths. A rule that finds
43
+ * nothing in an unread plist is not evidence of compliance, and runAudit turns
44
+ * a non-empty list into a finding rather than letting the verdict stand.
45
+ */
46
+ export const unreadablePlists = new Set();
47
+
48
+ /**
49
+ * Empty the record. runAudit calls this before it parses anything, so the set
50
+ * describes the run being reported and not every run this process has done.
51
+ */
52
+ export function resetUnreadablePlists() {
53
+ unreadablePlists.clear();
54
+ }
55
+
56
+ /** Decode the XML entities a plist can carry. */
57
+ function unescapeXml(s) {
58
+ return s
59
+ .replace(/&lt;/g, "<")
60
+ .replace(/&gt;/g, ">")
61
+ .replace(/&quot;/g, '"')
62
+ .replace(/&apos;/g, "'")
63
+ .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
64
+ .replace(/&amp;/g, "&");
65
+ }
66
+
67
+ /**
68
+ * Minimal XML-plist reader for hosts without plutil.
69
+ *
70
+ * Deliberately small: it handles the element set an Info.plist, an entitlements
71
+ * file and a PrivacyInfo.xcprivacy actually use. It is not a general plist
72
+ * implementation and makes no attempt at binary plists - those return null and
73
+ * are recorded as unreadable, which is the honest answer.
74
+ *
75
+ * @param {string} xml
76
+ * @returns {object|Array|null}
77
+ */
78
+ export function parseXmlPlist(xml) {
79
+ // <!DOCTYPE ...> and <?xml ...?> carry nothing we need and complicate the scan.
80
+ const body = xml.replace(/<\?xml[^>]*\?>/g, "").replace(/<!DOCTYPE[^>]*>/g, "");
81
+ const tokens = body.match(/<\/?[A-Za-z]+(?:\s[^>]*)?\/?>|[^<]+/g);
82
+ if (!tokens) return null;
83
+ let i = 0;
84
+
85
+ const next = () => tokens[i++];
86
+ const peek = () => tokens[i];
87
+
88
+ function readValue(tag) {
89
+ switch (tag) {
90
+ case "dict": {
91
+ const out = {};
92
+ for (;;) {
93
+ const t = peek();
94
+ if (t === undefined || t === "</dict>") {
95
+ next();
96
+ return out;
97
+ }
98
+ if (t === "<key>") {
99
+ next();
100
+ const k = unescapeXml(String(next() ?? "").trim());
101
+ next(); // </key>
102
+ const vt = String(next() ?? "").trim();
103
+ out[k] = readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
104
+ } else {
105
+ next();
106
+ }
107
+ }
108
+ }
109
+ case "array": {
110
+ const out = [];
111
+ for (;;) {
112
+ const t = peek();
113
+ if (t === undefined || t === "</array>") {
114
+ next();
115
+ return out;
116
+ }
117
+ const vt = String(next() ?? "").trim();
118
+ if (!vt.startsWith("<")) continue;
119
+ out.push(readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]));
120
+ }
121
+ }
122
+ case "string":
123
+ case "data":
124
+ case "date": {
125
+ const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "";
126
+ if (String(peek() ?? "").startsWith("</")) next();
127
+ return unescapeXml(raw);
128
+ }
129
+ case "integer":
130
+ case "real": {
131
+ const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "0";
132
+ if (String(peek() ?? "").startsWith("</")) next();
133
+ return Number(raw.trim());
134
+ }
135
+ case "true":
136
+ return true;
137
+ case "false":
138
+ return false;
139
+ default:
140
+ return null;
141
+ }
142
+ }
143
+
144
+ for (;;) {
145
+ const t = next();
146
+ if (t === undefined) return null;
147
+ const s = String(t).trim();
148
+ if (!s.startsWith("<plist")) continue;
149
+ for (;;) {
150
+ const u = next();
151
+ if (u === undefined) return null;
152
+ const v = String(u).trim();
153
+ if (!v.startsWith("<")) continue;
154
+ return readValue(v.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
155
+ }
156
+ }
157
+ }
158
+
159
+ /** Parse a binary or XML plist file into a JS object. */
27
160
  export function parsePlist(plistPath) {
28
161
  if (!existsSync(plistPath)) return null;
29
- const json = run(`plutil -convert json -o - ${shq(plistPath)} 2>/dev/null`);
30
- if (!json) return null;
162
+ if (hasPlutil()) {
163
+ const json = run(`plutil -convert json -o - ${shq(plistPath)} 2>/dev/null`);
164
+ if (json) {
165
+ try {
166
+ return JSON.parse(json);
167
+ } catch {
168
+ /* fall through to the XML reader */
169
+ }
170
+ }
171
+ }
172
+ let raw;
31
173
  try {
32
- return JSON.parse(json);
174
+ raw = readFileSync(plistPath, "utf-8");
33
175
  } catch {
176
+ unreadablePlists.add(plistPath);
177
+ return null;
178
+ }
179
+ // A binary plist starts with the magic "bplist"; there is no reading it here.
180
+ if (raw.startsWith("bplist")) {
181
+ unreadablePlists.add(plistPath);
34
182
  return null;
35
183
  }
184
+ const parsed = parseXmlPlist(raw);
185
+ if (parsed === null) unreadablePlists.add(plistPath);
186
+ return parsed;
36
187
  }
37
188
 
38
189
  /** Recursively measure total size of a directory in bytes. */
@@ -21,7 +21,7 @@
21
21
  * the rest of the scan.
22
22
  */
23
23
 
24
- import { parseArchive } from "./context.js";
24
+ import { hasPlutil, parseArchive, resetUnreadablePlists, unreadablePlists } from "./context.js";
25
25
  import { compareSeverity } from "./models.js";
26
26
 
27
27
  // ---------- Rule registry --------------------------------------------------
@@ -93,6 +93,7 @@ function resolveRuleSelection(rules) {
93
93
  * @returns {Promise<object>} structured audit report
94
94
  */
95
95
  export async function runAudit({ archivePath, rules = "all", options = {} } = {}) {
96
+ resetUnreadablePlists();
96
97
  const ctx = parseArchive(archivePath);
97
98
 
98
99
  const selectedIDs = new Set(resolveRuleSelection(rules));
@@ -134,6 +135,25 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
134
135
  }
135
136
  }
136
137
 
138
+ // A rule that found nothing in a plist nobody could read has not measured
139
+ // anything, and the audit must not let that reach the caller as compliance.
140
+ // Every rule reads infoPlist, so one unread plist can silence all eighteen:
141
+ // the tool answered PASS on a deliberately non-compliant fixture the first
142
+ // time it ran on a host without plutil. Reported as an error so the verdict
143
+ // is not PASS, with the cause named - this is the tool's reach, not the
144
+ // archive's fault.
145
+ const unreadable = [...unreadablePlists].sort();
146
+ if (unreadable.length > 0) {
147
+ violations.push({
148
+ ruleID: "archive-readable",
149
+ severity: "error",
150
+ message: `${unreadable.length} plist(s) could not be read, so the rules below them measured nothing: ${unreadable.join(", ")}`,
151
+ suggestion: hasPlutil()
152
+ ? "The files are present but neither plutil nor the XML reader could parse them. Re-export the archive, or open an issue with one of the files attached."
153
+ : "Binary plists need `plutil`, which ships with macOS. Run this audit on macOS, or convert the archive's plists to XML first (`plutil -convert xml1`).",
154
+ });
155
+ }
156
+
137
157
  // Sort: error → warning → info, then by ruleID alphabetically inside each.
138
158
  violations.sort((a, b) => {
139
159
  const c = compareSeverity(b.severity, a.severity);
@@ -156,6 +176,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
156
176
  app: ctx.appName,
157
177
  rulesRun: ranIDs,
158
178
  rulesSkipped: skippedIDs,
179
+ // Explicit, so a caller can tell "clean" from "could not look".
180
+ measurable: unreadable.length === 0,
181
+ unreadablePlists: unreadable,
159
182
  summary,
160
183
  verdict,
161
184
  violations,
@@ -58,15 +58,35 @@ function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
58
58
  }
59
59
  })
60
60
  .filter(Boolean)
61
- .sort((a, b) => b.mtime - a.mtime);
62
-
63
- for (let i = 0; i < entries.length; i++) {
64
- if (keepSet.has(resolve(entries[i].full))) continue;
65
- const tooOld = entries[i].mtime < cutoff;
66
- const tooMany = i >= keepFiles;
67
- if (!tooOld && !tooMany) continue;
61
+ // Name as the tie-break, because mtime ties are not a corner case. APFS
62
+ // records nanoseconds, so five files written in a loop have five distinct
63
+ // stamps; ext4 on a CI runner records one, so they have the same stamp and
64
+ // "the newest" is whatever order readdir happened to return. Without this
65
+ // the retained set differs per filesystem.
66
+ .sort((a, b) => b.mtime - a.mtime || (a.full < b.full ? 1 : a.full > b.full ? -1 : 0));
67
+
68
+ // keepFiles is the size of the directory after the prune, not the size of
69
+ // the part of it retention got to decide. A promised file is one of the
70
+ // survivors, so it takes one of the slots - reserved up front rather than
71
+ // counted as the loop reaches it, because otherwise the answer depends on
72
+ // where that file happens to sort. It used to be neither: a keepSet entry
73
+ // was skipped without consuming anything, so keepFiles: 1 plus one promised
74
+ // file left TWO files behind. On APFS the promised file is genuinely the
75
+ // newest and sorted first, which hid it; on a filesystem whose mtimes tie it
76
+ // sorted anywhere and the directory kept a stale file forever.
77
+ const reserved = entries.filter((e) => keepSet.has(resolve(e.full))).length;
78
+ const budget = Math.max(0, keepFiles - reserved);
79
+ let kept = 0;
80
+ for (const entry of entries) {
81
+ if (keepSet.has(resolve(entry.full))) continue;
82
+ const tooOld = entry.mtime < cutoff;
83
+ const tooMany = kept >= budget;
84
+ if (!tooOld && !tooMany) {
85
+ kept++;
86
+ continue;
87
+ }
68
88
  try {
69
- rmSync(entries[i].full, { recursive: true, force: true });
89
+ rmSync(entry.full, { recursive: true, force: true });
70
90
  removed++;
71
91
  } catch {
72
92
  // A file another process holds open is skipped, not fatal.