@mobileaidev/ai-app-bridge 0.2.4 → 0.2.5
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/README.md +21 -0
- package/bin/ai-app-bridge.js +49 -24
- package/bin/mcp-server.js +226 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,6 +27,27 @@ session start. Set `AI_APP_BRIDGE_MCP_SURFACE=full` before launching
|
|
|
27
27
|
`ai-app-bridge-mcp` only when a client needs the legacy one-tool-per-command
|
|
28
28
|
surface.
|
|
29
29
|
|
|
30
|
+
For multi-step app automation, call `run` with `command: "batch"`. Batch steps
|
|
31
|
+
run serially in one MCP call, so a failed step can stop and mark the remaining
|
|
32
|
+
steps as skipped without mixing results from different commands:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"command": "batch",
|
|
37
|
+
"arguments": {
|
|
38
|
+
"defaults": {
|
|
39
|
+
"packageName": "io.github.mobileaidev.aiappbridge.sample"
|
|
40
|
+
},
|
|
41
|
+
"steps": [
|
|
42
|
+
{ "id": "launch", "command": "launch-app" },
|
|
43
|
+
{ "id": "wait-home", "command": "wait-text", "arguments": { "targetText": "Home" } },
|
|
44
|
+
{ "id": "capture-logs", "command": "logs", "arguments": { "limit": 20 } }
|
|
45
|
+
],
|
|
46
|
+
"stopOnError": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
30
51
|
WebView network and console capture use Android WebView DevTools/CDP when the
|
|
31
52
|
target app is debuggable and WebView debugging is enabled.
|
|
32
53
|
|
package/bin/ai-app-bridge.js
CHANGED
|
@@ -976,6 +976,13 @@ function normalizeBridgeError(error) {
|
|
|
976
976
|
suggestion: 'Check the device state and retry; if the bridge port is known, pass --port to skip package port discovery.',
|
|
977
977
|
};
|
|
978
978
|
}
|
|
979
|
+
if (error?.aiAppBridgePackageMismatch || lower.includes('bridge package mismatch')) {
|
|
980
|
+
return {
|
|
981
|
+
code: 'bridge_package_mismatch',
|
|
982
|
+
message,
|
|
983
|
+
suggestion: 'The resolved bridge port belongs to another package. Relaunch the target app and retry with the explicit packageName.',
|
|
984
|
+
};
|
|
985
|
+
}
|
|
979
986
|
if (error?.aiAppBridgePortDiscovery || lower.includes('bridge port discovery failed') || lower.includes('run-as') || lower.includes('package not found')) {
|
|
980
987
|
return {
|
|
981
988
|
code: 'bridge_port_discovery_failed',
|
|
@@ -1001,21 +1008,38 @@ function firstErrorLine(error) {
|
|
|
1001
1008
|
return String(error?.message || error || 'unknown_error').split(/\r?\n/).find(Boolean) || 'unknown_error';
|
|
1002
1009
|
}
|
|
1003
1010
|
|
|
1004
|
-
async function bridgeGet(ctx, requestPath) {
|
|
1005
|
-
await ensureForward(ctx);
|
|
1006
|
-
const body = await httpGet(bridgeUrl(ctx, requestPath));
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
return
|
|
1018
|
-
}
|
|
1011
|
+
async function bridgeGet(ctx, requestPath) {
|
|
1012
|
+
await ensureForward(ctx);
|
|
1013
|
+
const body = await httpGet(bridgeUrl(ctx, requestPath));
|
|
1014
|
+
const payload = JSON.parse(body);
|
|
1015
|
+
verifyBridgeTargetPackage(ctx, payload, requestPath);
|
|
1016
|
+
return payload;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function bridgePost(ctx, requestPath, payload) {
|
|
1020
|
+
await ensureForward(ctx);
|
|
1021
|
+
const body = await httpPost(bridgeUrl(ctx, requestPath), payload);
|
|
1022
|
+
const responsePayload = JSON.parse(body);
|
|
1023
|
+
verifyBridgeTargetPackage(ctx, responsePayload, requestPath);
|
|
1024
|
+
return responsePayload;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function bridgeUrl(ctx, requestPath) {
|
|
1028
|
+
return `http://127.0.0.1:${ctx.hostPort || ctx.port}${requestPath}`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function verifyBridgeTargetPackage(ctx, payload, requestPath) {
|
|
1032
|
+
if (!ctx.explicitPackageName || !payload || typeof payload !== 'object') return;
|
|
1033
|
+
const responsePackageName = payload.app && typeof payload.app === 'object'
|
|
1034
|
+
? payload.app.packageName
|
|
1035
|
+
: undefined;
|
|
1036
|
+
if (!responsePackageName || responsePackageName === ctx.packageName) return;
|
|
1037
|
+
const error = new Error(`bridge package mismatch for ${requestPath}: expected ${ctx.packageName}, got ${responsePackageName}`);
|
|
1038
|
+
error.aiAppBridgePackageMismatch = true;
|
|
1039
|
+
error.expectedPackageName = ctx.packageName;
|
|
1040
|
+
error.actualPackageName = responsePackageName;
|
|
1041
|
+
throw error;
|
|
1042
|
+
}
|
|
1019
1043
|
|
|
1020
1044
|
function httpGet(url) {
|
|
1021
1045
|
return new Promise((resolve, reject) => {
|
|
@@ -4311,16 +4335,17 @@ module.exports = {
|
|
|
4311
4335
|
parseForegroundWindow,
|
|
4312
4336
|
chooseWebViewDevToolsSocket,
|
|
4313
4337
|
chooseWebViewPage,
|
|
4314
|
-
shapeNetworkCapture,
|
|
4315
|
-
compactNetworkRecord,
|
|
4316
|
-
pruneGeneratedArtifacts,
|
|
4317
|
-
shouldSkipInstallerTapForInstalledPackage,
|
|
4338
|
+
shapeNetworkCapture,
|
|
4339
|
+
compactNetworkRecord,
|
|
4340
|
+
pruneGeneratedArtifacts,
|
|
4341
|
+
shouldSkipInstallerTapForInstalledPackage,
|
|
4318
4342
|
shouldDismissKeyboardForPoint,
|
|
4319
4343
|
shouldUseDefaultPortFallback,
|
|
4320
4344
|
screenshotOutputPath,
|
|
4321
|
-
statusSearchText,
|
|
4322
|
-
uiautomatorLockPath,
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4345
|
+
statusSearchText,
|
|
4346
|
+
uiautomatorLockPath,
|
|
4347
|
+
verifyBridgeTargetPackage,
|
|
4348
|
+
waitTextConditionsMet,
|
|
4349
|
+
withFileLock,
|
|
4350
|
+
};
|
|
4326
4351
|
|
package/bin/mcp-server.js
CHANGED
|
@@ -446,6 +446,7 @@ const commandDefinitions = [
|
|
|
446
446
|
{ command: 'webview-console', domain: 'webview', summary: 'Capture WebView console/log events through CDP.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'durationMs', 'script', 'maxEvents'] },
|
|
447
447
|
{ command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
|
|
448
448
|
{ command: 'remove-forward', domain: 'advanced', summary: 'Remove the ADB port forward for the bridge.', options: ['serial', 'port'] },
|
|
449
|
+
{ command: 'batch', domain: 'advanced', summary: 'Run multiple AI App Bridge commands serially in one MCP call.', options: ['defaults', 'steps', 'stopOnError', 'includeRaw', 'maxRawChars'] },
|
|
449
450
|
{ command: 'smoke', domain: 'diagnostics', summary: 'Run the native sample smoke test.', options: ['serial', 'packageName', 'outFile', 'artifactDir', 'skipFlutterLaunch'] },
|
|
450
451
|
];
|
|
451
452
|
|
|
@@ -552,6 +553,9 @@ async function runGeneric(args = {}) {
|
|
|
552
553
|
commandArgs[key] = args[key];
|
|
553
554
|
}
|
|
554
555
|
}
|
|
556
|
+
if (command === 'batch') {
|
|
557
|
+
return runBatch(commandArgs);
|
|
558
|
+
}
|
|
555
559
|
return runBridgeChecked(command, commandArgs);
|
|
556
560
|
}
|
|
557
561
|
|
|
@@ -566,11 +570,231 @@ function runBridgeChecked(command, args = {}) {
|
|
|
566
570
|
}
|
|
567
571
|
return runBridge(command, args);
|
|
568
572
|
}
|
|
569
|
-
|
|
573
|
+
|
|
574
|
+
async function runBatch(args = {}, runner = runBridgeChecked) {
|
|
575
|
+
const startedAtMs = Date.now();
|
|
576
|
+
const mode = args.mode ? String(args.mode) : 'serial';
|
|
577
|
+
if (mode !== 'serial') {
|
|
578
|
+
return toolJson({ ok: false, error: 'batch_mode_not_supported', mode }, true);
|
|
579
|
+
}
|
|
580
|
+
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
581
|
+
if (steps.length === 0) {
|
|
582
|
+
return toolJson({ ok: false, error: 'batch_steps_required' }, true);
|
|
583
|
+
}
|
|
584
|
+
const maxSteps = args.maxSteps === undefined ? 30 : Number(args.maxSteps);
|
|
585
|
+
if (!Number.isInteger(maxSteps) || maxSteps < 1) {
|
|
586
|
+
return toolJson({ ok: false, error: 'invalid_max_steps', maxSteps: args.maxSteps }, true);
|
|
587
|
+
}
|
|
588
|
+
if (steps.length > maxSteps) {
|
|
589
|
+
return toolJson({ ok: false, error: 'batch_too_many_steps', stepCount: steps.length, maxSteps }, true);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const defaults = args.defaults && typeof args.defaults === 'object' ? { ...args.defaults } : {};
|
|
593
|
+
for (const key of ['adb', 'serial', 'port', 'packageName', 'artifactDir']) {
|
|
594
|
+
if (args[key] !== undefined && defaults[key] === undefined) {
|
|
595
|
+
defaults[key] = args[key];
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const normalizedSteps = [];
|
|
600
|
+
const seenIds = new Set();
|
|
601
|
+
for (let index = 0; index < steps.length; index += 1) {
|
|
602
|
+
const rawStep = steps[index] && typeof steps[index] === 'object' ? steps[index] : {};
|
|
603
|
+
const stepId = String(rawStep.id || `step_${index + 1}`);
|
|
604
|
+
if (seenIds.has(stepId)) {
|
|
605
|
+
return toolJson({ ok: false, error: 'duplicate_batch_step_id', stepId }, true);
|
|
606
|
+
}
|
|
607
|
+
seenIds.add(stepId);
|
|
608
|
+
const command = normalizeCommandName(rawStep.command);
|
|
609
|
+
if (!commandByName.has(command)) {
|
|
610
|
+
return toolJson({ ok: false, error: 'unknown_batch_step_command', stepId, command: rawStep.command || '' }, true);
|
|
611
|
+
}
|
|
612
|
+
if (command === 'batch') {
|
|
613
|
+
return toolJson({ ok: false, error: 'nested_batch_not_supported', stepId }, true);
|
|
614
|
+
}
|
|
615
|
+
normalizedSteps.push({ ...rawStep, id: stepId, command });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const stopOnError = args.stopOnError !== false;
|
|
619
|
+
const includeRaw = Boolean(args.includeRaw);
|
|
620
|
+
const maxRawChars = args.maxRawChars === undefined ? 4000 : Number(args.maxRawChars);
|
|
621
|
+
if (!Number.isInteger(maxRawChars) || maxRawChars < 0) {
|
|
622
|
+
return toolJson({ ok: false, error: 'invalid_max_raw_chars', maxRawChars: args.maxRawChars }, true);
|
|
623
|
+
}
|
|
624
|
+
const results = [];
|
|
625
|
+
let stopped = false;
|
|
626
|
+
|
|
627
|
+
for (const step of normalizedSteps) {
|
|
628
|
+
if (stopped) {
|
|
629
|
+
results.push({
|
|
630
|
+
id: step.id,
|
|
631
|
+
command: step.command,
|
|
632
|
+
status: 'skipped',
|
|
633
|
+
ok: false,
|
|
634
|
+
skipped: true,
|
|
635
|
+
reason: 'stopOnError',
|
|
636
|
+
});
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const stepStartedAtMs = Date.now();
|
|
641
|
+
const stepArgs = {
|
|
642
|
+
...defaults,
|
|
643
|
+
...(step.arguments && typeof step.arguments === 'object' ? step.arguments : {}),
|
|
644
|
+
};
|
|
645
|
+
for (const key of ['adb', 'serial', 'port', 'packageName']) {
|
|
646
|
+
if (step[key] !== undefined) {
|
|
647
|
+
stepArgs[key] = step[key];
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
const toolResult = await runner(step.command, stepArgs);
|
|
652
|
+
const parsed = parseToolResult(toolResult);
|
|
653
|
+
const passed = !parsed.isError && parsed.payload?.ok !== false;
|
|
654
|
+
const stepResult = {
|
|
655
|
+
id: step.id,
|
|
656
|
+
command: step.command,
|
|
657
|
+
status: passed ? 'passed' : 'failed',
|
|
658
|
+
ok: passed,
|
|
659
|
+
packageName: stepArgs.packageName,
|
|
660
|
+
port: stepArgs.port,
|
|
661
|
+
durationMs: Date.now() - stepStartedAtMs,
|
|
662
|
+
summary: summarizeToolPayload(parsed),
|
|
663
|
+
};
|
|
664
|
+
if (!passed) {
|
|
665
|
+
stepResult.error = parsed.payload?.error || firstTextLine(parsed.text) || 'command_failed';
|
|
666
|
+
}
|
|
667
|
+
if (includeRaw) {
|
|
668
|
+
stepResult.result = parsed.payload || undefined;
|
|
669
|
+
stepResult.rawText = parsed.payload ? undefined : truncateText(parsed.text, maxRawChars);
|
|
670
|
+
}
|
|
671
|
+
results.push(stepResult);
|
|
672
|
+
if (!passed && stopOnError) {
|
|
673
|
+
stopped = true;
|
|
674
|
+
}
|
|
675
|
+
} catch (error) {
|
|
676
|
+
const stepResult = {
|
|
677
|
+
id: step.id,
|
|
678
|
+
command: step.command,
|
|
679
|
+
status: 'failed',
|
|
680
|
+
ok: false,
|
|
681
|
+
packageName: stepArgs.packageName,
|
|
682
|
+
port: stepArgs.port,
|
|
683
|
+
durationMs: Date.now() - stepStartedAtMs,
|
|
684
|
+
error: error.message || String(error),
|
|
685
|
+
};
|
|
686
|
+
results.push(stepResult);
|
|
687
|
+
if (stopOnError) {
|
|
688
|
+
stopped = true;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const failed = results.filter((item) => item.status === 'failed').length;
|
|
694
|
+
const skipped = results.filter((item) => item.status === 'skipped').length;
|
|
695
|
+
const passed = results.filter((item) => item.status === 'passed').length;
|
|
696
|
+
return toolJson({
|
|
697
|
+
ok: failed === 0,
|
|
698
|
+
batchId: args.batchId || generatedBatchId(),
|
|
699
|
+
mode,
|
|
700
|
+
stopOnError,
|
|
701
|
+
stepCount: normalizedSteps.length,
|
|
702
|
+
passed,
|
|
703
|
+
failed,
|
|
704
|
+
skipped,
|
|
705
|
+
durationMs: Date.now() - startedAtMs,
|
|
706
|
+
steps: results,
|
|
707
|
+
}, failed > 0);
|
|
708
|
+
}
|
|
709
|
+
|
|
570
710
|
async function runBridge(command, args) {
|
|
571
711
|
return runProcess(buildBridgeCliArgs(command, args));
|
|
572
712
|
}
|
|
573
713
|
|
|
714
|
+
function parseToolResult(toolResult) {
|
|
715
|
+
const text = String(toolResult?.content?.[0]?.text || '');
|
|
716
|
+
try {
|
|
717
|
+
return {
|
|
718
|
+
isError: Boolean(toolResult?.isError),
|
|
719
|
+
text,
|
|
720
|
+
payload: JSON.parse(text),
|
|
721
|
+
};
|
|
722
|
+
} catch (_) {
|
|
723
|
+
return {
|
|
724
|
+
isError: Boolean(toolResult?.isError),
|
|
725
|
+
text,
|
|
726
|
+
payload: null,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function summarizeToolPayload(parsed) {
|
|
732
|
+
const payload = parsed.payload;
|
|
733
|
+
if (!payload || typeof payload !== 'object') {
|
|
734
|
+
return { text: truncateText(parsed.text, 500) };
|
|
735
|
+
}
|
|
736
|
+
const summary = {
|
|
737
|
+
ok: payload.ok,
|
|
738
|
+
error: payload.error || null,
|
|
739
|
+
};
|
|
740
|
+
if (payload.packageName) summary.packageName = payload.packageName;
|
|
741
|
+
if (payload.app?.packageName) summary.app = payload.app.packageName;
|
|
742
|
+
if (payload.activity) summary.activity = payload.activity;
|
|
743
|
+
if (payload.component) summary.component = payload.component;
|
|
744
|
+
if (payload.transport) summary.transport = payload.transport;
|
|
745
|
+
if (payload.source) summary.source = payload.source;
|
|
746
|
+
if (payload.path) summary.path = payload.path;
|
|
747
|
+
if (payload.debugBridge) {
|
|
748
|
+
summary.bridge = {
|
|
749
|
+
version: payload.debugBridge.version,
|
|
750
|
+
port: payload.debugBridge.port,
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
if (payload.count !== undefined) summary.count = payload.count;
|
|
754
|
+
if (payload.nodeCount !== undefined) summary.nodeCount = payload.nodeCount;
|
|
755
|
+
if (Array.isArray(payload.items)) summary.items = payload.items.length;
|
|
756
|
+
if (payload.values && typeof payload.values === 'object') {
|
|
757
|
+
summary.values = Object.keys(payload.values).length;
|
|
758
|
+
}
|
|
759
|
+
if (payload.counts) summary.counts = payload.counts;
|
|
760
|
+
if (Array.isArray(payload.requests)) summary.requests = payload.requests.length;
|
|
761
|
+
if (Array.isArray(payload.console)) summary.console = payload.console.length;
|
|
762
|
+
if (payload.flutter?.layout?.operable) {
|
|
763
|
+
summary.flutterOperable = {
|
|
764
|
+
ok: payload.flutter.layout.operable.ok,
|
|
765
|
+
count: payload.flutter.layout.operable.count,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (payload.result && typeof payload.result === 'object') {
|
|
769
|
+
summary.result = {
|
|
770
|
+
ok: payload.result.ok,
|
|
771
|
+
error: payload.result.error || null,
|
|
772
|
+
value: truncateText(payload.result.value, 200),
|
|
773
|
+
bodyText: truncateText(payload.result.bodyText, 200),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
return summary;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function truncateText(value, maxChars) {
|
|
780
|
+
if (value === undefined || value === null) return value;
|
|
781
|
+
const text = String(value);
|
|
782
|
+
if (text.length <= maxChars) return text;
|
|
783
|
+
return `${text.slice(0, maxChars)}...`;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function firstTextLine(value) {
|
|
787
|
+
const lines = String(value || '').split(/\r?\n/).filter((line) => line.trim());
|
|
788
|
+
return lines.find((line) => {
|
|
789
|
+
const text = line.trim().toLowerCase();
|
|
790
|
+
return text !== 'stderr:' && text !== 'stdout:';
|
|
791
|
+
}) || lines[0] || '';
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function generatedBatchId() {
|
|
795
|
+
return `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
796
|
+
}
|
|
797
|
+
|
|
574
798
|
function buildBridgeCliArgs(command, args = {}) {
|
|
575
799
|
const cliArgs = [cliScript, command];
|
|
576
800
|
addCommonArgs(cliArgs, args);
|
|
@@ -806,5 +1030,6 @@ if (require.main === module) {
|
|
|
806
1030
|
|
|
807
1031
|
module.exports = {
|
|
808
1032
|
buildBridgeCliArgs,
|
|
1033
|
+
runBatch,
|
|
809
1034
|
startServer,
|
|
810
1035
|
};
|