@mmerterden/multi-agent-toolkit-mcp 3.7.0 → 3.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.7.0",
3
+ "version": "3.9.0",
4
4
  "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 87 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",
@@ -128,7 +128,7 @@ function safeArg(x, label) {
128
128
  return x;
129
129
  }
130
130
 
131
- // ctx: { run, iosDevice, adbFlag, dumperScript }
131
+ // ctx: { run, iosDevice, adbFlag, dumperScript, dumperCommand }
132
132
  export async function handleDesign(name, args, ctx) {
133
133
  switch (name) {
134
134
  case "design_mock_detect":
@@ -172,7 +172,8 @@ export async function handleDesign(name, args, ctx) {
172
172
  }
173
173
  }
174
174
  const depth = Number(args.max_depth) || 12;
175
- const raw = ctx.run(`swift ${shq(ctx.dumperScript)} ${depth}`, { timeout: 15000 });
175
+ const dumper = ctx.dumperCommand ? ctx.dumperCommand() : `swift ${shq(ctx.dumperScript)}`;
176
+ const raw = ctx.run(`${dumper} ${depth}`, { timeout: 15000 });
176
177
  let tree; try { tree = JSON.parse(raw); } catch { return `ERROR: idb unavailable and AX dumper failed: ${String(raw).slice(0, 200)}`; }
177
178
  const elements = flattenIosAxTree(tree);
178
179
  const rf = tree.frame || {};
@@ -541,6 +541,19 @@ ${variants.map((v, i) => variantSection(v, i, fileKey, L)).join("")}
541
541
  </body></html>`;
542
542
  }
543
543
 
544
+ // The browser is closed on every path: a failed setContent/pdf used to leave a
545
+ // headless Chromium running for the life of the server.
546
+ export async function renderPdf({ html, pdfPath, chromium }) {
547
+ const browser = await chromium.launch();
548
+ try {
549
+ const page = await browser.newPage();
550
+ await page.setContent(html, { waitUntil: "networkidle" });
551
+ await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
552
+ } finally {
553
+ await browser.close();
554
+ }
555
+ }
556
+
544
557
  export async function writeReport({ report, outDir, formats = ["html"] }) {
545
558
  const html = renderHtml(report);
546
559
  const out = {};
@@ -571,11 +584,8 @@ export async function writeReport({ report, outDir, formats = ["html"] }) {
571
584
  let ok = false, err = "";
572
585
  try {
573
586
  const { chromium } = await import("playwright");
574
- const browser = await chromium.launch();
575
- const page = await browser.newPage();
576
- await page.setContent(html, { waitUntil: "networkidle" });
577
- await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "12mm", bottom: "12mm", left: "10mm", right: "10mm" } });
578
- await browser.close(); ok = true;
587
+ await renderPdf({ html, pdfPath, chromium });
588
+ ok = true;
579
589
  } catch (e) {
580
590
  err = e.message;
581
591
  // Fallback: use the chrome-headless-shell binary directly (no `playwright` package).
@@ -67,8 +67,18 @@ const RULE_GROUPS = {
67
67
  function resolveRuleSelection(rules) {
68
68
  if (!rules || rules === "all" || rules === "deep") return RULE_GROUPS.all();
69
69
  if (rules === "core") return RULE_GROUPS.core();
70
- // Comma-separated explicit list.
71
- return rules.split(",").map((s) => s.trim()).filter(Boolean);
70
+ // Comma-separated explicit list. An unknown id used to select nothing, and
71
+ // nothing selected audited nothing and reported PASS.
72
+ const known = RULE_REGISTRY.map((r) => r.id);
73
+ const asked = String(rules).split(",").map((s) => s.trim()).filter(Boolean);
74
+ const unknown = asked.filter((id) => !known.includes(id));
75
+ if (unknown.length > 0) {
76
+ throw new Error(`unknown rule id(s): ${unknown.join(", ")}. Valid values: all, core, deep, or a comma-separated subset of ${known.join(", ")}`);
77
+ }
78
+ if (asked.length === 0) {
79
+ throw new Error(`no rules selected from "${rules}". Valid values: all, core, deep, or a comma-separated subset of ${known.join(", ")}`);
80
+ }
81
+ return asked;
72
82
  }
73
83
 
74
84
  // ---------- Public entry ---------------------------------------------------
@@ -87,6 +97,7 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
87
97
 
88
98
  const selectedIDs = new Set(resolveRuleSelection(rules));
89
99
  const toRun = RULE_REGISTRY.filter((r) => selectedIDs.has(r.id));
100
+ if (toRun.length === 0) throw new Error("no rules selected; an audit that runs nothing cannot pass");
90
101
 
91
102
  const violations = [];
92
103
  const ranIDs = [];
@@ -135,6 +146,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
135
146
  info: violations.filter((v) => v.severity === "info").length,
136
147
  total: violations.length,
137
148
  };
149
+ if (ranIDs.length === 0) {
150
+ throw new Error(`no rule ran: ${skippedIDs.map((s) => `${s.id} (${s.reason})`).join("; ")}`);
151
+ }
138
152
  const verdict = summary.error > 0 ? "FAIL" : summary.warning > 0 ? "WARN" : "PASS";
139
153
 
140
154
  return {
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { execFile } from "child_process";
27
- import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "fs";
27
+ import { existsSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
28
28
  import { join } from "path";
29
29
  import { tmpdir } from "os";
30
30
 
@@ -227,6 +227,15 @@ export function resolveAuth(opts = {}) {
227
227
  * @param {string} [opts.signingStyle] "automatic" | "manual"
228
228
  * @returns {string} plist XML
229
229
  */
230
+ export function xmlEscape(value) {
231
+ return String(value ?? "")
232
+ .replace(/&/g, "&amp;")
233
+ .replace(/</g, "&lt;")
234
+ .replace(/>/g, "&gt;")
235
+ .replace(/"/g, "&quot;")
236
+ .replace(/'/g, "&apos;");
237
+ }
238
+
230
239
  export function buildExportOptionsPlist(opts) {
231
240
  const {
232
241
  method = "app-store-connect",
@@ -236,17 +245,17 @@ export function buildExportOptionsPlist(opts) {
236
245
  signingStyle,
237
246
  } = opts;
238
247
 
239
- const entries = [` <key>method</key>\n <string>${method}</string>`];
248
+ const entries = [` <key>method</key>\n <string>${xmlEscape(method)}</string>`];
240
249
  entries.push(
241
250
  ` <key>uploadSymbols</key>\n <${uploadSymbols ? "true" : "false"}/>`,
242
251
  );
243
- if (teamId) entries.push(` <key>teamID</key>\n <string>${teamId}</string>`);
252
+ if (teamId) entries.push(` <key>teamID</key>\n <string>${xmlEscape(teamId)}</string>`);
244
253
  if (signingStyle) {
245
- entries.push(` <key>signingStyle</key>\n <string>${signingStyle}</string>`);
254
+ entries.push(` <key>signingStyle</key>\n <string>${xmlEscape(signingStyle)}</string>`);
246
255
  }
247
256
  if (provisioningProfiles && Object.keys(provisioningProfiles).length > 0) {
248
257
  const rows = Object.entries(provisioningProfiles)
249
- .map(([bundleId, profile]) => ` <key>${bundleId}</key>\n <string>${profile}</string>`)
258
+ .map(([bundleId, profile]) => ` <key>${xmlEscape(bundleId)}</key>\n <string>${xmlEscape(profile)}</string>`)
250
259
  .join("\n");
251
260
  entries.push(` <key>provisioningProfiles</key>\n <dict>\n${rows}\n </dict>`);
252
261
  }
@@ -263,6 +272,36 @@ export function buildExportOptionsPlist(opts) {
263
272
  ].join("\n");
264
273
  }
265
274
 
275
+ /**
276
+ * The .ipa this export wrote: the newest one in output_dir, and only if it was
277
+ * written after the export started. An older .ipa left by a previous run was
278
+ * reported as this run's result.
279
+ *
280
+ * @param {string} outputDir
281
+ * @param {number} notBefore - epoch ms; files modified earlier are stale
282
+ * @returns {{ipaPath: string|null, stale: string[]}}
283
+ */
284
+ export function pickExportedIpa(outputDir, notBefore) {
285
+ if (!outputDir || !existsSync(outputDir)) return { ipaPath: null, stale: [] };
286
+ const candidates = readdirSync(outputDir)
287
+ .filter((f) => f.endsWith(".ipa"))
288
+ .map((f) => {
289
+ const full = join(outputDir, f);
290
+ try {
291
+ return { full, mtime: statSync(full).mtimeMs };
292
+ } catch {
293
+ return null;
294
+ }
295
+ })
296
+ .filter(Boolean)
297
+ .sort((a, b) => b.mtime - a.mtime);
298
+ const fresh = candidates.find((c) => c.mtime >= notBefore);
299
+ return {
300
+ ipaPath: fresh ? fresh.full : null,
301
+ stale: candidates.filter((c) => c.mtime < notBefore).map((c) => c.full),
302
+ };
303
+ }
304
+
266
305
  /**
267
306
  * Export a .xcarchive to a signed .ipa.
268
307
  *
@@ -305,6 +344,8 @@ export async function exportIpa(opts) {
305
344
  // want that.
306
345
  if (allowProvisioningUpdates) argv.push("-allowProvisioningUpdates");
307
346
 
347
+ // 2s of slack for filesystems with coarse mtime granularity.
348
+ const startedAt = Date.now() - 2000;
308
349
  const { err, stdout, stderr } = await execFileAsync("xcodebuild", argv, {
309
350
  encoding: "utf-8",
310
351
  timeout: timeoutSec * 1000,
@@ -317,13 +358,15 @@ export async function exportIpa(opts) {
317
358
  : stdout;
318
359
 
319
360
  const errors = (log.match(/^.*error:.*$/gim) || []).map((l) => l.trim());
320
- let ipaPath;
321
- if (existsSync(outputDir)) {
322
- const ipa = readdirSync(outputDir).find((f) => f.endsWith(".ipa"));
323
- if (ipa) ipaPath = join(outputDir, ipa);
324
- }
361
+ const picked = pickExportedIpa(outputDir, startedAt);
362
+ const ipaPath = picked.ipaPath || undefined;
325
363
  // xcodebuild can exit 0 and still produce nothing useful.
326
- if (!ipaPath) ok = false;
364
+ if (!ipaPath) {
365
+ ok = false;
366
+ if (picked.stale.length > 0) {
367
+ errors.push(`error: no .ipa was written by this export; ${picked.stale.join(", ")} in output_dir predates this run`);
368
+ }
369
+ }
327
370
 
328
371
  // The plist is consumed by the time xcodebuild returns. Leaving the temp dir
329
372
  // behind would accumulate one per export for the life of the machine; the path
@@ -85,11 +85,12 @@ const MEMINFO_ROWS = [
85
85
  * snapshots of the same flow is the shape a leak takes, where a single
86
86
  * absolute number says almost nothing.
87
87
  *
88
- * NOT VERIFIED ON A DEVICE. Written against the documented output shape on a
89
- * machine with adb installed but no device or emulator to run it against,
90
- * unlike the leaks parser above, which was
91
- * checked against live output. The tests below pin the shape this expects; the
92
- * first real run is the measurement.
88
+ * Verified against a real device: an Android 35 Pixel emulator, after the parser
89
+ * had first been written from documentation alone. That first version returned
90
+ * null for the swap total on every real device, because the documented shape
91
+ * says "TOTAL SWAP (KB):" and the device prints "TOTAL SWAP PSS:". Both are
92
+ * accepted now. It is the clearest argument in this file for running a parser
93
+ * against the thing it parses.
93
94
  *
94
95
  * @param {string} raw
95
96
  * @returns {{measurable: boolean, reason: string|null, pss: object, totalPssKb: number|null,
@@ -118,7 +119,11 @@ export function parseMeminfoOutput(raw) {
118
119
  };
119
120
  const totalPssKb = num(/TOTAL PSS:\s*(\d+)/i);
120
121
  const totalRssKb = num(/TOTAL RSS:\s*(\d+)/i);
121
- const totalSwapKb = num(/TOTAL SWAP \(KB\):\s*(\d+)/i);
122
+ // Two spellings in the wild: "TOTAL SWAP (KB):" in the documented shape and
123
+ // "TOTAL SWAP PSS:" on a real Android 35 device, which is what a Pixel
124
+ // emulator actually printed. The first regex, written from documentation,
125
+ // returned null against every real device.
126
+ const totalSwapKb = num(/TOTAL SWAP(?: \(KB\)| PSS)?:\s*(\d+)/i);
122
127
 
123
128
  if (found === 0 && totalPssKb === null) {
124
129
  return { measurable: false, reason: "no App Summary block in the dumpsys output", pss: {}, totalPssKb: null, totalRssKb: null, totalSwapKb: null };
@@ -127,17 +132,32 @@ export function parseMeminfoOutput(raw) {
127
132
  return { measurable: true, reason: null, pss, totalPssKb, totalRssKb, totalSwapKb };
128
133
  }
129
134
 
135
+ /**
136
+ * Accept both snapshot shapes: the parser's (pss, totalPssKb) and the one the
137
+ * android_meminfo tool emits and hands back as baseline_json (pss_kb,
138
+ * total_pss_kb). The diff read only the parser shape while the tool emitted
139
+ * the other, so mode=diff never compared anything.
140
+ */
141
+ function normalizeSnapshot(s) {
142
+ if (!s || typeof s !== "object") return null;
143
+ const pss = s.pss && typeof s.pss === "object" ? s.pss : s.pss_kb && typeof s.pss_kb === "object" ? s.pss_kb : {};
144
+ const totalPssKb = typeof s.totalPssKb === "number" ? s.totalPssKb : typeof s.total_pss_kb === "number" ? s.total_pss_kb : null;
145
+ return { measurable: s.measurable === true, pss, totalPssKb };
146
+ }
147
+
130
148
  /**
131
149
  * Difference between two meminfo snapshots, in KB.
132
150
  *
133
151
  * Only keys present in both are compared; a key missing from either side is
134
152
  * absent from the result rather than counted as zero growth.
135
153
  *
136
- * @param {object} before - parseMeminfoOutput result
137
- * @param {object} after - parseMeminfoOutput result
154
+ * @param {object} before - parseMeminfoOutput result, or the android_meminfo snapshot payload
155
+ * @param {object} after - parseMeminfoOutput result, or the android_meminfo snapshot payload
138
156
  * @returns {{comparable: boolean, reason: string|null, deltaKb: object, totalPssDeltaKb: number|null}}
139
157
  */
140
- export function diffMeminfo(before, after) {
158
+ export function diffMeminfo(beforeRaw, afterRaw) {
159
+ const before = normalizeSnapshot(beforeRaw);
160
+ const after = normalizeSnapshot(afterRaw);
141
161
  if (!before?.measurable || !after?.measurable) {
142
162
  return { comparable: false, reason: "one of the snapshots was not measurable", deltaKb: {}, totalPssDeltaKb: null };
143
163
  }
@@ -15,7 +15,7 @@
15
15
  // outputSchema answers with JSON the host parses as structuredContent, and
16
16
  // replacing that with a summary would break the parse.
17
17
 
18
- import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from "fs";
18
+ import { writeFileSync, readFileSync, mkdirSync, existsSync, readdirSync, statSync, rmSync } from "fs";
19
19
  import { join, resolve, sep } from "path";
20
20
  import { homedir } from "os";
21
21
 
@@ -31,20 +31,24 @@ const TAIL_LINES = 20;
31
31
  export const OFFLOAD_KEEP_FILES = 50;
32
32
  export const OFFLOAD_KEEP_DAYS = 7;
33
33
 
34
- // `keep` is the file the caller just wrote. It is never deleted, whatever the
35
- // retention numbers say: the returned text promises that path to the caller, and
36
- // this module exists precisely because losing the payload is the failure mode. A
37
- // keepFiles of 0 must bound the directory, not break the answer.
38
- export function pruneOffloadDir(dir, opts = {}) {
39
- const keepFiles = opts.keepFiles ?? OFFLOAD_KEEP_FILES;
40
- const keepDays = opts.keepDays ?? OFFLOAD_KEEP_DAYS;
41
- const now = opts.now ?? Date.now();
42
- const keep = typeof opts.keep === "string" ? resolve(opts.keep) : null;
34
+ // The server's own scratch directory (screenshots, UI dumps, push payloads,
35
+ // build logs, .xcresult bundles) gets the same treatment with a wider count,
36
+ // since a single design audit writes 100+ captures.
37
+ export const WORK_KEEP_ENTRIES = 200;
38
+ export const WORK_KEEP_DAYS = 7;
39
+
40
+ // `keep` names the file(s) the caller was just promised. They are never deleted,
41
+ // whatever the retention numbers say: the returned text promises that path to
42
+ // the caller, and this module exists precisely because losing the payload is the
43
+ // failure mode. A keepFiles of 0 must bound the directory, not break the answer.
44
+ function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
45
+ const keepList = Array.isArray(keep) ? keep : keep ? [keep] : [];
46
+ const keepSet = new Set(keepList.filter((k) => typeof k === "string").map((k) => resolve(k)));
43
47
  const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
44
48
  let removed = 0;
45
49
  try {
46
50
  const entries = readdirSync(dir)
47
- .filter((n) => n.endsWith(".txt"))
51
+ .filter(match)
48
52
  .map((n) => {
49
53
  const full = join(dir, n);
50
54
  try {
@@ -57,12 +61,12 @@ export function pruneOffloadDir(dir, opts = {}) {
57
61
  .sort((a, b) => b.mtime - a.mtime);
58
62
 
59
63
  for (let i = 0; i < entries.length; i++) {
60
- if (keep && resolve(entries[i].full) === keep) continue;
64
+ if (keepSet.has(resolve(entries[i].full))) continue;
61
65
  const tooOld = entries[i].mtime < cutoff;
62
66
  const tooMany = i >= keepFiles;
63
67
  if (!tooOld && !tooMany) continue;
64
68
  try {
65
- unlinkSync(entries[i].full);
69
+ rmSync(entries[i].full, { recursive: true, force: true });
66
70
  removed++;
67
71
  } catch {
68
72
  // A file another process holds open is skipped, not fatal.
@@ -74,6 +78,28 @@ export function pruneOffloadDir(dir, opts = {}) {
74
78
  return removed;
75
79
  }
76
80
 
81
+ export function pruneOffloadDir(dir, opts = {}) {
82
+ return pruneEntries(dir, {
83
+ keepFiles: opts.keepFiles ?? OFFLOAD_KEEP_FILES,
84
+ keepDays: opts.keepDays ?? OFFLOAD_KEEP_DAYS,
85
+ now: opts.now ?? Date.now(),
86
+ keep: opts.keep,
87
+ match: (n) => n.endsWith(".txt"),
88
+ });
89
+ }
90
+
91
+ // Every entry counts here, directories included: an .xcresult bundle is a
92
+ // directory and was the largest thing nothing ever removed.
93
+ export function pruneWorkDir(dir, opts = {}) {
94
+ return pruneEntries(dir, {
95
+ keepFiles: opts.keepFiles ?? WORK_KEEP_ENTRIES,
96
+ keepDays: opts.keepDays ?? WORK_KEEP_DAYS,
97
+ now: opts.now ?? Date.now(),
98
+ keep: opts.keep,
99
+ match: () => true,
100
+ });
101
+ }
102
+
77
103
  let lastOffload = null;
78
104
 
79
105
  export function lastOffloadRecord() {