@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/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@mmerterden/multi-agent-toolkit-mcp",
3
+ "version": "3.0.0",
4
+ "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 83 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
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "multi-agent-toolkit-mcp": "index.js",
9
+ "dev-toolkit-mcp": "index.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node index.js",
13
+ "test": "node --test tools/design-check/__tests__/design-check.test.mjs tools/design-check/__tests__/plan-determinism.test.mjs tools/ios-app-store-audit/__tests__/app-store-audit.test.mjs tools/ios-testflight/__tests__/testflight.test.mjs tools/ui-inspect/__tests__/ui-inspect.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
14
+ "gates": "bash scripts/gates.sh"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "ios",
20
+ "android",
21
+ "simulator",
22
+ "emulator",
23
+ "xcrun",
24
+ "simctl",
25
+ "adb",
26
+ "testing",
27
+ "screenshot",
28
+ "accessibility",
29
+ "ui-testing",
30
+ "mobile",
31
+ "claude",
32
+ "claude-code",
33
+ "copilot",
34
+ "cursor",
35
+ "antigravity",
36
+ "playwright",
37
+ "web-automation",
38
+ "browser-automation",
39
+ "agent-dsl",
40
+ "batch-execution",
41
+ "ios-simulator",
42
+ "android-emulator",
43
+ "appium-alternative",
44
+ "visual-regression",
45
+ "app-store",
46
+ "testflight",
47
+ "device-automation",
48
+ "xcode"
49
+ ],
50
+ "author": "Mert Erden",
51
+ "license": "MIT",
52
+ "publishConfig": {
53
+ "registry": "https://registry.npmjs.org",
54
+ "access": "public"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/mmerterden/multi-agent-toolkit-mcp.git"
59
+ },
60
+ "homepage": "https://github.com/mmerterden/multi-agent-toolkit-mcp#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/mmerterden/multi-agent-toolkit-mcp/issues"
63
+ },
64
+ "engines": {
65
+ "node": ">=20.0.0"
66
+ },
67
+ "files": [
68
+ "index.js",
69
+ "ui-tree-dumper.swift",
70
+ "tools/",
71
+ "!tools/**/__tests__",
72
+ "README.md",
73
+ "CHANGELOG.md",
74
+ "LICENSE"
75
+ ],
76
+ "dependencies": {
77
+ "@modelcontextprotocol/sdk": "^1.30.0",
78
+ "pixelmatch": "^7.2.0",
79
+ "pngjs": "^7.0.0"
80
+ },
81
+ "peerDependencies": {
82
+ "playwright": ">=1.60.0 <2"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "playwright": {
86
+ "optional": true
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Selection logic for macOS DiagnosticReports crash files.
3
+ *
4
+ * Pure so it can be tested without a filesystem: the tool layer reads the
5
+ * directory and hands the entries here.
6
+ *
7
+ * @module tools/crash-logs
8
+ */
9
+
10
+ const CRASH_EXTENSIONS = [".ips", ".crash", ".panic", ".hang", ".diag"];
11
+
12
+ /**
13
+ * @param {Array<{name: string, mtimeMs: number, size: number}>} entries directory listing with stats
14
+ * @param {object} [opts]
15
+ * @param {string} [opts.app] only files whose name contains this substring (case-insensitive)
16
+ * @param {number} [opts.sinceMs] only files modified at or after this epoch ms
17
+ * @param {number} [opts.limit] max results (default 20)
18
+ * @returns {Array<{name: string, mtimeMs: number, size: number}>} newest first
19
+ */
20
+ export function selectCrashReports(entries, opts = {}) {
21
+ const { app, sinceMs, limit = 20 } = opts;
22
+ const needle = app ? String(app).toLowerCase() : null;
23
+ return entries
24
+ .filter((e) => CRASH_EXTENSIONS.some((ext) => e.name.endsWith(ext)))
25
+ .filter((e) => (needle ? e.name.toLowerCase().includes(needle) : true))
26
+ .filter((e) => (sinceMs !== undefined ? e.mtimeMs >= sinceMs : true))
27
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
28
+ .slice(0, Math.max(0, limit));
29
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * content-cardinality.js - suppress the geometry noise a fixture-count
3
+ * difference creates, WITHOUT suppressing a real defect.
4
+ *
5
+ * The problem (docs/design-check-gaps.md section 1). A live capture is compared
6
+ * against a screen frame, and a screen frame carries whatever the mock fixture
7
+ * produced. A design showing 5 passenger rows against an app rendering 3 makes
8
+ * every container height and every downstream position differ for a reason that
9
+ * is not a defect. On a real 109-target module that noise dominated the report.
10
+ *
11
+ * Why this is written defensively. The dangerous direction is not noise, it is a
12
+ * hidden defect: demote too eagerly and the engine silently stops reporting real
13
+ * deviations, in a report whose whole purpose is to surface them, and the mistake
14
+ * is invisible precisely because the finding is gone. So the rules below are
15
+ * deliberately narrow, and every one of them is a test in
16
+ * `__tests__/design-check.test.mjs`:
17
+ *
18
+ * 1. Nothing is ever DELETED. A demoted finding stays in the report with
19
+ * `advisory: true` and a `rootCause`, so it renders under the existing
20
+ * "DO NOT CHANGE (advisory / not a defect)" group with its reason attached.
21
+ * `demoted` is returned as a count so a run can state what it demoted.
22
+ * 2. Only GEOMETRY findings are eligible. Copy, colour, typography, font-family
23
+ * and tap-target findings are never touched: a row-count difference cannot
24
+ * make a colour wrong or a string mistranslated, so it can never be the
25
+ * explanation for one.
26
+ * 3. Nothing is demoted unless a mismatch is actually detected, from evidence,
27
+ * in a repeated group. No mismatch -> the findings array is returned
28
+ * unchanged, same objects.
29
+ * 4. Only findings AT OR BELOW the mismatched group are eligible. A count
30
+ * difference shifts what follows it; it cannot explain a deviation above it.
31
+ * 5. A finding already marked advisory is left alone (idempotent), and a
32
+ * `verified` finding is never demoted.
33
+ *
34
+ * FIELD VALIDATION STILL PENDING: the acceptance criterion in the gaps doc ("a
35
+ * screen whose fixture returns a different row count produces zero geometry
36
+ * findings") needs the real module, a live capture and Figma design context to
37
+ * confirm end to end. What is proven here is the safe direction - the rules above
38
+ * hold on synthetic fixtures in both directions. Treat the tuning constants as
39
+ * provisional until a real run is measured.
40
+ */
41
+
42
+ /** Finding categories a cardinality difference can plausibly explain. */
43
+ const GEOMETRY_CATEGORIES = new Set(["spacing", "size", "position", "alignment"]);
44
+
45
+ /** Finding types it can explain. Narrower than the category set on purpose. */
46
+ const GEOMETRY_TYPES = new Set(["spacing", "height", "inset", "gap", "position", "offset"]);
47
+
48
+ /**
49
+ * Types that must NEVER be demoted, whatever their category says. Listed
50
+ * explicitly rather than relying on the allow-list above, so a future category
51
+ * rename cannot quietly make one of these eligible.
52
+ */
53
+ const NEVER_DEMOTE_TYPES = new Set([
54
+ "copy",
55
+ "color",
56
+ "colour",
57
+ "font-size",
58
+ "font-family",
59
+ "typography",
60
+ "tap-target",
61
+ "icon-size",
62
+ "missing",
63
+ "extra",
64
+ ]);
65
+
66
+ /**
67
+ * Strip the varying tail from an element name so repeated siblings collapse to
68
+ * one group key: `passengerRow_2` / `passenger row 3` / `PassengerRow-4` all
69
+ * become `passengerrow`.
70
+ *
71
+ * @param {string} name
72
+ * @returns {string}
73
+ */
74
+ export function groupKey(name) {
75
+ return String(name || "")
76
+ .trim()
77
+ .toLowerCase()
78
+ .replace(/[\s_-]+/g, "")
79
+ .replace(/\d+$/, "")
80
+ .replace(/(item|row|cell|card)$/, "$1");
81
+ }
82
+
83
+ /**
84
+ * Count repeated siblings per group in a flat element list.
85
+ *
86
+ * @param {Array<object>} elements
87
+ * @returns {Map<string, {count: number, minY: number}>}
88
+ */
89
+ function groupCounts(elements) {
90
+ const out = new Map();
91
+ for (const el of elements || []) {
92
+ const raw = el?.label ?? el?.text ?? el?.identifier ?? el?.name;
93
+ const key = groupKey(raw);
94
+ if (!key) continue;
95
+ const y = Number.isFinite(el?.y) ? el.y : Infinity;
96
+ const cur = out.get(key);
97
+ if (cur) {
98
+ cur.count += 1;
99
+ cur.minY = Math.min(cur.minY, y);
100
+ } else {
101
+ out.set(key, { count: 1, minY: y });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Detect a repeated-group cardinality difference between design and live.
109
+ *
110
+ * Requires a group to be genuinely REPEATED on at least one side (>= 2), because
111
+ * a 1-vs-0 difference is a missing element - a real defect, and exactly what this
112
+ * must not explain away.
113
+ *
114
+ * @param {Array<object>} figmaSpec
115
+ * @param {Array<object>} liveGeometry
116
+ * @param {{minRepeat?: number}} [opts]
117
+ * @returns {{mismatch: boolean, groups: Array<object>, boundaryY: number|null}}
118
+ */
119
+ export function detectCardinalityMismatch(figmaSpec, liveGeometry, opts = {}) {
120
+ const minRepeat = opts.minRepeat ?? 2;
121
+ const design = groupCounts(figmaSpec);
122
+ const live = groupCounts(liveGeometry);
123
+ const groups = [];
124
+
125
+ for (const [key, d] of design) {
126
+ const l = live.get(key);
127
+ if (!l) continue; // absent entirely is a missing-element defect, not a count difference
128
+ if (d.count === l.count) continue;
129
+ if (Math.max(d.count, l.count) < minRepeat) continue; // 1 vs 0 is not a repeat
130
+ groups.push({
131
+ key,
132
+ designCount: d.count,
133
+ liveCount: l.count,
134
+ // The topmost occurrence on either side: everything at or below it can be
135
+ // shifted by the difference.
136
+ firstY: Math.min(d.minY, l.minY),
137
+ });
138
+ }
139
+
140
+ const boundaryY = groups.length
141
+ ? Math.min(...groups.map((g) => (Number.isFinite(g.firstY) ? g.firstY : Infinity)))
142
+ : null;
143
+
144
+ return {
145
+ mismatch: groups.length > 0,
146
+ groups,
147
+ boundaryY: Number.isFinite(boundaryY) ? boundaryY : null,
148
+ };
149
+ }
150
+
151
+ /** Is this finding the kind a cardinality difference could explain? */
152
+ function isGeometryFinding(f) {
153
+ const type = String(f?.type || "").toLowerCase();
154
+ const category = String(f?.category || "").toLowerCase();
155
+ if (NEVER_DEMOTE_TYPES.has(type)) return false;
156
+ return GEOMETRY_TYPES.has(type) || GEOMETRY_CATEGORIES.has(category);
157
+ }
158
+
159
+ /** The finding's vertical position, or null when it cannot be established. */
160
+ function findingY(f) {
161
+ const y = f?.region?.y ?? f?.y;
162
+ return Number.isFinite(y) ? y : null;
163
+ }
164
+
165
+ /**
166
+ * Demote the geometry findings a detected cardinality difference explains.
167
+ *
168
+ * @param {Array<object>} findings
169
+ * @param {{mismatch: boolean, groups: Array<object>, boundaryY: number|null}} detection
170
+ * @returns {{findings: Array<object>, demoted: number, rootCause: string|null}}
171
+ */
172
+ export function demoteCardinalityNoise(findings, detection) {
173
+ const list = Array.isArray(findings) ? findings : [];
174
+ if (!detection?.mismatch) return { findings: list, demoted: 0, rootCause: null };
175
+
176
+ const summary = detection.groups
177
+ .map((g) => `${g.key}: design ${g.designCount} vs live ${g.liveCount}`)
178
+ .join("; ");
179
+ const rootCause =
180
+ `content count differs between the design fixture and the running app (${summary}). ` +
181
+ `Container heights and everything below the first differing group shift for that reason, ` +
182
+ `not because the layout is wrong. Re-run against a fixture with matching counts to measure these.`;
183
+
184
+ const boundary = detection.boundaryY;
185
+ let demoted = 0;
186
+
187
+ const out = list.map((f) => {
188
+ if (!f || f.advisory || f.status === "verified") return f;
189
+ if (!isGeometryFinding(f)) return f;
190
+
191
+ // Rule 4: only at or below the first differing group. An unknown position
192
+ // is NOT assumed to be below it - unplaceable findings keep full severity,
193
+ // because demoting on a guess is the failure mode this module must not have.
194
+ if (boundary !== null) {
195
+ const y = findingY(f);
196
+ if (y === null || y < boundary) return f;
197
+ }
198
+
199
+ demoted += 1;
200
+ return { ...f, advisory: true, rootCause, demotedBy: "content-cardinality" };
201
+ });
202
+
203
+ return { findings: out, demoted, rootCause };
204
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * design_ui_geometry helpers - turn a live UI hierarchy into a flat list of
3
+ * element bounding boxes, so spacing/position deltas against a Figma spec can
4
+ * be measured in pixels. Generic: works off the iOS AX JSON dumper output and
5
+ * the Android uiautomator XML dump.
6
+ *
7
+ * Element shape: { role, label, text, identifier, x, y, w, h }
8
+ * Coordinates are in device points/pixels as reported by the platform.
9
+ */
10
+
11
+ // iOS: parse the ui-tree-dumper.swift JSON (nested AXNode with frame {x,y,w,h}).
12
+ export function flattenIosAxTree(json) {
13
+ let root;
14
+ try { root = typeof json === "string" ? JSON.parse(json) : json; } catch { return []; }
15
+ const out = [];
16
+ const visit = (node) => {
17
+ if (!node || typeof node !== "object") return;
18
+ const f = node.frame || {};
19
+ if (typeof f.w === "number" && typeof f.h === "number" && f.w > 0 && f.h > 0) {
20
+ out.push({
21
+ role: node.role || "",
22
+ label: node.title || node.description || "",
23
+ text: node.value || "",
24
+ identifier: node.identifier || "",
25
+ x: Math.round(f.x || 0), y: Math.round(f.y || 0),
26
+ w: Math.round(f.w), h: Math.round(f.h),
27
+ });
28
+ }
29
+ (node.children || []).forEach(visit);
30
+ };
31
+ visit(root);
32
+ return out;
33
+ }
34
+
35
+ // iOS via idb: parse `idb ui describe-all --json` output (array of AX elements,
36
+ // each with frame {x,y,width,height} in points). Works headless (no Simulator GUI).
37
+ export function flattenIdbDescribeAll(json) {
38
+ let arr;
39
+ try {
40
+ const raw = typeof json === "string" ? json.trim() : json;
41
+ arr = typeof raw === "string"
42
+ ? (raw.startsWith("[") ? JSON.parse(raw) : raw.split("\n").filter(Boolean).map((l) => JSON.parse(l)))
43
+ : raw;
44
+ } catch { return []; }
45
+ if (!Array.isArray(arr)) return [];
46
+ const out = [];
47
+ for (const e of arr) {
48
+ const f = e && e.frame;
49
+ if (!f || typeof f.width !== "number") continue;
50
+ if (f.width <= 0 || f.height <= 0) continue;
51
+ if ((e.type || e.role_description) === "Application") continue;
52
+ out.push({
53
+ role: e.role || e.type || "",
54
+ label: e.AXLabel || "",
55
+ text: e.AXValue || e.title || "",
56
+ identifier: e.AXUniqueId || "",
57
+ x: Math.round(f.x), y: Math.round(f.y),
58
+ w: Math.round(f.width), h: Math.round(f.height),
59
+ });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Android: parse uiautomator XML. bounds="[x1,y1][x2,y2]".
65
+ export function flattenAndroidUiXml(xml) {
66
+ if (!xml || typeof xml !== "string") return [];
67
+ const out = [];
68
+ const nodeRe = /<node\b([^>]*)\/?>/g;
69
+ const attr = (s, name) => {
70
+ const m = new RegExp(`${name}="([^"]*)"`).exec(s);
71
+ return m ? m[1] : "";
72
+ };
73
+ let m;
74
+ while ((m = nodeRe.exec(xml)) !== null) {
75
+ const a = m[1];
76
+ const bounds = attr(a, "bounds");
77
+ const bm = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds);
78
+ if (!bm) continue;
79
+ const x1 = +bm[1], y1 = +bm[2], x2 = +bm[3], y2 = +bm[4];
80
+ const w = x2 - x1, h = y2 - y1;
81
+ if (w <= 0 || h <= 0) continue;
82
+ out.push({
83
+ role: attr(a, "class").split(".").pop() || "",
84
+ label: attr(a, "content-desc"),
85
+ text: attr(a, "text"),
86
+ identifier: attr(a, "resource-id"),
87
+ x: x1, y: y1, w, h,
88
+ });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // Normalize a Figma node subtree (from get_design_context / metadata) into the
94
+ // same flat element shape, carrying style spec where present.
95
+ // Accepts a node with absoluteBoundingBox / children / style / fills.
96
+ export function flattenFigmaNode(node, originX = 0, originY = 0) {
97
+ const out = [];
98
+ const visit = (n) => {
99
+ if (!n || typeof n !== "object") return;
100
+ const bb = n.absoluteBoundingBox || n.boundingBox;
101
+ if (bb && typeof bb.width === "number") {
102
+ const style = n.style || {};
103
+ const fill = firstSolidColor(n.fills) || (style.fills && firstSolidColor(style.fills));
104
+ out.push({
105
+ role: n.type || "",
106
+ label: n.name || "",
107
+ text: n.characters || "",
108
+ identifier: n.id || "",
109
+ x: Math.round(bb.x - originX), y: Math.round(bb.y - originY),
110
+ w: Math.round(bb.width), h: Math.round(bb.height),
111
+ fontSize: style.fontSize || null,
112
+ fontFamily: style.fontFamily || style.fontPostScriptName || null,
113
+ color: fill || null,
114
+ itemSpacing: typeof n.itemSpacing === "number" ? n.itemSpacing : null,
115
+ paddings: {
116
+ top: n.paddingTop, right: n.paddingRight, bottom: n.paddingBottom, left: n.paddingLeft,
117
+ },
118
+ });
119
+ }
120
+ (n.children || []).forEach(visit);
121
+ };
122
+ visit(node);
123
+ return out;
124
+ }
125
+
126
+ function firstSolidColor(fills) {
127
+ if (!Array.isArray(fills)) return null;
128
+ for (const f of fills) {
129
+ if (f && f.type === "SOLID" && f.color) {
130
+ const { r, g, b } = f.color;
131
+ return rgbToHex(r * 255, g * 255, b * 255);
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ export function rgbToHex(r, g, b) {
138
+ const h = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
139
+ return `#${h(r)}${h(g)}${h(b)}`.toUpperCase();
140
+ }