@mmerterden/multi-agent-toolkit-mcp 3.4.0 → 3.5.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,44 @@ Releases before this file exists are recorded in the git tags and commit history
15
15
 
16
16
  ---
17
17
 
18
+ ## 3.5.0
19
+
20
+ ### Added
21
+
22
+ - **`ios_accessibility_audit` checks the reading order.** A screen with perfect
23
+ labels is still unusable if VoiceOver walks it in the wrong order, and nothing
24
+ was looking. The audit compares the reading sequence against the visual layout
25
+ and reports the first element that arrives out of place.
26
+
27
+ The comparison is rows-then-columns, not a naive top-to-bottom sort: a home
28
+ screen is a grid, a toolbar is a row, and sorting by `y` alone calls every
29
+ correct grid broken. Elements share a row when their vertical extents overlap
30
+ by more than half the shorter one, which absorbs the few points of jitter real
31
+ layouts carry. `rtl: true` expects right-to-left within a row.
32
+
33
+ It is an `important` finding and never a `critical` one: a deliberate reading
34
+ order that differs from the visual one is legitimate, so this is a question
35
+ worth asking rather than a proven defect. Verified on a live home screen grid:
36
+ 13 elements, reading order correct, no false positive.
37
+
38
+ - **Traits and hints.** The dumper now reads `AXRoleDescription` (what a screen
39
+ reader announces the element AS) and `AXHelp` (where the iOS
40
+ `accessibilityHint` bridges to). A control with no role announcement is read
41
+ out as bare text and the user cannot tell it is actionable, so that is an
42
+ `important` finding. A missing hint is not: hints are not required on every
43
+ element, so the result reports `hints_present` as a count and leaves the
44
+ judgement to the reader.
45
+
46
+ `AXSubrole` was probed first and carries nothing at all through this bridge,
47
+ so it is not read. Guessing at it would have produced a check that never fires.
48
+
49
+ ### Note
50
+
51
+ Stage 2 of 4. Contrast, Dynamic Type and clipped text still cannot be reached
52
+ from a tree dump; they need Apple's own `XCUIAccessibilityAudit`, which is an
53
+ XCUITest run rather than a simctl call. The Android side is stage 4 and is
54
+ unverifiable on a machine with no adb.
55
+
18
56
  ## 3.4.0
19
57
 
20
58
  ### Fixed
package/index.js CHANGED
@@ -351,7 +351,7 @@ const IOS_TOOLS = [
351
351
  { name: "ios_get_app_container", description: "Get iOS app container path", inputSchema: { type: "object", properties: { bundle_id: { type: "string" }, container: { type: "string", enum: ["app", "data", "groups"] }, device_id: { type: "string" } }, required: ["bundle_id"] } },
352
352
  { name: "ios_erase_device", description: "Factory reset iOS simulator", inputSchema: { type: "object", properties: { device_id: { type: "string" } } } },
353
353
  { name: "ios_get_ui_tree", description: "Get iOS accessibility UI tree via macOS AX APIs. Pass `path` to write the raw dump to a file and return only its location - the full tree can be tens of KB.", inputSchema: { type: "object", properties: { max_depth: { type: "number" }, path: { type: "string", description: "Absolute file path to write the raw tree JSON to. The parent directory must already exist." } } } },
354
- { name: "ios_accessibility_audit", description: "Audit iOS app accessibility: missing labels, small tap targets (<44pt), missing identifiers. Use scope to filter by identifier prefix (e.g. 'login_' only checks login screen elements).", inputSchema: { type: "object", properties: { max_depth: { type: "number" }, scope: { type: "string", description: "Filter: only audit elements whose identifier starts with this prefix (e.g. 'login_', 'settings_'). Omit to audit all." } } } },
354
+ { name: "ios_accessibility_audit", description: "Audit iOS app accessibility on the booted simulator: missing labels, controls a screen reader cannot name, tap targets under 44pt, missing identifiers, and whether the reading order follows the visual layout. Reports measurable:false with a reason rather than a clean result when the tree could not be read - booting a device is not enough, Simulator.app must be running. Scopes to the device screen, never the simulator own chrome.", inputSchema: { type: "object", properties: { max_depth: { type: "number" }, scope: { type: "string", description: "Filter: only audit elements whose identifier starts with this prefix (e.g. 'login_', 'settings_'). Omit to audit all." }, rtl: { type: "boolean", description: "Expect right-to-left reading within a row. Default false." } } } },
355
355
  { name: "ios_biometric", description: "Simulate Face ID / Touch ID on iOS simulator (match or nomatch)", inputSchema: { type: "object", properties: { match: { type: "boolean", description: "true=success, false=failure" }, device_id: { type: "string" } }, required: ["match"] } },
356
356
  { name: "ios_archive_audit", description: "DEPRECATED - use ios_app_store_audit (18-rule deep scan). Lighter 6-check audit kept for backward compatibility; will be removed in the next major.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Path to .xcarchive" } }, required: ["archive_path"] } },
