@mmerterden/multi-agent-toolkit-mcp 3.1.1 → 3.3.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 +53 -0
- package/README.md +4 -3
- package/README.tr.md +3 -3
- package/index.js +102 -1
- package/package.json +3 -3
- package/tools/memory/index.js +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,59 @@ Releases before this file exists are recorded in the git tags and commit history
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## 3.3.0
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `ios_leaks` and `android_meminfo`: the server had 84 tools and not one of them
|
|
23
|
+
looked at memory.
|
|
24
|
+
|
|
25
|
+
`ios_leaks` runs `/usr/bin/leaks` against a simulator or host process, by pid
|
|
26
|
+
or by bundle id. Simulator processes are ordinary host processes, so no
|
|
27
|
+
`simctl spawn` is needed; this was checked against a live simulator rather than
|
|
28
|
+
assumed. `mode: "diff"` reports only what is new since a memory graph saved by
|
|
29
|
+
an earlier call, which is the shape a regression gate wants, where a single
|
|
30
|
+
absolute count says little.
|
|
31
|
+
|
|
32
|
+
`android_meminfo` reads `dumpsys meminfo` (KB, no root) and `mode: "diff"`
|
|
33
|
+
compares two snapshots, because growth across the same flow is the signal a
|
|
34
|
+
leak produces.
|
|
35
|
+
|
|
36
|
+
Both report `measurable: false` with a reason instead of a clean result when
|
|
37
|
+
they could not look: an iOS target without `get-task-allow`, an Android package
|
|
38
|
+
with no running process. `leaks` exits 0 in the unmeasurable case, exactly as
|
|
39
|
+
it does for a clean run, so anything keying off the exit status calls an
|
|
40
|
+
uninspected process leak-free.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `run()` turned every non-zero exit into an `ERROR` string with the command's
|
|
45
|
+
output discarded. `leaks` exits 1 when it FINDS leaks, so the new tool would
|
|
46
|
+
have reported its most important result as "could not measure". `runCapture()`
|
|
47
|
+
returns what the command printed and lets the caller decide what the status
|
|
48
|
+
means. Existing callers are untouched.
|
|
49
|
+
|
|
50
|
+
### Note
|
|
51
|
+
|
|
52
|
+
`android_meminfo`'s parser was written against the documented output shape on a
|
|
53
|
+
machine with no adb and no emulator. The `leaks` parser was written against live
|
|
54
|
+
output captured from real runs, including the leaking and the unmeasurable case.
|
|
55
|
+
The tests pin both shapes; the first real Android run is that half's measurement.
|
|
56
|
+
|
|
57
|
+
## 3.2.0
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- `ios_xcresult` gains `mode: "metrics"`: XCTMetric performance results straight
|
|
62
|
+
out of the `.xcresult` as JSON, via `xcresulttool get test-results metrics`.
|
|
63
|
+
The tool already shelled out to `xcresulttool` for `mode: "tests"`, so this is
|
|
64
|
+
a mode on an existing tool rather than a new integration, and Apple ships the
|
|
65
|
+
output's JSON Schema behind `--schema`, so the shape is documented rather than
|
|
66
|
+
inferred. Optional `test_id` scopes it to one case or suite.
|
|
67
|
+
Metrics exist only inside the bundle - there is no line in the build log to
|
|
68
|
+
fall back to - so a missing `.xcresult` returns an error instead of an empty
|
|
69
|
+
result that would read as "measured, nothing to report".
|
|
70
|
+
|
|
18
71
|
## 3.1.1
|
|
19
72
|
|
|
20
73
|
### Fixed
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
🇹🇷 Türkçe: [README.tr.md](./README.tr.md)
|
|
9
9
|
|
|
10
|
-
**
|
|
10
|
+
**86 tools** for iOS Simulator, Android Emulator, and headless web control. MCP server that lets your AI coding assistant see, interact with, and audit your mobile apps - plus drive browsers, run an 18-rule App Store compliance audit, and orchestrate multi-step batch flows.
|
|
11
11
|
|
|
12
12
|
Distributed on the **public npm registry** - `npx @mmerterden/multi-agent-toolkit-mcp` resolves with no auth, no token, no `~/.npmrc` setup.
|
|
13
13
|
|
|
@@ -17,6 +17,7 @@ That distinction is worth keeping straight. This line once called five hosts "th
|
|
|
17
17
|
|
|
18
18
|
**7 categories:**
|
|
19
19
|
- **Device Control** (59 tools) - screenshot, tap, swipe, type, navigate, dark mode, locale, orientation, location, permissions, push notifications, real start/stop screen recording
|
|
20
|
+
- **Memory** (2 tools) - `ios_leaks` runs /usr/bin/leaks against a simulator or host process, snapshot or diff against a saved memory graph; `android_meminfo` reads dumpsys meminfo, snapshot or diff. Both report `measurable:false` rather than a clean result when they could not look
|
|
20
21
|
- **Crash Diagnostics** (2 tools) - `ios_list_crashes` reads the host's DiagnosticReports, `android_list_crashes` dumps the adb crash buffer, both tail-bounded
|
|
21
22
|
- **Accessibility Audit** (2 tools) - auto-detect missing labels, small tap targets, missing identifiers (scope filtering supported)
|
|
22
23
|
- **Store Compliance** (5 tools) - App Store / Play Store readiness; **18-rule deep `ios_app_store_audit`** cross-references Apple ITMS error codes + App Store Review Guidelines (privacy manifest, required-reason API, Info.plist, code signing, entitlements, embedded SDK, IPv6, debug-tool leak, ...)
|
|
@@ -189,7 +190,7 @@ Run a scripted login flow in one MCP call:
|
|
|
189
190
|
|
|
190
191
|
Returns per-step status so the agent can self-correct on failure.
|
|
191
192
|
|
|
192
|
-
### iOS Tools (
|
|
193
|
+
### iOS Tools (39)
|
|
193
194
|
|
|
194
195
|
| Tool | Description |
|
|
195
196
|
|------|-------------|
|
|
@@ -232,7 +233,7 @@ Returns per-step status so the agent can self-correct on failure.
|
|
|
232
233
|
| `ios_visual_diff` | Compare two PNG screenshots (pixelmatch). Returns diff_pct, pass/fail vs threshold, optional diff PNG. Useful for snapshot regressions across light/dark, locale, dynamic type |
|
|
233
234
|
| `ios_list_crashes` | List recent crash reports from the host's DiagnosticReports - filter by process name, bound by age and count |
|
|
234
235
|
|
|
235
|
-
### Android Tools (
|
|
236
|
+
### Android Tools (31)
|
|
236
237
|
|
|
237
238
|
| Tool | Description |
|
|
238
239
|
|------|-------------|
|
package/README.tr.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
🇬🇧 English: [README.md](./README.md)
|
|
9
9
|
|
|
10
|
-
iOS Simulator, Android Emulator ve headless web kontrolü için **
|
|
10
|
+
iOS Simulator, Android Emulator ve headless web kontrolü için **86 araç**. AI kodlama asistanının mobil uygulamalarını görmesini, onlarla etkileşime girmesini ve denetlemesini sağlayan bir MCP sunucusu - ayrıca tarayıcıları sürer, 18-kurallık bir App Store uyumluluk denetimi çalıştırır ve çok-adımlı batch akışlarını orkestre eder.
|
|
11
11
|
|
|
12
12
|
**Public npm registry** üzerinden dağıtılır - `npx @mmerterden/multi-agent-toolkit-mcp`, auth'suz, token'sız, `~/.npmrc` ayarı gerekmeden çözülür.
|
|
13
13
|
|
|
@@ -189,7 +189,7 @@ Tek bir MCP çağrısında scriptlenmiş bir login akışı çalıştır:
|
|
|
189
189
|
|
|
190
190
|
Agent'ın hata durumunda kendini düzeltebilmesi için adım-başına durum döner.
|
|
191
191
|
|
|
192
|
-
### iOS Araçları (
|
|
192
|
+
### iOS Araçları (39)
|
|
193
193
|
|
|
194
194
|
| Araç | Açıklama |
|
|
195
195
|
|------|-------------|
|
|
@@ -232,7 +232,7 @@ Agent'ın hata durumunda kendini düzeltebilmesi için adım-başına durum dön
|
|
|
232
232
|
| `ios_visual_diff` | İki PNG screenshot'ı karşılaştır (pixelmatch). diff_pct, threshold'a karşı pass/fail, opsiyonel diff PNG döner. Açık/koyu mod, locale, dynamic type genelinde snapshot regresyonları için kullanışlı |
|
|
233
233
|
| `ios_list_crashes` | Host'un DiagnosticReports dizinindeki güncel crash raporlarını listele - süreç adına göre filtrele, yaş ve sayı ile sınırla |
|
|
234
234
|
|
|
235
|
-
### Android Araçları (
|
|
235
|
+
### Android Araçları (31)
|
|
236
236
|
|
|
237
237
|
| Araç | Açıklama |
|
|
238
238
|
|------|-------------|
|
package/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./tools/ios-testflight/index.js";
|
|
30
30
|
import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
|
|
31
31
|
import { parseLaunchOutput } from "./tools/launch-time/index.js";
|
|
32
|
+
import { parseLeaksOutput, parseMeminfoOutput, diffMeminfo } from "./tools/memory/index.js";
|
|
32
33
|
import { interactiveElements } from "./tools/ui-inspect/index.js";
|
|
33
34
|
import { selectCrashReports } from "./tools/crash-logs/index.js";
|
|
34
35
|
import {
|
|
@@ -78,6 +79,25 @@ function run(cmd, opts = {}) {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Run a command whose non-zero exit is a RESULT, not a failure.
|
|
84
|
+
*
|
|
85
|
+
* `leaks` exits 1 when it finds leaks. Through run() that becomes an ERROR
|
|
86
|
+
* string with the report discarded, and a parser reading it sees no summary
|
|
87
|
+
* line and concludes the target could not be measured - which turns "leaks
|
|
88
|
+
* found" into "nothing measured", the exact inversion the tool exists to avoid.
|
|
89
|
+
* Here the command's own output is returned whatever the status, and the
|
|
90
|
+
* caller decides what it means.
|
|
91
|
+
*/
|
|
92
|
+
function runCapture(cmd, opts = {}) {
|
|
93
|
+
try {
|
|
94
|
+
return execSync(cmd, { encoding: "utf-8", timeout: 30000, ...opts }).trim();
|
|
95
|
+
} catch (e) {
|
|
96
|
+
const printed = `${e.stdout || ""}${e.stderr || ""}`.trim();
|
|
97
|
+
return printed || `${ERROR_PREFIX}${truncateError(e.message)}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
81
101
|
// Async twin of run() for tools that hold the child for minutes: execSync would
|
|
82
102
|
// block the event loop, leaving the server unable to answer any other request
|
|
83
103
|
// (or a cancellation) for the whole build. Same contract: trimmed stdout on
|
|
@@ -337,8 +357,9 @@ const IOS_TOOLS = [
|
|
|
337
357
|
{ name: "ios_testflight_validate", description: "Run Apple's own pre-submission validation on an .ipa via `xcrun altool --validate-app`, then map returned ITMS error codes onto the App Store rule each one implies. This is the authoritative gate: unlike the static ios_app_store_audit it can catch an unregistered bundle ID, a profile that does not match the App Store Connect app record, a version+build pair already used, and entitlements not provisioned for the app ID. Auth is a 3-tier chain: ASC API key (api_key_id + api_issuer_id), else Apple ID + app-specific password referenced indirectly through a keychain item or env var (never passed by value), else the gate returns verdict SKIPPED with a reason. SKIPPED is not a pass - it means Apple was never asked. Set list_providers=true for a pre-flight that reports which teams the credentials can deliver for (needed when a corporate Apple ID belongs to several).", inputSchema: { type: "object", properties: { ipa_path: { type: "string", description: "Absolute path to the .ipa" }, platform: { type: "string", description: "ios (default) | appletvos | visionos | macos" }, api_key_id: { type: "string", description: "App Store Connect API key ID (tier 1)" }, api_issuer_id: { type: "string", description: "App Store Connect issuer ID (tier 1)" }, p8_path: { type: "string", description: "Explicit path to AuthKey_<id>.p8; otherwise altool's search dirs are used" }, apple_id: { type: "string", description: "Apple ID (tier 2)" }, keychain_item: { type: "string", description: "Keychain item holding the app-specific password (tier 2, preferred)" }, password_env_var: { type: "string", description: "Env var holding the app-specific password (tier 2 fallback)" }, provider_public_id: { type: "string", description: "Required when the account belongs to multiple providers" }, list_providers: { type: "boolean", description: "Pre-flight only: list deliverable providers and return" }, timeout_sec: { type: "number", description: "Default 900" } }, required: [] } },
|
|
338
358
|
{ name: "ios_app_store_audit", description: "Deep App Store Review compliance audit for .xcarchive bundles. 18-rule catalog covering privacy manifest, code signing, embedded SDKs, entitlements, asset hygiene, IPv6 compliance, debug-tool leak detection, Swift ABI compatibility, SDK floor (ITMS-90725), and more. Returns structured JSON with severity-ranked violations and ITMS error code mappings. Replaces ios_archive_audit (deprecated). Pass rules='core' for the 6 baseline checks (code-signing, entitlements, info-plist, privacy-manifest, binary-size, sdk-floor); 'all'|'deep' for the full 18-rule scan; CSV like 'binary-size,ipv6-compliance' for an explicit subset.", inputSchema: { type: "object", properties: { archive_path: { type: "string", description: "Absolute path to the .xcarchive bundle" }, rules: { type: "string", description: "'all' (default) | 'core' | 'deep' | comma-separated ruleIDs" } }, required: ["archive_path"] } },
|
|
339
359
|
{ name: "ios_xcodebuild", description: "Build / test / clean an Xcode project with progressive disclosure. Returns one-line summary plus xcresult ID; drill in via ios_xcresult. Token-efficient - full log stays out of context unless requested.", inputSchema: { type: "object", properties: { project: { type: "string", description: "Path to .xcodeproj (mutually exclusive with workspace)" }, workspace: { type: "string", description: "Path to .xcworkspace (mutually exclusive with project)" }, scheme: { type: "string" }, configuration: { type: "string", description: "Debug / Release (default: Release)" }, destination: { type: "string", description: "Xcode destination string. Default: generic iOS Simulator" }, action: { type: "string", enum: ["build", "test", "clean", "archive", "clean-build"], description: "Default: build" }, derived_data_path: { type: "string" }, extra_args: { type: "string", description: "Additional raw xcodebuild args appended verbatim" }, timeout_sec: { type: "number", description: "Build timeout in seconds (default 600)" } }, required: ["scheme"] } },
|
|
340
|
-
{ name: "ios_xcresult", description: "Drill into a previous ios_xcodebuild result by xcresult ID. Modes: summary (counts), errors (file:line + message), warnings, log (last N lines), tests (failed). Use this instead of dumping the whole build log into context.", inputSchema: { type: "object", properties: { id: { type: "string", description: "xcresult ID returned by ios_xcodebuild" }, mode: { type: "string", enum: ["summary", "errors", "warnings", "log", "tests"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" } }, required: ["id"] } },
|
|
360
|
+
{ name: "ios_xcresult", description: "Drill into a previous ios_xcodebuild result by xcresult ID. Modes: summary (counts), errors (file:line + message), warnings, log (last N lines), tests (failed), metrics (XCTMetric performance results as JSON). Use this instead of dumping the whole build log into context.", inputSchema: { type: "object", properties: { id: { type: "string", description: "xcresult ID returned by ios_xcodebuild" }, mode: { type: "string", enum: ["summary", "errors", "warnings", "log", "tests", "metrics"], description: "Default: summary" }, log_lines: { type: "number", description: "Lines of raw log to return when mode=log (default 200)" }, test_id: { type: "string", description: "mode=metrics only: scope to one test case or suite instead of every measured test" } }, required: ["id"] } },
|
|
341
361
|
{ name: "ios_visual_diff", description: "Compare two PNG screenshots. Returns JSON with diff_pct, pass/fail vs threshold, and an optional diff image. Use for snapshot regression checks across light/dark, locale, dynamic type variants.", inputSchema: { type: "object", properties: { baseline: { type: "string", description: "Path to baseline PNG" }, current: { type: "string", description: "Path to current PNG" }, threshold: { type: "number", description: "Per-pixel color threshold 0..1 (default 0.1, lower = stricter)" }, max_diff_pct: { type: "number", description: "Fail if diff exceeds this percent (default 1.0)" }, output: { type: "string", description: "Path to write diff PNG (optional)" } }, required: ["baseline", "current"] } },
|
|
362
|
+
{ name: "ios_leaks", description: "Look for leaked memory in a running simulator (or host) process with /usr/bin/leaks. mode=snapshot reports the current leak count and bytes; mode=diff reports only leaks new since a saved memory graph, which is the shape a regression gate wants. Reports measurable:false when the target lacks get-task-allow rather than reporting it as clean - leaks exits 0 in that case, so an unmeasurable target and a clean one are indistinguishable by exit status. Debug builds are debuggable; Apple-signed apps are not.", inputSchema: { type: "object", properties: { pid: { type: "number", description: "Process id. Either this or bundle_id." }, bundle_id: { type: "string", description: "Bundle id of an app running on the booted simulator; its pid is resolved for you." }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_graph: { type: "string", description: "mode=diff: path to the memory graph saved by an earlier call" }, output_graph: { type: "string", description: "Save a memory graph here to use as a later baseline" } }, required: [] } },
|
|
342
363
|
{ name: "ios_list_crashes", description: "List recent crash reports from the host's ~/Library/Logs/DiagnosticReports - where simulator app crashes land. Filter by process name, bound by age and count.", inputSchema: { type: "object", properties: { app: { type: "string", description: "Only reports whose file name (the crashed process) contains this substring" }, since_min: { type: "number", description: "Only reports newer than this many minutes" }, limit: { type: "number", description: "Max reports returned, newest first (default 20)" } } } },
|
|
343
364
|
];
|
|
344
365
|
|
|
@@ -731,6 +752,22 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
731
752
|
const warns = (raw.match(/^.*warning:.*$/gim) || []).map(l => l.trim());
|
|
732
753
|
return JSON.stringify({ id, count: warns.length, warnings: warns }, null, 2);
|
|
733
754
|
}
|
|
755
|
+
if (mode === "metrics") {
|
|
756
|
+
// Performance results live only inside the .xcresult - there is no line
|
|
757
|
+
// in the text log to fall back to - so a missing bundle is an error, not
|
|
758
|
+
// an empty measurement. XCTMetric results land here only when the tests
|
|
759
|
+
// were built with performance diagnostics enabled.
|
|
760
|
+
if (!existsSync(xcresultPath)) {
|
|
761
|
+
return `ERROR: no .xcresult bundle for ${id} at ${xcresultPath}; performance metrics exist only in the bundle, not in the build log`;
|
|
762
|
+
}
|
|
763
|
+
const scope = args.test_id ? ` --test-id ${shq(args.test_id)}` : "";
|
|
764
|
+
const out = run(
|
|
765
|
+
`xcrun xcresulttool get test-results metrics --path ${shq(xcresultPath)}${scope} --compact 2>&1`,
|
|
766
|
+
{ timeout: 20000 },
|
|
767
|
+
);
|
|
768
|
+
if (!out || out.startsWith("ERROR")) return out || `ERROR: xcresulttool returned nothing for ${id}`;
|
|
769
|
+
return out;
|
|
770
|
+
}
|
|
734
771
|
if (mode === "tests") {
|
|
735
772
|
if (existsSync(xcresultPath)) {
|
|
736
773
|
const out = run(`xcrun xcresulttool get test-results tests --path ${shq(xcresultPath)} --format json 2>/dev/null`, { timeout: 20000 });
|
|
@@ -773,6 +810,42 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
773
810
|
const report = JSON.stringify({ passed, diff_pct: parseFloat(diffPct.toFixed(4)), diff_pixels: diffPixels, total_pixels: total, threshold, max_diff_pct: maxPct, baseline: args.baseline, current: args.current, diff_image: diffImagePath }, null, 2);
|
|
774
811
|
return diffImagePath ? fileResult(report, diffImagePath) : report;
|
|
775
812
|
}
|
|
813
|
+
case "ios_leaks": {
|
|
814
|
+
let pid = Number.isInteger(args.pid) ? args.pid : null;
|
|
815
|
+
if (!pid && args.bundle_id) {
|
|
816
|
+
const d = iosDevice(args.device_id);
|
|
817
|
+
const out = run(`xcrun simctl spawn ${d} launchctl list 2>/dev/null | grep ${shq(sanitizeId(args.bundle_id))}`);
|
|
818
|
+
const m = out && out.match(/^(\d+)\s/m);
|
|
819
|
+
if (!m) return `ERROR: no running process for ${args.bundle_id} on the booted simulator; launch it first`;
|
|
820
|
+
pid = parseInt(m[1], 10);
|
|
821
|
+
}
|
|
822
|
+
if (!pid) return "ERROR: pass pid or bundle_id";
|
|
823
|
+
|
|
824
|
+
const mode = args.mode || "snapshot";
|
|
825
|
+
const graphOut = args.output_graph ? ` --outputGraph=${shq(args.output_graph)}` : "";
|
|
826
|
+
if (mode === "diff") {
|
|
827
|
+
if (!args.baseline_graph) return "ERROR: mode=diff needs baseline_graph, the memory graph saved by an earlier call";
|
|
828
|
+
if (!existsSync(args.baseline_graph)) return `ERROR: baseline graph not found at ${args.baseline_graph}`;
|
|
829
|
+
}
|
|
830
|
+
const diffArg = mode === "diff" ? ` --diffFrom=${shq(args.baseline_graph)}` : "";
|
|
831
|
+
// leaks exits 1 when it FINDS leaks, so a non-zero status is a result and
|
|
832
|
+
// not a failure. Everything below is decided from the parsed output.
|
|
833
|
+
const raw = runCapture(`leaks ${pid}${diffArg}${graphOut} 2>&1`, { timeout: 120000 });
|
|
834
|
+
const parsed = parseLeaksOutput(raw);
|
|
835
|
+
return JSON.stringify({
|
|
836
|
+
pid,
|
|
837
|
+
mode,
|
|
838
|
+
measurable: parsed.measurable,
|
|
839
|
+
reason: parsed.reason,
|
|
840
|
+
leak_count: parsed.leakCount,
|
|
841
|
+
leaked_bytes: parsed.leakedBytes,
|
|
842
|
+
nodes_malloced: parsed.nodesMalloced,
|
|
843
|
+
malloced_kb: parsed.mallocedKb,
|
|
844
|
+
root_leaks: parsed.roots.slice(0, 50),
|
|
845
|
+
root_leaks_truncated: parsed.roots.length > 50 ? parsed.roots.length - 50 : 0,
|
|
846
|
+
graph_saved_to: args.output_graph || null,
|
|
847
|
+
}, null, 2);
|
|
848
|
+
}
|
|
776
849
|
case "ios_list_crashes": {
|
|
777
850
|
const dir = join(homedir(), "Library", "Logs", "DiagnosticReports");
|
|
778
851
|
if (!existsSync(dir)) return JSON.stringify({ dir, count: 0, reports: [] }, null, 2);
|
|
@@ -832,6 +905,7 @@ const ANDROID_TOOLS = [
|
|
|
832
905
|
{ name: "android_accessibility_audit", description: "Audit Android app accessibility: missing contentDescription, small touch targets (<48dp), missing resource-id. Use scope to filter by resource-id prefix.", inputSchema: { type: "object", properties: { device_id: { type: "string" }, scope: { type: "string", description: "Filter: only audit elements whose resource-id contains this prefix (e.g. 'login_', 'com.example:id/login_'). Omit to audit all." } } } },
|
|
833
906
|
{ name: "android_launch_time", description: "Measure Android app launch time: force-stops the package, starts it with am start -W, and reports TotalTime/WaitTime in ms plus the platform's own LaunchState (COLD/WARM/HOT). Below Android 10 there is no LaunchState and cold_start is null rather than assumed.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, activity: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
834
907
|
{ name: "android_apk_audit", description: "Audit APK/AAB for Play Store compliance: debug flag, target SDK, permissions, signing, ProGuard", inputSchema: { type: "object", properties: { apk_path: { type: "string", description: "Path to .apk file" } }, required: ["apk_path"] } },
|
|
908
|
+
{ name: "android_meminfo", description: "Read an Android app's memory via `adb shell dumpsys meminfo` (KB, no root). mode=snapshot returns the App Summary rows and totals; mode=diff compares two snapshots so growth across the same flow is visible, which is the signal a leak actually produces - a single absolute number says almost nothing. Reports measurable:false when the package has no running process rather than returning zeros.", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" }, mode: { type: "string", enum: ["snapshot", "diff"], description: "Default: snapshot" }, baseline_json: { type: "string", description: "mode=diff: the JSON returned by an earlier snapshot call" } }, required: ["package_name"] } },
|
|
835
909
|
{ name: "android_list_crashes", description: "Dump the Android crash log buffer (`adb logcat -b crash -d`), tail-bounded. Empty output means no crashes since the buffer was last cleared.", inputSchema: { type: "object", properties: { lines: { type: "number", description: "Max lines returned, from the end (default 200)" }, device_id: { type: "string" } } } },
|
|
836
910
|
{ name: "android_set_orientation", description: "Rotate the Android screen to portrait or landscape. Disables accelerometer rotation and pins user_rotation, so the device stays put until rotation is re-enabled. Accounts for the device's natural orientation (detected via wm size), so landscape-natural tablets rotate correctly too. No iOS counterpart: simctl exposes no rotation lever.", inputSchema: { type: "object", properties: { orientation: { type: "string", enum: ["portrait", "landscape"] }, device_id: { type: "string" } }, required: ["orientation"] } },
|
|
837
911
|
];
|
|
@@ -1081,6 +1155,33 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1081
1155
|
const warnings = findings.filter(f => f.status === "warning").length;
|
|
1082
1156
|
return JSON.stringify({ apk: p, summary: { critical, warnings, passed: findings.filter(f => f.status === "pass").length, total_checks: findings.length }, verdict: critical > 0 ? "FAIL - critical issues must be fixed" : warnings > 0 ? "WARN - review warnings" : "PASS - ready for Play Store", findings }, null, 2);
|
|
1083
1157
|
}
|
|
1158
|
+
case "android_meminfo": {
|
|
1159
|
+
const pkg = sanitizeId(args.package_name);
|
|
1160
|
+
const raw = runCapture(`adb ${df} shell dumpsys meminfo ${pkg} 2>&1`, { timeout: 30000 });
|
|
1161
|
+
const snapshot = parseMeminfoOutput(raw);
|
|
1162
|
+
const mode = args.mode || "snapshot";
|
|
1163
|
+
if (mode === "diff") {
|
|
1164
|
+
if (!args.baseline_json) return "ERROR: mode=diff needs baseline_json, the JSON returned by an earlier snapshot call";
|
|
1165
|
+
let before;
|
|
1166
|
+
try {
|
|
1167
|
+
before = JSON.parse(args.baseline_json);
|
|
1168
|
+
} catch {
|
|
1169
|
+
return "ERROR: baseline_json is not valid JSON";
|
|
1170
|
+
}
|
|
1171
|
+
const d = diffMeminfo(before, snapshot);
|
|
1172
|
+
return JSON.stringify({ package: args.package_name, mode, comparable: d.comparable, reason: d.reason, delta_kb: d.deltaKb, total_pss_delta_kb: d.totalPssDeltaKb, after: snapshot }, null, 2);
|
|
1173
|
+
}
|
|
1174
|
+
return JSON.stringify({
|
|
1175
|
+
package: args.package_name,
|
|
1176
|
+
mode,
|
|
1177
|
+
measurable: snapshot.measurable,
|
|
1178
|
+
reason: snapshot.reason,
|
|
1179
|
+
pss_kb: snapshot.pss,
|
|
1180
|
+
total_pss_kb: snapshot.totalPssKb,
|
|
1181
|
+
total_rss_kb: snapshot.totalRssKb,
|
|
1182
|
+
total_swap_kb: snapshot.totalSwapKb,
|
|
1183
|
+
}, null, 2);
|
|
1184
|
+
}
|
|
1084
1185
|
case "android_list_crashes": {
|
|
1085
1186
|
const lines = args.lines !== undefined ? num(args.lines, "lines") : 200;
|
|
1086
1187
|
const out = run(`adb ${df} logcat -b crash -d | tail -${lines}`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "MCP server for iOS Simulator, Android Emulator and headless web control.
|
|
3
|
+
"version": "3.3.0",
|
|
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",
|
|
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/crash-logs/__tests__/crash-logs.test.mjs tools/launch-time/__tests__/launch-time.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/crash-logs/__tests__/crash-logs.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",
|
|
14
14
|
"gates": "bash scripts/gates.sh"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory - parsers for `leaks` (iOS/macOS) and `dumpsys meminfo` (Android).
|
|
3
|
+
*
|
|
4
|
+
* Lives here rather than inline in index.js because index.js connects its
|
|
5
|
+
* transport at import time and cannot be loaded by a test.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse `leaks <pid>` output.
|
|
10
|
+
*
|
|
11
|
+
* The exit code alone cannot be trusted, verified against leaks on macOS:
|
|
12
|
+
*
|
|
13
|
+
* no leaks exit 0, summary "Process N: 0 leaks for 0 total leaked bytes."
|
|
14
|
+
* leaks found exit 1, summary "Process N: 198 leaks for 811008 total leaked bytes."
|
|
15
|
+
* target not debuggable exit 0, a "not debuggable" line, and NO summary at all
|
|
16
|
+
*
|
|
17
|
+
* So exit 1 is a finding rather than a failure, and exit 0 is either a clean
|
|
18
|
+
* run or a run that never measured anything. A wrapper that reads the exit code
|
|
19
|
+
* reports the third case as clean, which is the worst possible answer: a green
|
|
20
|
+
* result from a process nothing inspected. `measurable` is therefore derived
|
|
21
|
+
* from the presence of the summary line, never from the exit status.
|
|
22
|
+
*
|
|
23
|
+
* A process is not debuggable when it lacks the get-task-allow entitlement.
|
|
24
|
+
* Debug builds carry it; Apple's own signed apps do not.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} raw - stdout+stderr of `leaks`
|
|
27
|
+
* @returns {{measurable: boolean, reason: string|null, leakCount: number|null,
|
|
28
|
+
* leakedBytes: number|null, nodesMalloced: number|null,
|
|
29
|
+
* mallocedKb: number|null, roots: string[]}}
|
|
30
|
+
*/
|
|
31
|
+
export function parseLeaksOutput(raw) {
|
|
32
|
+
const text = typeof raw === "string" ? raw : "";
|
|
33
|
+
|
|
34
|
+
const summary = text.match(/Process\s+\d+:\s+(\d+)\s+leaks?\s+for\s+(\d+)\s+total leaked bytes/);
|
|
35
|
+
const malloced = text.match(/Process\s+\d+:\s+(\d+)\s+nodes malloced for\s+(\d+)\s+KB/);
|
|
36
|
+
|
|
37
|
+
if (!summary) {
|
|
38
|
+
// Order matters: report the specific reason when leaks named one, so the
|
|
39
|
+
// caller can tell "I could not look" from "I looked and found nothing".
|
|
40
|
+
const notDebuggable = /is not debuggable/.test(text);
|
|
41
|
+
const noSuchProcess = /(No such process|Cannot examine process|does not exist)/i.test(text);
|
|
42
|
+
return {
|
|
43
|
+
measurable: false,
|
|
44
|
+
reason: notDebuggable
|
|
45
|
+
? "target is not debuggable (no get-task-allow entitlement); build and run a Debug configuration"
|
|
46
|
+
: noSuchProcess
|
|
47
|
+
? "no such process"
|
|
48
|
+
: "leaks produced no summary line",
|
|
49
|
+
leakCount: null,
|
|
50
|
+
leakedBytes: null,
|
|
51
|
+
nodesMalloced: malloced ? parseInt(malloced[1], 10) : null,
|
|
52
|
+
mallocedKb: malloced ? parseInt(malloced[2], 10) : null,
|
|
53
|
+
roots: [],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const roots = (text.match(/^\s*\d+\s+\([^)]*\)\s+ROOT LEAK:.*$/gm) || []).map((l) => l.trim());
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
measurable: true,
|
|
61
|
+
reason: null,
|
|
62
|
+
leakCount: parseInt(summary[1], 10),
|
|
63
|
+
leakedBytes: parseInt(summary[2], 10),
|
|
64
|
+
nodesMalloced: malloced ? parseInt(malloced[1], 10) : null,
|
|
65
|
+
mallocedKb: malloced ? parseInt(malloced[2], 10) : null,
|
|
66
|
+
roots,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MEMINFO_ROWS = [
|
|
71
|
+
["Java Heap", "javaHeapKb"],
|
|
72
|
+
["Native Heap", "nativeHeapKb"],
|
|
73
|
+
["Code", "codeKb"],
|
|
74
|
+
["Stack", "stackKb"],
|
|
75
|
+
["Graphics", "graphicsKb"],
|
|
76
|
+
["Private Other", "privateOtherKb"],
|
|
77
|
+
["System", "systemKb"],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse the App Summary block of `adb shell dumpsys meminfo <package>`.
|
|
82
|
+
*
|
|
83
|
+
* Every number in meminfo is KB. The App Summary block is the part worth
|
|
84
|
+
* reading for a leak signal: growth in Java Heap or Native Heap across two
|
|
85
|
+
* snapshots of the same flow is the shape a leak takes, where a single
|
|
86
|
+
* absolute number says almost nothing.
|
|
87
|
+
*
|
|
88
|
+
* NOT VERIFIED ON A DEVICE. Written against the documented output shape on a
|
|
89
|
+
* machine with no adb and no emulator, unlike the leaks parser above, which was
|
|
90
|
+
* checked against live output. The tests below pin the shape this expects; the
|
|
91
|
+
* first real run is the measurement.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} raw
|
|
94
|
+
* @returns {{measurable: boolean, reason: string|null, pss: object, totalPssKb: number|null,
|
|
95
|
+
* totalRssKb: number|null, totalSwapKb: number|null}}
|
|
96
|
+
*/
|
|
97
|
+
export function parseMeminfoOutput(raw) {
|
|
98
|
+
const text = typeof raw === "string" ? raw : "";
|
|
99
|
+
|
|
100
|
+
if (/No process found for/i.test(text)) {
|
|
101
|
+
return { measurable: false, reason: "no running process for that package", pss: {}, totalPssKb: null, totalRssKb: null, totalSwapKb: null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pss = {};
|
|
105
|
+
let found = 0;
|
|
106
|
+
for (const [label, key] of MEMINFO_ROWS) {
|
|
107
|
+
const m = text.match(new RegExp(`^\\s*${label}:\\s+(\\d+)`, "mi"));
|
|
108
|
+
if (m) {
|
|
109
|
+
pss[key] = parseInt(m[1], 10);
|
|
110
|
+
found++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const num = (re) => {
|
|
115
|
+
const m = text.match(re);
|
|
116
|
+
return m ? parseInt(m[1], 10) : null;
|
|
117
|
+
};
|
|
118
|
+
const totalPssKb = num(/TOTAL PSS:\s*(\d+)/i);
|
|
119
|
+
const totalRssKb = num(/TOTAL RSS:\s*(\d+)/i);
|
|
120
|
+
const totalSwapKb = num(/TOTAL SWAP \(KB\):\s*(\d+)/i);
|
|
121
|
+
|
|
122
|
+
if (found === 0 && totalPssKb === null) {
|
|
123
|
+
return { measurable: false, reason: "no App Summary block in the dumpsys output", pss: {}, totalPssKb: null, totalRssKb: null, totalSwapKb: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { measurable: true, reason: null, pss, totalPssKb, totalRssKb, totalSwapKb };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Difference between two meminfo snapshots, in KB.
|
|
131
|
+
*
|
|
132
|
+
* Only keys present in both are compared; a key missing from either side is
|
|
133
|
+
* absent from the result rather than counted as zero growth.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} before - parseMeminfoOutput result
|
|
136
|
+
* @param {object} after - parseMeminfoOutput result
|
|
137
|
+
* @returns {{comparable: boolean, reason: string|null, deltaKb: object, totalPssDeltaKb: number|null}}
|
|
138
|
+
*/
|
|
139
|
+
export function diffMeminfo(before, after) {
|
|
140
|
+
if (!before?.measurable || !after?.measurable) {
|
|
141
|
+
return { comparable: false, reason: "one of the snapshots was not measurable", deltaKb: {}, totalPssDeltaKb: null };
|
|
142
|
+
}
|
|
143
|
+
const deltaKb = {};
|
|
144
|
+
for (const key of Object.keys(after.pss)) {
|
|
145
|
+
if (typeof before.pss[key] === "number") deltaKb[key] = after.pss[key] - before.pss[key];
|
|
146
|
+
}
|
|
147
|
+
const totalPssDeltaKb =
|
|
148
|
+
typeof before.totalPssKb === "number" && typeof after.totalPssKb === "number"
|
|
149
|
+
? after.totalPssKb - before.totalPssKb
|
|
150
|
+
: null;
|
|
151
|
+
return { comparable: true, reason: null, deltaKb, totalPssDeltaKb };
|
|
152
|
+
}
|