@mmerterden/multi-agent-toolkit-mcp 3.1.0 → 3.2.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 +33 -0
- package/index.js +32 -5
- package/package.json +2 -2
- package/tools/launch-time/index.js +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,39 @@ Releases before this file exists are recorded in the git tags and commit history
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
+
## 3.2.0
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `ios_xcresult` gains `mode: "metrics"`: XCTMetric performance results straight
|
|
23
|
+
out of the `.xcresult` as JSON, via `xcresulttool get test-results metrics`.
|
|
24
|
+
The tool already shelled out to `xcresulttool` for `mode: "tests"`, so this is
|
|
25
|
+
a mode on an existing tool rather than a new integration, and Apple ships the
|
|
26
|
+
output's JSON Schema behind `--schema`, so the shape is documented rather than
|
|
27
|
+
inferred. Optional `test_id` scopes it to one case or suite.
|
|
28
|
+
Metrics exist only inside the bundle - there is no line in the build log to
|
|
29
|
+
fall back to - so a missing `.xcresult` returns an error instead of an empty
|
|
30
|
+
result that would read as "measured, nothing to report".
|
|
31
|
+
|
|
32
|
+
## 3.1.1
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `android_launch_time` reported `cold_start: true` on every call. It force-stops
|
|
37
|
+
the package first, which kills the process but leaves the page cache warm, so
|
|
38
|
+
the start it measures is cold only some of the time. Android 10 replaced
|
|
39
|
+
`ThisTime` with `LaunchState` (COLD, WARM, HOT, UNKNOWN), which is the
|
|
40
|
+
platform's own verdict on the start it just performed, and the tool was
|
|
41
|
+
ignoring it while asserting the answer itself. The result now carries
|
|
42
|
+
`launch_state` and derives `cold_start` from it. Below Android 10 nothing
|
|
43
|
+
reports a launch state, so `cold_start` is `null` there rather than a claim
|
|
44
|
+
nothing checked. A failed launch now surfaces its `Error:` line instead of
|
|
45
|
+
returning null timings that read like a measurement.
|
|
46
|
+
- The parser moved to `tools/launch-time/` with a suite covering the Android 10+
|
|
47
|
+
and pre-10 output shapes, a warm start, a failed launch and non-string input.
|
|
48
|
+
It could not be tested where it was: `index.js` connects its transport at
|
|
49
|
+
import time, so nothing in it is reachable from a test.
|
|
50
|
+
|
|
18
51
|
## Unreleased
|
|
19
52
|
|
|
20
53
|
## 3.1.0
|
package/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
validateApp,
|
|
29
29
|
} from "./tools/ios-testflight/index.js";
|
|
30
30
|
import { DESIGN_TOOLS, handleDesign } from "./tools/design-check/index.js";
|
|
31
|
+
import { parseLaunchOutput } from "./tools/launch-time/index.js";
|
|
31
32
|
import { interactiveElements } from "./tools/ui-inspect/index.js";
|
|
32
33
|
import { selectCrashReports } from "./tools/crash-logs/index.js";
|
|
33
34
|
import {
|
|
@@ -336,7 +337,7 @@ const IOS_TOOLS = [
|
|
|
336
337
|
{ 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: [] } },
|
|
337
338
|
{ 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"] } },
|
|
338
339
|
{ 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"] } },
|
|
339
|
-
{ 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"] } },
|
|
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), 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"] } },
|
|
340
341
|
{ 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"] } },
|
|
341
342
|
{ 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)" } } } },
|
|
342
343
|
];
|
|
@@ -730,6 +731,22 @@ async function handleIOS(name, args, ctx = {}) {
|
|
|
730
731
|
const warns = (raw.match(/^.*warning:.*$/gim) || []).map(l => l.trim());
|
|
731
732
|
return JSON.stringify({ id, count: warns.length, warnings: warns }, null, 2);
|
|
732
733
|
}
|
|
734
|
+
if (mode === "metrics") {
|
|
735
|
+
// Performance results live only inside the .xcresult - there is no line
|
|
736
|
+
// in the text log to fall back to - so a missing bundle is an error, not
|
|
737
|
+
// an empty measurement. XCTMetric results land here only when the tests
|
|
738
|
+
// were built with performance diagnostics enabled.
|
|
739
|
+
if (!existsSync(xcresultPath)) {
|
|
740
|
+
return `ERROR: no .xcresult bundle for ${id} at ${xcresultPath}; performance metrics exist only in the bundle, not in the build log`;
|
|
741
|
+
}
|
|
742
|
+
const scope = args.test_id ? ` --test-id ${shq(args.test_id)}` : "";
|
|
743
|
+
const out = run(
|
|
744
|
+
`xcrun xcresulttool get test-results metrics --path ${shq(xcresultPath)}${scope} --compact 2>&1`,
|
|
745
|
+
{ timeout: 20000 },
|
|
746
|
+
);
|
|
747
|
+
if (!out || out.startsWith("ERROR")) return out || `ERROR: xcresulttool returned nothing for ${id}`;
|
|
748
|
+
return out;
|
|
749
|
+
}
|
|
733
750
|
if (mode === "tests") {
|
|
734
751
|
if (existsSync(xcresultPath)) {
|
|
735
752
|
const out = run(`xcrun xcresulttool get test-results tests --path ${shq(xcresultPath)} --format json 2>/dev/null`, { timeout: 20000 });
|
|
@@ -829,7 +846,7 @@ const ANDROID_TOOLS = [
|
|
|
829
846
|
{ name: "android_open_url", description: "Open URL or deep link on Android", inputSchema: { type: "object", properties: { url: { type: "string" }, device_id: { type: "string" } }, required: ["url"] } },
|
|
830
847
|
{ name: "android_clear_app_data", description: "Clear all data for Android app", inputSchema: { type: "object", properties: { package_name: { type: "string" }, device_id: { type: "string" } }, required: ["package_name"] } },
|
|
831
848
|
{ 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." } } } },
|
|
832
|
-
{ name: "android_launch_time", description: "Measure Android app
|
|
849
|
+
{ 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"] } },
|
|
833
850
|
{ 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"] } },
|
|
834
851
|
{ 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" } } } },
|
|
835
852
|
{ 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"] } },
|
|
@@ -1020,9 +1037,19 @@ async function handleAndroid(name, args, ctx = {}) {
|
|
|
1020
1037
|
run(`adb ${df} shell am force-stop ${sanitizeId(args.package_name)}`);
|
|
1021
1038
|
const activity = sanitizeId(args.activity || `${args.package_name}/.MainActivity`);
|
|
1022
1039
|
const result = run(`adb ${df} shell am start -W -n ${activity} 2>&1`);
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1040
|
+
const parsed = parseLaunchOutput(result);
|
|
1041
|
+
return JSON.stringify({
|
|
1042
|
+
package: args.package_name,
|
|
1043
|
+
// The platform's own verdict, not ours: force-stop kills the process but
|
|
1044
|
+
// leaves the page cache warm, so this is a cold start only sometimes.
|
|
1045
|
+
// null means the device is below Android 10 and never reported one.
|
|
1046
|
+
launch_state: parsed.launchState,
|
|
1047
|
+
cold_start: parsed.coldStart,
|
|
1048
|
+
total_time_ms: parsed.totalTimeMs,
|
|
1049
|
+
wait_time_ms: parsed.waitTimeMs,
|
|
1050
|
+
error: parsed.error,
|
|
1051
|
+
raw: result,
|
|
1052
|
+
}, null, 2);
|
|
1026
1053
|
}
|
|
1027
1054
|
case "android_apk_audit": {
|
|
1028
1055
|
const p = args.apk_path;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmerterden/multi-agent-toolkit-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "MCP server for iOS Simulator, Android Emulator and headless web control. 84 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",
|
|
@@ -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/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/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,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launch-time - parse `am start -W` output.
|
|
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
|
+
* The handler force-stops the package first, which kills the process but leaves
|
|
8
|
+
* the page cache warm, so it produces a cold start only some of the time. The
|
|
9
|
+
* tool used to report `cold_start: true` unconditionally, which is an assertion
|
|
10
|
+
* about something the platform already measures: Android 10 replaced `ThisTime`
|
|
11
|
+
* with `LaunchState`, one of COLD, WARM, HOT or UNKNOWN, and that is the
|
|
12
|
+
* system's own verdict on the start it just performed. Below Android 10 there is
|
|
13
|
+
* no LaunchState line, and the honest answer there is "unknown" rather than a
|
|
14
|
+
* claim nothing checked.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const LAUNCH_STATES = new Set(["COLD", "WARM", "HOT", "UNKNOWN"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} raw - stdout+stderr of `am start -W -n <activity>`
|
|
21
|
+
* @returns {{status: string|null, launchState: string|null, coldStart: boolean|null,
|
|
22
|
+
* activity: string|null, totalTimeMs: number|null, waitTimeMs: number|null,
|
|
23
|
+
* error: string|null}}
|
|
24
|
+
*/
|
|
25
|
+
export function parseLaunchOutput(raw) {
|
|
26
|
+
const text = typeof raw === "string" ? raw : "";
|
|
27
|
+
const field = (name) => text.match(new RegExp(`^\\s*${name}:\\s*(.+?)\\s*$`, "m"))?.[1] ?? null;
|
|
28
|
+
const intField = (name) => {
|
|
29
|
+
const v = text.match(new RegExp(`^\\s*${name}:\\s*(\\d+)\\s*$`, "m"))?.[1];
|
|
30
|
+
return v === undefined ? null : parseInt(v, 10);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const rawState = field("LaunchState");
|
|
34
|
+
const launchState = rawState && LAUNCH_STATES.has(rawState.toUpperCase())
|
|
35
|
+
? rawState.toUpperCase()
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
// `am start` reports a failure on a line of its own and still exits 0, so a
|
|
39
|
+
// missing TotalTime with an Error line is a failed launch, not a parse miss.
|
|
40
|
+
const errorLine = text.match(/^\s*Error:\s*(.+?)\s*$/m)?.[1] ?? null;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
status: field("Status"),
|
|
44
|
+
launchState,
|
|
45
|
+
// COLD is the only state that is a cold start. Below Android 10 nothing
|
|
46
|
+
// reports it, so null means "not measured", never "no".
|
|
47
|
+
coldStart: launchState === null ? null : launchState === "COLD",
|
|
48
|
+
activity: field("Activity"),
|
|
49
|
+
totalTimeMs: intField("TotalTime"),
|
|
50
|
+
waitTimeMs: intField("WaitTime"),
|
|
51
|
+
error: errorLine,
|
|
52
|
+
};
|
|
53
|
+
}
|