357
357
  { 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"] } },
@@ -508,13 +508,15 @@ async function handleIOS(name, args, ctx = {}) {
508
508
  } catch {
509
509
  tree = null;
510
510
  }
511
- const r = auditIosTree({ tree, scope: args.scope || null });
511
+ const r = auditIosTree({ tree, scope: args.scope || null, rtl: args.rtl === true });
512
512
  return JSON.stringify({
513
513
  scope: r.scope,
514
514
  measurable: r.measurable,
515
515
  reason: r.reason,
516
516
  elements_scanned: r.elementsScanned,
517
517
  elements_skipped: r.elementsSkipped,
518
+ reading_order_ok: r.readingOrderOk ?? null,
519
+ hints_present: r.hintsPresent ?? null,
518
520
  total_issues: r.totalIssues,
519
521
  critical: r.critical,
520
522
  important: r.important,
@@ -1546,6 +1548,8 @@ const ACCESSIBILITY_AUDIT_SCHEMA = {
1546
1548
  reason: { type: ["string", "null"] },
1547
1549
  elements_scanned: { type: "integer" },
1548
1550
  elements_skipped: { type: "integer" },
1551
+ reading_order_ok: { type: ["boolean", "null"] },
1552
+ hints_present: { type: ["integer", "null"] },
1549
1553
  total_issues: { type: ["integer", "null"] },
1550
1554
  critical: { type: ["integer", "null"] },
1551
1555
  important: { type: ["integer", "null"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 86 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",
@@ -65,6 +65,84 @@ export function deviceScreenSubtree(tree) {
65
65
  return groups.reduce((best, c) => (area(c) > area(best) ? c : best), groups[0]);
66
66
  }
67
67
 
68
+ /**
69
+ * Flatten the interactive elements in the order a screen reader would visit
70
+ * them, which is the order the accessibility tree presents them in.
71
+ *
72
+ * @param {object} node
73
+ * @param {Set<string>} roles
74
+ * @returns {object[]}
75
+ */
76
+ function readingSequence(node, roles) {
77
+ const out = [];
78
+ const visit = (n) => {
79
+ if (!n || typeof n !== "object") return;
80
+ if (roles.has(n.role)) out.push(n);
81
+ (n.children || []).forEach(visit);
82
+ };
83
+ visit(node);
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Group elements into visual rows.
89
+ *
90
+ * A screen is not a single top-to-bottom column. A home screen is a grid, a
91
+ * toolbar is a row, and comparing the reading order against a naive
92
+ * top-to-bottom sort reports every grid as broken. Two elements share a row
93
+ * when their vertical extents overlap by more than half the shorter one.
94
+ *
95
+ * @param {object[]} els
96
+ * @returns {object[][]} rows, top to bottom, each sorted left to right
97
+ */
98
+ export function visualRows(els) {
99
+ const sorted = [...els].sort((a, b) => (a.frame?.y || 0) - (b.frame?.y || 0));
100
+ const rows = [];
101
+ for (const el of sorted) {
102
+ const y = el.frame?.y || 0;
103
+ const h = el.frame?.h || 0;
104
+ const row = rows.find((r) => {
105
+ const ry = r.top;
106
+ const rh = r.height;
107
+ const overlap = Math.min(y + h, ry + rh) - Math.max(y, ry);
108
+ return overlap > Math.min(h, rh) / 2;
109
+ });
110
+ if (row) {
111
+ row.items.push(el);
112
+ row.top = Math.min(row.top, y);
113
+ row.height = Math.max(row.height, h);
114
+ } else {
115
+ rows.push({ top: y, height: h, items: [el] });
116
+ }
117
+ }
118
+ return rows.map((r) => r.items.sort((a, b) => (a.frame?.x || 0) - (b.frame?.x || 0)));
119
+ }
120
+
121
+ /**
122
+ * Does the reading order follow the visual layout?
123
+ *
124
+ * The check is deliberately coarse: it compares the reading sequence against
125
+ * rows-then-columns, and reports the first element that arrives out of place.
126
+ * A screen can carry a deliberate reading order that differs from the visual
127
+ * one, so this is an `important` finding and never a `critical` one: it is a
128
+ * question worth asking, not a proven defect.
129
+ *
130
+ * @param {object[]} sequence - elements in reading order
131
+ * @param {boolean} rtl - right-to-left layout
132
+ * @returns {{ok: boolean, firstOutOfPlace: object|null, expectedIndex: number|null}}
133
+ */
134
+ export function checkReadingOrder(sequence, rtl = false) {
135
+ if (sequence.length < 2) return { ok: true, firstOutOfPlace: null, expectedIndex: null };
136
+ const rows = visualRows(sequence);
137
+ const expected = rows.flatMap((r) => (rtl ? [...r].reverse() : r));
138
+ for (let i = 0; i < sequence.length; i++) {
139
+ if (sequence[i] !== expected[i]) {
140
+ return { ok: false, firstOutOfPlace: sequence[i], expectedIndex: expected.indexOf(sequence[i]) };
141
+ }
142
+ }
143
+ return { ok: true, firstOutOfPlace: null, expectedIndex: null };
144
+ }
145
+
68
146
  /**
69
147
  * @param {object} params
70
148
  * @param {object|null} params.tree - parsed ui-tree-dumper output
@@ -74,7 +152,7 @@ export function deviceScreenSubtree(tree) {
74
152
  * totalIssues: number|null, critical: number|null,
75
153
  * important: number|null, warning: number|null, issues: object[]}}
76
154
  */
77
- export function auditIosTree({ tree, scope = null }) {
155
+ export function auditIosTree({ tree, scope = null, rtl = false }) {
78
156
  if (isDegenerateTree(tree)) {
79
157
  return unmeasurable(
80
158
  scope,
@@ -95,6 +173,7 @@ export function auditIosTree({ tree, scope = null }) {
95
173
  const issues = [];
96
174
  let elementsScanned = 0;
97
175
  let elementsSkipped = 0;
176
+ const sequence = [];
98
177
 
99
178
  const visit = (node, path = "") => {
100
179
  if (!node || typeof node !== "object") return;
@@ -109,6 +188,12 @@ export function auditIosTree({ tree, scope = null }) {
109
188
  return;
110
189
  }
111
190
  elementsScanned++;
191
+ sequence.push(node);
192
+ // What a screen reader announces the element AS. Without it a control is
193
+ // read out as bare text and the user cannot tell it is actionable.
194
+ if (!node.roleDescription) {
195
+ issues.push({ severity: "important", issue: "No role announced (VoiceOver cannot say what this control is)", element: loc, identifier: node.identifier || null });
196
+ }
112
197
  if (!node.title && !node.description && !node.value) {
113
198
  issues.push({ severity: "critical", issue: "Missing accessibility label", element: loc, identifier: node.identifier || null });
114
199
  }
@@ -135,12 +220,26 @@ export function auditIosTree({ tree, scope = null }) {
135
220
  );
136
221
  }
137
222
 
223
+ const order = checkReadingOrder(sequence, rtl);
224
+ if (!order.ok) {
225
+ const el = order.firstOutOfPlace;
226
+ issues.push({
227
+ severity: "important",
228
+ issue: `Reading order does not follow the visual layout: this element is read at position ${sequence.indexOf(el) + 1} but sits at position ${order.expectedIndex + 1} on screen`,
229
+ element: el.role,
230
+ identifier: el.identifier || null,
231
+ label: el.title || el.description || el.value || null,
232
+ });
233
+ }
234
+
138
235
  return {
139
236
  measurable: true,
140
237
  reason: null,
141
238
  scope: scope || "all",
142
239
  elementsScanned,
143
240
  elementsSkipped,
241
+ readingOrderOk: order.ok,
242
+ hintsPresent: sequence.filter((n) => n.help).length,
144
243
  ...tally(issues),
145
244
  };
146
245
  }
@@ -9,6 +9,12 @@ import Foundation
9
9
 
10
10
  struct AXNode: Codable {
11
11
  let role: String
12
+ // What a screen reader announces the element AS. Probed on a live simulator:
13
+ // AXRoleDescription carries "button" and friends, AXSubrole carries nothing
14
+ // at all through this bridge, so only the former is read.
15
+ let roleDescription: String?
16
+ // The iOS accessibilityHint bridges to AXHelp ("Double tap to open").
17
+ let help: String?
12
18
  let title: String?
13
19
  let value: String?
14
20
  let description: String?
@@ -53,6 +59,8 @@ func dumpElement(_ element: AXUIElement, depth: Int, maxDepth: Int) -> AXNode? {
53
59
  let value = getString(element, kAXValueAttribute)
54
60
  let desc = getString(element, kAXDescriptionAttribute)
55
61
  let identifier = getString(element, kAXIdentifierAttribute)
62
+ let roleDescription = getString(element, "AXRoleDescription")
63
+ let help = getString(element, "AXHelp")
56
64
  let enabled = getBool(element, kAXEnabledAttribute)
57
65
  let focused = getBool(element, kAXFocusedAttribute)
58
66
  let frame = getFrame(element)
@@ -75,6 +83,8 @@ func dumpElement(_ element: AXUIElement, depth: Int, maxDepth: Int) -> AXNode? {
75
83
 
76
84
  return AXNode(
77
85
  role: role,
86
+ roleDescription: roleDescription,
87
+ help: help,
78
88
  title: title,
79
89
  value: value,
80
90
  description: desc,