@mmerterden/multi-agent-toolkit-mcp 3.11.0 → 3.13.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-toolkit-mcp",
3
- "version": "3.11.0",
4
- "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 99 tools: device automation (tap/swipe/type), accessibility audits, visual diff, crash logs, App Store / Play Store pre-submission compliance. Runs standalone over stdio with any MCP client.",
3
+ "version": "3.13.0",
4
+ "description": "MCP server for iOS Simulator, Android Emulator and headless web control. 115 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",
7
7
  "bin": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "scripts": {
12
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/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
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/code-intel/__tests__/code-intel.test.mjs tools/pass-kit/__tests__/pass-kit.test.mjs tools/crash-logs/__tests__/crash-logs.test.mjs tools/a11y/__tests__/a11y.test.mjs tools/launch-time/__tests__/launch-time.test.mjs tools/memory/__tests__/memory.test.mjs tools/offload/__tests__/offload.test.mjs tools/context/__tests__/context.test.mjs __tests__/server-tools.test.mjs __tests__/injection.test.mjs",
14
14
  "gates": "bash scripts/gates.sh"
15
15
  },
16
16
  "keywords": [
@@ -73,6 +73,12 @@
73
73
  "CHANGELOG.md",
74
74
  "LICENSE"
75
75
  ],
76
+ "_overridesReadme": "Three advisories reach this tree only through @modelcontextprotocol/sdk, and all three are fixed inside the major their consumer already depends on. The upper bound on fast-uri is load-bearing: ajv@8 declares ^3, and an unbounded >=3.1.6 resolves to 4.x, which is a different major under a dependency that never asked for it.",
77
+ "overrides": {
78
+ "fast-uri": ">=3.1.6 <4",
79
+ "hono": ">=4.13.5 <5",
80
+ "qs": ">=6.16.0 <7"
81
+ },
76
82
  "dependencies": {
77
83
  "@modelcontextprotocol/sdk": "^1.30.0",
78
84
  "pixelmatch": "^7.2.0",
@@ -0,0 +1,175 @@
1
+ // Full-text index over offloaded payloads.
2
+ //
3
+ // The offload module already writes a large result to a file and hands back a
4
+ // head+tail window plus the path, and `agent_query_output` greps that file.
5
+ // Grep answers "which lines contain this string"; it cannot answer "which part
6
+ // of this is about X", and it has no notion of one passage being a better match
7
+ // than another. For a 40k-line log that is the difference between reading the
8
+ // right 60 lines and reading the first 60 that happen to contain the word.
9
+ //
10
+ // So the same file is also indexed: chunked, stored in SQLite FTS5, and ranked
11
+ // with bm25(). node:sqlite ships with Node, so this costs no dependency.
12
+ //
13
+ // Three layers, cheapest first:
14
+ // context_search ranked snippets, ~50-100 tokens, enough to decide
15
+ // context_get one chunk in full, by the id search returned
16
+ // the file path everything, which is what the caller already had
17
+ //
18
+ // agent_query_output is untouched. It is in the cross-CLI contract and removing
19
+ // it would be a breaking change; this sits beside it.
20
+
21
+ import { DatabaseSync } from "node:sqlite";
22
+ import { createHash } from "node:crypto";
23
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
24
+ import { dirname, join } from "node:path";
25
+ import { homedir } from "node:os";
26
+
27
+ // Overridable so a gate can index a fixture corpus without writing into the
28
+ // caller's real index, and so the ranking it asserts is over a known set rather
29
+ // than over whatever that machine happened to offload last.
30
+ export const INDEX_DIR =
31
+ process.env.MCP_TOOLKIT_INDEX_DIR ||
32
+ join(homedir(), ".claude", "logs", "multi-agent-toolkit", ".index");
33
+
34
+ // Chunks are lines, not bytes: a log line cut in half matches nothing and reads
35
+ // as nonsense. 40 lines is small enough that a hit points at a place and large
36
+ // enough to carry its own context.
37
+ export const CHUNK_LINES = 40;
38
+ export const CHUNK_OVERLAP = 8;
39
+
40
+ function dbPathFor(dir) {
41
+ return join(dir, "context.db");
42
+ }
43
+
44
+ export function openIndex(dir = INDEX_DIR) {
45
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
46
+ const db = new DatabaseSync(dbPathFor(dir));
47
+ db.exec(`
48
+ create table if not exists docs (
49
+ id integer primary key,
50
+ path text not null,
51
+ mtime integer not null,
52
+ size integer not null,
53
+ unique(path)
54
+ );
55
+ create virtual table if not exists chunks using fts5(
56
+ body,
57
+ doc_id unindexed,
58
+ first_line unindexed,
59
+ last_line unindexed
60
+ );
61
+ `);
62
+ return db;
63
+ }
64
+
65
+ export function chunk(text, lines = CHUNK_LINES, overlap = CHUNK_OVERLAP) {
66
+ const all = String(text).split("\n");
67
+ const out = [];
68
+ const step = Math.max(1, lines - overlap);
69
+ for (let start = 0; start < all.length; start += step) {
70
+ const slice = all.slice(start, start + lines);
71
+ if (!slice.join("").trim()) continue;
72
+ out.push({ body: slice.join("\n"), firstLine: start + 1, lastLine: start + slice.length });
73
+ if (start + lines >= all.length) break;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ // Re-indexing an unchanged file is wasted work AND duplicate hits, so the
79
+ // (mtime, size) pair decides. It is not a hash: hashing a 40 MB log to learn
80
+ // nothing changed costs more than the indexing it saves.
81
+ export function indexFile(db, path) {
82
+ if (!existsSync(path)) return { indexed: false, reason: "no such file" };
83
+ const st = statSync(path);
84
+ const existing = db.prepare("select id, mtime, size from docs where path = ?").get(path);
85
+ if (existing && existing.mtime === Math.floor(st.mtimeMs) && existing.size === st.size) {
86
+ const n = db.prepare("select count(*) as n from chunks where doc_id = ?").get(existing.id);
87
+ return { indexed: false, reason: "unchanged", docId: existing.id, chunks: n?.n ?? 0 };
88
+ }
89
+ if (existing) {
90
+ db.prepare("delete from chunks where doc_id = ?").run(existing.id);
91
+ db.prepare("delete from docs where id = ?").run(existing.id);
92
+ }
93
+ db.prepare("insert into docs (path, mtime, size) values (?, ?, ?)").run(
94
+ path,
95
+ Math.floor(st.mtimeMs),
96
+ st.size,
97
+ );
98
+ const docId = db.prepare("select id from docs where path = ?").get(path).id;
99
+ const insert = db.prepare(
100
+ "insert into chunks (body, doc_id, first_line, last_line) values (?, ?, ?, ?)",
101
+ );
102
+ const pieces = chunk(readFileSync(path, "utf8"));
103
+ for (const p of pieces) insert.run(p.body, docId, p.firstLine, p.lastLine);
104
+ return { indexed: true, docId, chunks: pieces.length };
105
+ }
106
+
107
+ // FTS5 treats a bare `-` or an unbalanced quote as syntax, and a caller typing
108
+ // a log line into the query gets a SQL error instead of results. Quoting each
109
+ // term makes every query a literal phrase search, which is what a caller
110
+ // searching a log actually means.
111
+ export function toMatchQuery(query) {
112
+ const terms = String(query)
113
+ .split(/\s+/)
114
+ .map((t) => t.replace(/"/g, ""))
115
+ .filter(Boolean);
116
+ if (!terms.length) return null;
117
+ return terms.map((t) => `"${t}"`).join(" ");
118
+ }
119
+
120
+ export function search(db, query, { limit = 5, path = null } = {}) {
121
+ const match = toMatchQuery(query);
122
+ if (!match) return [];
123
+ const where = path
124
+ ? "where chunks match ? and doc_id = (select id from docs where path = ?)"
125
+ : "where chunks match ?";
126
+ const params = path ? [match, path] : [match];
127
+ const rows = db
128
+ .prepare(
129
+ `select chunks.rowid, doc_id, first_line, last_line, body,
130
+ (select path from docs where docs.id = chunks.doc_id) as path,
131
+ bm25(chunks) as score
132
+ from chunks ${where} order by score limit ?`,
133
+ )
134
+ .all(...params, limit);
135
+ return rows.map((r) => ({
136
+ id: r.rowid,
137
+ docId: r.doc_id,
138
+ path: r.path,
139
+ firstLine: r.first_line,
140
+ lastLine: r.last_line,
141
+ score: r.score,
142
+ snippet: snippetOf(r.body, query),
143
+ }));
144
+ }
145
+
146
+ // A window around the first matching line, not the head of the chunk: the
147
+ // caller is deciding whether to open this, and the head of a 40-line chunk
148
+ // often says nothing about why it matched.
149
+ export function snippetOf(body, query, radius = 2) {
150
+ const lines = String(body).split("\n");
151
+ const needles = String(query).toLowerCase().split(/\s+/).filter(Boolean);
152
+ let hit = lines.findIndex((l) => needles.some((n) => l.toLowerCase().includes(n)));
153
+ if (hit < 0) hit = 0;
154
+ const from = Math.max(0, hit - radius);
155
+ return lines.slice(from, hit + radius + 1).join("\n");
156
+ }
157
+
158
+ export function getChunk(db, id) {
159
+ const row = db
160
+ .prepare(
161
+ `select c.rowid as id, c.body, c.first_line, c.last_line, d.path
162
+ from chunks c join docs d on d.id = c.doc_id where c.rowid = ?`,
163
+ )
164
+ .get(Number(id));
165
+ return row ?? null;
166
+ }
167
+
168
+ export function docPathId(path) {
169
+ return createHash("sha256").update(String(path)).digest("hex").slice(0, 12);
170
+ }
171
+
172
+ export function ensureParent(path) {
173
+ const parent = dirname(path);
174
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
175
+ }
@@ -3,14 +3,22 @@
3
3
  * 18 rules consume. Direct port of XCArchiveParser.swift +
4
4
  * InfoPlistParser.swift + PrivacyManifestParser.swift + EntitlementParser.swift.
5
5
  *
6
- * Plist parsing is delegated to `plutil -convert json` (system binary on
7
- * macOS, ships with the OS) - more reliable than any Node `plist` package
8
- * across the long tail of Apple plist quirks (binary plists, XML-with-DTD,
9
- * mixed array/dict roots).
6
+ * Plist parsing prefers `plutil -convert json` (system binary on macOS, ships
7
+ * with the OS) - more reliable than any Node `plist` package across the long
8
+ * tail of Apple plist quirks (binary plists, XML-with-DTD, mixed array/dict
9
+ * roots).
10
+ *
11
+ * When plutil is absent it falls back to a small XML reader, and when even that
12
+ * cannot apply - a binary plist off macOS - it records the miss rather than
13
+ * returning an empty object. That distinction is the whole point: every rule
14
+ * reads `infoPlist`, so a silent `{}` makes an unreadable archive audit exactly
15
+ * like a compliant one, and the tool answers PASS because it saw nothing. It
16
+ * shipped that way; a run with no plutil on PATH reported zero violations on a
17
+ * deliberately non-compliant fixture.
10
18
  */
11
19
 
12
20
  import { execSync } from "child_process";
13
- import { existsSync, readdirSync, statSync } from "fs";
21
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
14
22
  import { shq } from "./models.js";
15
23
  import { join, basename } from "path";
16
24
 
@@ -23,16 +31,159 @@ function run(cmd) {
23
31
  }
24
32
  }
25
33
 
26
- /** Parse a binary or XML plist file into a JS object via `plutil`. */
34
+ /** True when the `plutil` binary is on PATH. Probed once. */
35
+ let _hasPlutil = null;
36
+ export function hasPlutil() {
37
+ if (_hasPlutil === null) _hasPlutil = run("command -v plutil") !== "";
38
+ return _hasPlutil;
39
+ }
40
+
41
+ /**
42
+ * Plists this process could not read, as absolute paths. A rule that finds
43
+ * nothing in an unread plist is not evidence of compliance, and runAudit turns
44
+ * a non-empty list into a finding rather than letting the verdict stand.
45
+ */
46
+ export const unreadablePlists = new Set();
47
+
48
+ /**
49
+ * Empty the record. runAudit calls this before it parses anything, so the set
50
+ * describes the run being reported and not every run this process has done.
51
+ */
52
+ export function resetUnreadablePlists() {
53
+ unreadablePlists.clear();
54
+ }
55
+
56
+ /** Decode the XML entities a plist can carry. */
57
+ function unescapeXml(s) {
58
+ return s
59
+ .replace(/&lt;/g, "<")
60
+ .replace(/&gt;/g, ">")
61
+ .replace(/&quot;/g, '"')
62
+ .replace(/&apos;/g, "'")
63
+ .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
64
+ .replace(/&amp;/g, "&");
65
+ }
66
+
67
+ /**
68
+ * Minimal XML-plist reader for hosts without plutil.
69
+ *
70
+ * Deliberately small: it handles the element set an Info.plist, an entitlements
71
+ * file and a PrivacyInfo.xcprivacy actually use. It is not a general plist
72
+ * implementation and makes no attempt at binary plists - those return null and
73
+ * are recorded as unreadable, which is the honest answer.
74
+ *
75
+ * @param {string} xml
76
+ * @returns {object|Array|null}
77
+ */
78
+ export function parseXmlPlist(xml) {
79
+ // <!DOCTYPE ...> and <?xml ...?> carry nothing we need and complicate the scan.
80
+ const body = xml.replace(/<\?xml[^>]*\?>/g, "").replace(/<!DOCTYPE[^>]*>/g, "");
81
+ const tokens = body.match(/<\/?[A-Za-z]+(?:\s[^>]*)?\/?>|[^<]+/g);
82
+ if (!tokens) return null;
83
+ let i = 0;
84
+
85
+ const next = () => tokens[i++];
86
+ const peek = () => tokens[i];
87
+
88
+ function readValue(tag) {
89
+ switch (tag) {
90
+ case "dict": {
91
+ const out = {};
92
+ for (;;) {
93
+ const t = peek();
94
+ if (t === undefined || t === "</dict>") {
95
+ next();
96
+ return out;
97
+ }
98
+ if (t === "<key>") {
99
+ next();
100
+ const k = unescapeXml(String(next() ?? "").trim());
101
+ next(); // </key>
102
+ const vt = String(next() ?? "").trim();
103
+ out[k] = readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
104
+ } else {
105
+ next();
106
+ }
107
+ }
108
+ }
109
+ case "array": {
110
+ const out = [];
111
+ for (;;) {
112
+ const t = peek();
113
+ if (t === undefined || t === "</array>") {
114
+ next();
115
+ return out;
116
+ }
117
+ const vt = String(next() ?? "").trim();
118
+ if (!vt.startsWith("<")) continue;
119
+ out.push(readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]));
120
+ }
121
+ }
122
+ case "string":
123
+ case "data":
124
+ case "date": {
125
+ const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "";
126
+ if (String(peek() ?? "").startsWith("</")) next();
127
+ return unescapeXml(raw);
128
+ }
129
+ case "integer":
130
+ case "real": {
131
+ const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "0";
132
+ if (String(peek() ?? "").startsWith("</")) next();
133
+ return Number(raw.trim());
134
+ }
135
+ case "true":
136
+ return true;
137
+ case "false":
138
+ return false;
139
+ default:
140
+ return null;
141
+ }
142
+ }
143
+
144
+ for (;;) {
145
+ const t = next();
146
+ if (t === undefined) return null;
147
+ const s = String(t).trim();
148
+ if (!s.startsWith("<plist")) continue;
149
+ for (;;) {
150
+ const u = next();
151
+ if (u === undefined) return null;
152
+ const v = String(u).trim();
153
+ if (!v.startsWith("<")) continue;
154
+ return readValue(v.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
155
+ }
156
+ }
157
+ }
158
+
159
+ /** Parse a binary or XML plist file into a JS object. */
27
160
  export function parsePlist(plistPath) {
28
161
  if (!existsSync(plistPath)) return null;
29
- const json = run(`plutil -convert json -o - ${shq(plistPath)} 2>/dev/null`);
30
- if (!json) return null;
162
+ if (hasPlutil()) {
163
+ const json = run(`plutil -convert json -o - ${shq(plistPath)} 2>/dev/null`);
164
+ if (json) {
165
+ try {
166
+ return JSON.parse(json);
167
+ } catch {
168
+ /* fall through to the XML reader */
169
+ }
170
+ }
171
+ }
172
+ let raw;
31
173
  try {
32
- return JSON.parse(json);
174
+ raw = readFileSync(plistPath, "utf-8");
33
175
  } catch {
176
+ unreadablePlists.add(plistPath);
177
+ return null;
178
+ }
179
+ // A binary plist starts with the magic "bplist"; there is no reading it here.
180
+ if (raw.startsWith("bplist")) {
181
+ unreadablePlists.add(plistPath);
34
182
  return null;
35
183
  }
184
+ const parsed = parseXmlPlist(raw);
185
+ if (parsed === null) unreadablePlists.add(plistPath);
186
+ return parsed;
36
187
  }
37
188
 
38
189
  /** Recursively measure total size of a directory in bytes. */
@@ -21,7 +21,7 @@
21
21
  * the rest of the scan.
22
22
  */
23
23
 
24
- import { parseArchive } from "./context.js";
24
+ import { hasPlutil, parseArchive, resetUnreadablePlists, unreadablePlists } from "./context.js";
25
25
  import { compareSeverity } from "./models.js";
26
26
 
27
27
  // ---------- Rule registry --------------------------------------------------
@@ -93,6 +93,7 @@ function resolveRuleSelection(rules) {
93
93
  * @returns {Promise<object>} structured audit report
94
94
  */
95
95
  export async function runAudit({ archivePath, rules = "all", options = {} } = {}) {
96
+ resetUnreadablePlists();
96
97
  const ctx = parseArchive(archivePath);
97
98
 
98
99
  const selectedIDs = new Set(resolveRuleSelection(rules));
@@ -134,6 +135,25 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
134
135
  }
135
136
  }
136
137
 
138
+ // A rule that found nothing in a plist nobody could read has not measured
139
+ // anything, and the audit must not let that reach the caller as compliance.
140
+ // Every rule reads infoPlist, so one unread plist can silence all eighteen:
141
+ // the tool answered PASS on a deliberately non-compliant fixture the first
142
+ // time it ran on a host without plutil. Reported as an error so the verdict
143
+ // is not PASS, with the cause named - this is the tool's reach, not the
144
+ // archive's fault.
145
+ const unreadable = [...unreadablePlists].sort();
146
+ if (unreadable.length > 0) {
147
+ violations.push({
148
+ ruleID: "archive-readable",
149
+ severity: "error",
150
+ message: `${unreadable.length} plist(s) could not be read, so the rules below them measured nothing: ${unreadable.join(", ")}`,
151
+ suggestion: hasPlutil()
152
+ ? "The files are present but neither plutil nor the XML reader could parse them. Re-export the archive, or open an issue with one of the files attached."
153
+ : "Binary plists need `plutil`, which ships with macOS. Run this audit on macOS, or convert the archive's plists to XML first (`plutil -convert xml1`).",
154
+ });
155
+ }
156
+
137
157
  // Sort: error → warning → info, then by ruleID alphabetically inside each.
138
158
  violations.sort((a, b) => {
139
159
  const c = compareSeverity(b.severity, a.severity);
@@ -156,6 +176,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
156
176
  app: ctx.appName,
157
177
  rulesRun: ranIDs,
158
178
  rulesSkipped: skippedIDs,
179
+ // Explicit, so a caller can tell "clean" from "could not look".
180
+ measurable: unreadable.length === 0,
181
+ unreadablePlists: unreadable,
159
182
  summary,
160
183
  verdict,
161
184
  violations,
@@ -58,15 +58,35 @@ function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
58
58
  }
59
59
  })
60
60
  .filter(Boolean)
61
- .sort((a, b) => b.mtime - a.mtime);
62
-
63
- for (let i = 0; i < entries.length; i++) {
64
- if (keepSet.has(resolve(entries[i].full))) continue;
65
- const tooOld = entries[i].mtime < cutoff;
66
- const tooMany = i >= keepFiles;
67
- if (!tooOld && !tooMany) continue;
61
+ // Name as the tie-break, because mtime ties are not a corner case. APFS
62
+ // records nanoseconds, so five files written in a loop have five distinct
63
+ // stamps; ext4 on a CI runner records one, so they have the same stamp and
64
+ // "the newest" is whatever order readdir happened to return. Without this
65
+ // the retained set differs per filesystem.
66
+ .sort((a, b) => b.mtime - a.mtime || (a.full < b.full ? 1 : a.full > b.full ? -1 : 0));
67
+
68
+ // keepFiles is the size of the directory after the prune, not the size of
69
+ // the part of it retention got to decide. A promised file is one of the
70
+ // survivors, so it takes one of the slots - reserved up front rather than
71
+ // counted as the loop reaches it, because otherwise the answer depends on
72
+ // where that file happens to sort. It used to be neither: a keepSet entry
73
+ // was skipped without consuming anything, so keepFiles: 1 plus one promised
74
+ // file left TWO files behind. On APFS the promised file is genuinely the
75
+ // newest and sorted first, which hid it; on a filesystem whose mtimes tie it
76
+ // sorted anywhere and the directory kept a stale file forever.
77
+ const reserved = entries.filter((e) => keepSet.has(resolve(e.full))).length;
78
+ const budget = Math.max(0, keepFiles - reserved);
79
+ let kept = 0;
80
+ for (const entry of entries) {
81
+ if (keepSet.has(resolve(entry.full))) continue;
82
+ const tooOld = entry.mtime < cutoff;
83
+ const tooMany = kept >= budget;
84
+ if (!tooOld && !tooMany) {
85
+ kept++;
86
+ continue;
87
+ }
68
88
  try {
69
- rmSync(entries[i].full, { recursive: true, force: true });
89
+ rmSync(entry.full, { recursive: true, force: true });
70
90
  removed++;
71
91
  } catch {
72
92
  // A file another process holds open is skipped, not fatal.