@mobileaidev/ai-app-bridge 0.2.4 → 0.2.6
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 +31 -4
- package/bin/ai-app-bridge.js +119 -37
- package/bin/mcp-server.js +324 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
```bash
|
|
4
4
|
npm install -g @mobileaidev/ai-app-bridge
|
|
5
5
|
|
|
6
|
-
ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
|
|
6
|
+
ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
|
|
7
7
|
ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
|
|
8
8
|
ai-app-bridge install-apk --package-name io.github.mobileaidev.aiappbridge.sample --apk-path app-debug.apk
|
|
9
|
+
ai-app-bridge clear-app-data --package-name io.github.mobileaidev.aiappbridge.sample
|
|
9
10
|
ai-app-bridge launch-app --package-name io.github.mobileaidev.aiappbridge.sample
|
|
10
11
|
ai-app-bridge launch-activity --package-name io.github.mobileaidev.aiappbridge.sample --activity .MainActivity --extra route=/home
|
|
11
12
|
ai-app-bridge screenshot --package-name io.github.mobileaidev.aiappbridge.sample
|
|
@@ -21,12 +22,38 @@ into the model context:
|
|
|
21
22
|
- `capabilities` lists the bridge domains and command names.
|
|
22
23
|
- `run` executes a selected command with command-specific arguments.
|
|
23
24
|
|
|
24
|
-
This keeps install, launch, UI, Flutter, WebView, logcat, network,
|
|
25
|
-
permission capabilities discoverable without exposing dozens of full schemas
|
|
26
|
-
session start. Set `AI_APP_BRIDGE_MCP_SURFACE=full` before launching
|
|
25
|
+
This keeps install, data reset, launch, UI, Flutter, WebView, logcat, network,
|
|
26
|
+
and permission capabilities discoverable without exposing dozens of full schemas
|
|
27
|
+
at session start. Set `AI_APP_BRIDGE_MCP_SURFACE=full` before launching
|
|
27
28
|
`ai-app-bridge-mcp` only when a client needs the legacy one-tool-per-command
|
|
28
29
|
surface.
|
|
29
30
|
|
|
31
|
+
The MCP server accepts both standard `Content-Length` framed JSON-RPC messages
|
|
32
|
+
and single-line JSON messages. Responses use the format of the first request on
|
|
33
|
+
that connection, so standard MCP clients keep framed responses while local
|
|
34
|
+
Node REPL scripts can send and read one JSON object per line.
|
|
35
|
+
|
|
36
|
+
For multi-step app automation, call `run` with `command: "batch"`. Batch steps
|
|
37
|
+
run serially in one MCP call, so a failed step can stop and mark the remaining
|
|
38
|
+
steps as skipped without mixing results from different commands:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"command": "batch",
|
|
43
|
+
"arguments": {
|
|
44
|
+
"defaults": {
|
|
45
|
+
"packageName": "io.github.mobileaidev.aiappbridge.sample"
|
|
46
|
+
},
|
|
47
|
+
"steps": [
|
|
48
|
+
{ "id": "launch", "command": "launch-app" },
|
|
49
|
+
{ "id": "wait-home", "command": "wait-text", "arguments": { "targetText": "Home" } },
|
|
50
|
+
{ "id": "capture-logs", "command": "logs", "arguments": { "limit": 20 } }
|
|
51
|
+
],
|
|
52
|
+
"stopOnError": true
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
30
57
|
WebView network and console capture use Android WebView DevTools/CDP when the
|
|
31
58
|
target app is debuggable and WebView debugging is enabled.
|
|
32
59
|
|
package/bin/ai-app-bridge.js
CHANGED
|
@@ -70,6 +70,7 @@ Device/action commands:
|
|
|
70
70
|
|
|
71
71
|
App/permission commands:
|
|
72
72
|
install-apk Install an APK and assist device-side installer screens.
|
|
73
|
+
clear-app-data Clear target app local data through the bridge runtime.
|
|
73
74
|
launch-app Launch the target package LAUNCHER Activity.
|
|
74
75
|
launch-activity Launch an explicit Android Activity component.
|
|
75
76
|
launch-native-test Launch the debug native Android bridge test Activity.
|
|
@@ -296,10 +297,12 @@ async function runCommand(command, options, ctx) {
|
|
|
296
297
|
return keyboardState(ctx);
|
|
297
298
|
case 'hide-keyboard':
|
|
298
299
|
return hideKeyboard(ctx, options);
|
|
299
|
-
case 'install-apk':
|
|
300
|
-
return installApk(ctx, options);
|
|
301
|
-
case '
|
|
302
|
-
return
|
|
300
|
+
case 'install-apk':
|
|
301
|
+
return installApk(ctx, options);
|
|
302
|
+
case 'clear-app-data':
|
|
303
|
+
return clearAppData(ctx);
|
|
304
|
+
case 'webview-pages':
|
|
305
|
+
return webviewPages(ctx, options);
|
|
303
306
|
case 'webview-network':
|
|
304
307
|
return webviewCdpCapture(ctx, { ...options, captureNetwork: true, captureConsole: true });
|
|
305
308
|
case 'webview-console':
|
|
@@ -976,6 +979,13 @@ function normalizeBridgeError(error) {
|
|
|
976
979
|
suggestion: 'Check the device state and retry; if the bridge port is known, pass --port to skip package port discovery.',
|
|
977
980
|
};
|
|
978
981
|
}
|
|
982
|
+
if (error?.aiAppBridgePackageMismatch || lower.includes('bridge package mismatch')) {
|
|
983
|
+
return {
|
|
984
|
+
code: 'bridge_package_mismatch',
|
|
985
|
+
message,
|
|
986
|
+
suggestion: 'The resolved bridge port belongs to another package. Relaunch the target app and retry with the explicit packageName.',
|
|
987
|
+
};
|
|
988
|
+
}
|
|
979
989
|
if (error?.aiAppBridgePortDiscovery || lower.includes('bridge port discovery failed') || lower.includes('run-as') || lower.includes('package not found')) {
|
|
980
990
|
return {
|
|
981
991
|
code: 'bridge_port_discovery_failed',
|
|
@@ -1001,21 +1011,38 @@ function firstErrorLine(error) {
|
|
|
1001
1011
|
return String(error?.message || error || 'unknown_error').split(/\r?\n/).find(Boolean) || 'unknown_error';
|
|
1002
1012
|
}
|
|
1003
1013
|
|
|
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
|
-
}
|
|
1014
|
+
async function bridgeGet(ctx, requestPath) {
|
|
1015
|
+
await ensureForward(ctx);
|
|
1016
|
+
const body = await httpGet(bridgeUrl(ctx, requestPath));
|
|
1017
|
+
const payload = JSON.parse(body);
|
|
1018
|
+
verifyBridgeTargetPackage(ctx, payload, requestPath);
|
|
1019
|
+
return payload;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function bridgePost(ctx, requestPath, payload) {
|
|
1023
|
+
await ensureForward(ctx);
|
|
1024
|
+
const body = await httpPost(bridgeUrl(ctx, requestPath), payload);
|
|
1025
|
+
const responsePayload = JSON.parse(body);
|
|
1026
|
+
verifyBridgeTargetPackage(ctx, responsePayload, requestPath);
|
|
1027
|
+
return responsePayload;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function bridgeUrl(ctx, requestPath) {
|
|
1031
|
+
return `http://127.0.0.1:${ctx.hostPort || ctx.port}${requestPath}`;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function verifyBridgeTargetPackage(ctx, payload, requestPath) {
|
|
1035
|
+
if (!ctx.explicitPackageName || !payload || typeof payload !== 'object') return;
|
|
1036
|
+
const responsePackageName = payload.app && typeof payload.app === 'object'
|
|
1037
|
+
? payload.app.packageName
|
|
1038
|
+
: undefined;
|
|
1039
|
+
if (!responsePackageName || responsePackageName === ctx.packageName) return;
|
|
1040
|
+
const error = new Error(`bridge package mismatch for ${requestPath}: expected ${ctx.packageName}, got ${responsePackageName}`);
|
|
1041
|
+
error.aiAppBridgePackageMismatch = true;
|
|
1042
|
+
error.expectedPackageName = ctx.packageName;
|
|
1043
|
+
error.actualPackageName = responsePackageName;
|
|
1044
|
+
throw error;
|
|
1045
|
+
}
|
|
1019
1046
|
|
|
1020
1047
|
function httpGet(url) {
|
|
1021
1048
|
return new Promise((resolve, reject) => {
|
|
@@ -3676,17 +3703,70 @@ async function safePermissionState(ctx, permission) {
|
|
|
3676
3703
|
}
|
|
3677
3704
|
}
|
|
3678
3705
|
|
|
3679
|
-
async function appopsSet(ctx, op, mode) {
|
|
3680
|
-
await adb(ctx, ['shell', 'appops', 'set', ctx.packageName, op, mode]);
|
|
3681
|
-
return {
|
|
3706
|
+
async function appopsSet(ctx, op, mode) {
|
|
3707
|
+
await adb(ctx, ['shell', 'appops', 'set', ctx.packageName, op, mode]);
|
|
3708
|
+
return {
|
|
3682
3709
|
ok: true,
|
|
3683
3710
|
packageName: ctx.packageName,
|
|
3684
3711
|
op,
|
|
3685
3712
|
mode,
|
|
3686
|
-
};
|
|
3687
|
-
}
|
|
3688
|
-
|
|
3689
|
-
async function
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
async function clearAppData(ctx) {
|
|
3717
|
+
if (!ctx.explicitPackageName) {
|
|
3718
|
+
throw new Error('packageName is required for clear-app-data');
|
|
3719
|
+
}
|
|
3720
|
+
let bridgeError = null;
|
|
3721
|
+
try {
|
|
3722
|
+
const bridgeResult = await bridgePost(ctx, '/v1/app/clear-data', {});
|
|
3723
|
+
return {
|
|
3724
|
+
...bridgeResult,
|
|
3725
|
+
packageName: ctx.packageName,
|
|
3726
|
+
method: 'bridge-runtime',
|
|
3727
|
+
};
|
|
3728
|
+
} catch (error) {
|
|
3729
|
+
bridgeError = firstErrorLine(error);
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
let result;
|
|
3733
|
+
try {
|
|
3734
|
+
result = await adb(ctx, clearAppDataAdbArgs(ctx.packageName));
|
|
3735
|
+
} catch (error) {
|
|
3736
|
+
return {
|
|
3737
|
+
ok: false,
|
|
3738
|
+
packageName: ctx.packageName,
|
|
3739
|
+
action: 'clear-app-data',
|
|
3740
|
+
error: 'clear_app_data_failed',
|
|
3741
|
+
bridgeError,
|
|
3742
|
+
message: error.message || String(error),
|
|
3743
|
+
};
|
|
3744
|
+
}
|
|
3745
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
3746
|
+
if (!/^Success\b/im.test(output)) {
|
|
3747
|
+
return {
|
|
3748
|
+
ok: false,
|
|
3749
|
+
packageName: ctx.packageName,
|
|
3750
|
+
error: 'clear_app_data_failed',
|
|
3751
|
+
bridgeError,
|
|
3752
|
+
output,
|
|
3753
|
+
};
|
|
3754
|
+
}
|
|
3755
|
+
return {
|
|
3756
|
+
ok: true,
|
|
3757
|
+
packageName: ctx.packageName,
|
|
3758
|
+
action: 'clear-app-data',
|
|
3759
|
+
method: 'pm-clear',
|
|
3760
|
+
bridgeError,
|
|
3761
|
+
output,
|
|
3762
|
+
};
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
function clearAppDataAdbArgs(packageName) {
|
|
3766
|
+
return ['shell', 'pm', 'clear', packageName];
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
async function resolveLogcatPid(ctx, options) {
|
|
3690
3770
|
if (options.pid && options.pid !== true && options.pid !== 'current') {
|
|
3691
3771
|
return String(options.pid);
|
|
3692
3772
|
}
|
|
@@ -4279,8 +4359,9 @@ function requiredNumber(value, name) {
|
|
|
4279
4359
|
}
|
|
4280
4360
|
|
|
4281
4361
|
module.exports = {
|
|
4282
|
-
buildBridgeFailureResult,
|
|
4283
|
-
|
|
4362
|
+
buildBridgeFailureResult,
|
|
4363
|
+
clearAppDataAdbArgs,
|
|
4364
|
+
defaultInstallerButtonTexts,
|
|
4284
4365
|
artifactTimestamp,
|
|
4285
4366
|
compactBridgeTree,
|
|
4286
4367
|
compactStatus,
|
|
@@ -4311,16 +4392,17 @@ module.exports = {
|
|
|
4311
4392
|
parseForegroundWindow,
|
|
4312
4393
|
chooseWebViewDevToolsSocket,
|
|
4313
4394
|
chooseWebViewPage,
|
|
4314
|
-
shapeNetworkCapture,
|
|
4315
|
-
compactNetworkRecord,
|
|
4316
|
-
pruneGeneratedArtifacts,
|
|
4317
|
-
shouldSkipInstallerTapForInstalledPackage,
|
|
4395
|
+
shapeNetworkCapture,
|
|
4396
|
+
compactNetworkRecord,
|
|
4397
|
+
pruneGeneratedArtifacts,
|
|
4398
|
+
shouldSkipInstallerTapForInstalledPackage,
|
|
4318
4399
|
shouldDismissKeyboardForPoint,
|
|
4319
4400
|
shouldUseDefaultPortFallback,
|
|
4320
4401
|
screenshotOutputPath,
|
|
4321
|
-
statusSearchText,
|
|
4322
|
-
uiautomatorLockPath,
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4402
|
+
statusSearchText,
|
|
4403
|
+
uiautomatorLockPath,
|
|
4404
|
+
verifyBridgeTargetPackage,
|
|
4405
|
+
waitTextConditionsMet,
|
|
4406
|
+
withFileLock,
|
|
4407
|
+
};
|
|
4326
4408
|
|
package/bin/mcp-server.js
CHANGED
|
@@ -11,12 +11,13 @@ const supportedProtocolVersions = ['2025-06-18', '2024-11-05'];
|
|
|
11
11
|
const defaultProtocolVersion = supportedProtocolVersions[0];
|
|
12
12
|
const mcpSurface = (process.env.AI_APP_BRIDGE_MCP_SURFACE || 'compact').toLowerCase();
|
|
13
13
|
const serverInstructions = [
|
|
14
|
-
'AI App Bridge observes and controls Android apps for agent workflows. Prefer these tools over raw adb when inspecting UI, text, WebView, logs, network, app install, launch, and permissions.',
|
|
14
|
+
'AI App Bridge observes and controls Android apps for agent workflows. Prefer these tools over raw adb when inspecting UI, text, WebView, logs, network, app install, data reset, launch, and permissions.',
|
|
15
15
|
'Default surface is compact: call capabilities to discover domains, then call run with a command and arguments.',
|
|
16
16
|
'Always pass packageName for app-specific commands, or pass an explicit port. Do not rely on a sample/default package in MCP sessions.',
|
|
17
17
|
].join(' ');
|
|
18
18
|
|
|
19
19
|
let buffer = Buffer.alloc(0);
|
|
20
|
+
let responseFormat = null;
|
|
20
21
|
|
|
21
22
|
function startServer() {
|
|
22
23
|
process.stdin.on('data', (chunk) => {
|
|
@@ -27,34 +28,77 @@ function startServer() {
|
|
|
27
28
|
process.stdin.on('error', () => {});
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
function drainMessages() {
|
|
31
|
-
while (true) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
31
|
+
function drainMessages() {
|
|
32
|
+
while (true) {
|
|
33
|
+
const parsed = readNextMessage(buffer);
|
|
34
|
+
if (!parsed) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
buffer = parsed.remaining;
|
|
38
|
+
setResponseFormat(parsed.format);
|
|
39
|
+
handleMessage(parsed.body).catch((error) => {
|
|
40
|
+
writeLog(`unhandled message error: ${error.stack || error}`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readNextMessage(source) {
|
|
46
|
+
const text = source.toString('utf8');
|
|
47
|
+
if (/^Content-Length:/i.test(text)) {
|
|
48
|
+
return readContentLengthMessage(source);
|
|
49
|
+
}
|
|
50
|
+
return readLineJsonMessage(source);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readContentLengthMessage(source) {
|
|
54
|
+
const delimiter = findHeaderDelimiter(source);
|
|
55
|
+
const headerEnd = delimiter.index;
|
|
56
|
+
if (headerEnd < 0) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const header = source.subarray(0, headerEnd).toString('utf8');
|
|
60
|
+
const match = /^Content-Length:\s*(\d+)$/im.exec(header);
|
|
61
|
+
if (!match) {
|
|
62
|
+
return {
|
|
63
|
+
body: source.subarray(headerEnd + delimiter.length).toString('utf8'),
|
|
64
|
+
format: 'frame',
|
|
65
|
+
remaining: Buffer.alloc(0),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const contentLength = Number(match[1]);
|
|
69
|
+
const messageStart = headerEnd + delimiter.length;
|
|
70
|
+
const messageEnd = messageStart + contentLength;
|
|
71
|
+
if (source.length < messageEnd) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
body: source.subarray(messageStart, messageEnd).toString('utf8'),
|
|
76
|
+
format: 'frame',
|
|
77
|
+
remaining: source.subarray(messageEnd),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readLineJsonMessage(source) {
|
|
82
|
+
const lfIndex = source.indexOf('\n');
|
|
83
|
+
if (lfIndex < 0) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const lineEnd = lfIndex > 0 && source[lfIndex - 1] === 13 ? lfIndex - 1 : lfIndex;
|
|
87
|
+
const body = source.subarray(0, lineEnd).toString('utf8');
|
|
88
|
+
return {
|
|
89
|
+
body,
|
|
90
|
+
format: 'line',
|
|
91
|
+
remaining: source.subarray(lfIndex + 1),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function setResponseFormat(format) {
|
|
96
|
+
if (!responseFormat) {
|
|
97
|
+
responseFormat = format;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function findHeaderDelimiter(source) {
|
|
58
102
|
const crlfIndex = source.indexOf('\r\n\r\n');
|
|
59
103
|
const lfIndex = source.indexOf('\n\n');
|
|
60
104
|
if (crlfIndex < 0) {
|
|
@@ -264,14 +308,15 @@ function fullToolDefinitions() {
|
|
|
264
308
|
bridgeTool('events', 'Read generic in-app event records.'),
|
|
265
309
|
bridgeTool('uia_tree', 'Read UIAutomator XML for the current device window.'),
|
|
266
310
|
bridgeTool('screenshot', 'Capture an ADB screenshot.'),
|
|
267
|
-
bridgeTool('install_apk', 'Install an APK through ADB while assisting device-side package-installer confirmation screens with UIAutomator.', {
|
|
268
|
-
apkPath: { type: 'string', description: 'Absolute or workspace-relative APK path.' },
|
|
269
|
-
allowDowngrade: { type: 'boolean', description: 'Pass -d to adb install.' },
|
|
270
|
-
streaming: { type: 'boolean', description: 'Use streaming install instead of the default --no-streaming mode.' },
|
|
271
|
-
installTimeoutMs: { type: 'number', description: 'Maximum time for adb install. Defaults to 180000 ms.' },
|
|
272
|
-
installerTimeoutMs: { type: 'number', description: 'Maximum time to keep assisting installer screens after adb install exits. Defaults to 90000 ms.' },
|
|
273
|
-
intervalMs: { type: 'number', description: 'Installer polling interval. Defaults to 700 ms.' },
|
|
274
|
-
}, ['apkPath']),
|
|
311
|
+
bridgeTool('install_apk', 'Install an APK through ADB while assisting device-side package-installer confirmation screens with UIAutomator.', {
|
|
312
|
+
apkPath: { type: 'string', description: 'Absolute or workspace-relative APK path.' },
|
|
313
|
+
allowDowngrade: { type: 'boolean', description: 'Pass -d to adb install.' },
|
|
314
|
+
streaming: { type: 'boolean', description: 'Use streaming install instead of the default --no-streaming mode.' },
|
|
315
|
+
installTimeoutMs: { type: 'number', description: 'Maximum time for adb install. Defaults to 180000 ms.' },
|
|
316
|
+
installerTimeoutMs: { type: 'number', description: 'Maximum time to keep assisting installer screens after adb install exits. Defaults to 90000 ms.' },
|
|
317
|
+
intervalMs: { type: 'number', description: 'Installer polling interval. Defaults to 700 ms.' },
|
|
318
|
+
}, ['apkPath']),
|
|
319
|
+
bridgeTool('clear_app_data', 'Clear target app local data through the bridge runtime. Requires packageName so it cannot target the sample package by default.', {}, ['packageName']),
|
|
275
320
|
bridgeTool('launch_app', 'Launch the target package LAUNCHER Activity. If multiple launcher Activities exist, returns launcher_ambiguous with candidates unless activity or component is explicit.', launchProperties()),
|
|
276
321
|
bridgeTool('launch_activity', 'Launch an explicit Android Activity component, optionally with action/data/category/string extras.', launchProperties()),
|
|
277
322
|
bridgeTool('launch_native_test', 'Launch the debug native Android bridge test Activity.'),
|
|
@@ -405,6 +450,7 @@ const commandDefinitions = [
|
|
|
405
450
|
{ command: 'events', domain: 'core', summary: 'Read in-app event records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
|
|
406
451
|
{ command: 'logcat', domain: 'diagnostics', summary: 'Read Android logcat with optional app pid, tag, level, and grep filters.', options: ['serial', 'packageName', 'pid', 'appPid', 'tag', 'level', 'grep', 'lines', 'since', 'follow', 'durationSec', 'clear'] },
|
|
407
452
|
{ command: 'install-apk', domain: 'app', summary: 'Install an APK and assist device-side installer confirmation screens.', options: ['serial', 'packageName', 'apkPath', 'allowDowngrade', 'streaming', 'installTimeoutMs', 'installerTimeoutMs', 'intervalMs'] },
|
|
453
|
+
{ command: 'clear-app-data', domain: 'app', summary: 'Clear target app local data through the bridge runtime.', targetApp: true, options: ['serial', 'packageName'] },
|
|
408
454
|
{ command: 'launch-app', domain: 'app', summary: 'Launch the target package LAUNCHER Activity and report launcher candidates.', targetApp: true, options: ['serial', 'packageName', 'activity', 'component', 'action', 'category', 'data', 'extra'] },
|
|
409
455
|
{ command: 'launch-activity', domain: 'app', summary: 'Launch an explicit Android Activity component with optional string extras.', targetApp: true, options: ['serial', 'packageName', 'activity', 'component', 'action', 'category', 'data', 'extra'] },
|
|
410
456
|
{ command: 'launch-native-test', domain: 'app', summary: 'Launch the debug native bridge test Activity.', targetApp: true, options: ['serial', 'packageName'] },
|
|
@@ -446,6 +492,7 @@ const commandDefinitions = [
|
|
|
446
492
|
{ 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
493
|
{ command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
|
|
448
494
|
{ command: 'remove-forward', domain: 'advanced', summary: 'Remove the ADB port forward for the bridge.', options: ['serial', 'port'] },
|
|
495
|
+
{ command: 'batch', domain: 'advanced', summary: 'Run multiple AI App Bridge commands serially in one MCP call.', options: ['defaults', 'steps', 'stopOnError', 'includeRaw', 'maxRawChars'] },
|
|
449
496
|
{ command: 'smoke', domain: 'diagnostics', summary: 'Run the native sample smoke test.', options: ['serial', 'packageName', 'outFile', 'artifactDir', 'skipFlutterLaunch'] },
|
|
450
497
|
];
|
|
451
498
|
|
|
@@ -480,6 +527,7 @@ async function callTool(name, args) {
|
|
|
480
527
|
input_flutter_text: 'input-flutter-text',
|
|
481
528
|
uia_tree: 'uia-tree',
|
|
482
529
|
install_apk: 'install-apk',
|
|
530
|
+
clear_app_data: 'clear-app-data',
|
|
483
531
|
launch_app: 'launch-app',
|
|
484
532
|
launch_activity: 'launch-activity',
|
|
485
533
|
launch_native_test: 'launch-native-test',
|
|
@@ -525,7 +573,7 @@ function capabilityPayload(args = {}) {
|
|
|
525
573
|
return {
|
|
526
574
|
ok: true,
|
|
527
575
|
surface: mcpSurface === 'full' || mcpSurface === 'legacy' ? 'full' : 'compact',
|
|
528
|
-
usage: 'Use run with one of these command names. Prefer packageName for app-specific commands; install-apk, launch-app, UI, WebView, logcat, network, and permission workflows are supported.',
|
|
576
|
+
usage: 'Use run with one of these command names. Prefer packageName for app-specific commands; install-apk, clear-app-data, launch-app, UI, WebView, logcat, network, and permission workflows are supported.',
|
|
529
577
|
domains,
|
|
530
578
|
};
|
|
531
579
|
}
|
|
@@ -552,6 +600,9 @@ async function runGeneric(args = {}) {
|
|
|
552
600
|
commandArgs[key] = args[key];
|
|
553
601
|
}
|
|
554
602
|
}
|
|
603
|
+
if (command === 'batch') {
|
|
604
|
+
return runBatch(commandArgs);
|
|
605
|
+
}
|
|
555
606
|
return runBridgeChecked(command, commandArgs);
|
|
556
607
|
}
|
|
557
608
|
|
|
@@ -560,17 +611,240 @@ function normalizeCommandName(value) {
|
|
|
560
611
|
}
|
|
561
612
|
|
|
562
613
|
function runBridgeChecked(command, args = {}) {
|
|
614
|
+
if (command === 'clear-app-data' && !args.packageName) {
|
|
615
|
+
return toolText('clear-app-data: packageName is required in MCP mode so the command cannot clear a default package.', true);
|
|
616
|
+
}
|
|
563
617
|
const definition = commandByName.get(command);
|
|
564
618
|
if (definition?.targetApp && !args.packageName && !args.port) {
|
|
565
619
|
return toolText(`${command}: packageName or explicit port is required in MCP mode so the command cannot fall back to a default package.`, true);
|
|
566
620
|
}
|
|
567
621
|
return runBridge(command, args);
|
|
568
622
|
}
|
|
569
|
-
|
|
623
|
+
|
|
624
|
+
async function runBatch(args = {}, runner = runBridgeChecked) {
|
|
625
|
+
const startedAtMs = Date.now();
|
|
626
|
+
const mode = args.mode ? String(args.mode) : 'serial';
|
|
627
|
+
if (mode !== 'serial') {
|
|
628
|
+
return toolJson({ ok: false, error: 'batch_mode_not_supported', mode }, true);
|
|
629
|
+
}
|
|
630
|
+
const steps = Array.isArray(args.steps) ? args.steps : [];
|
|
631
|
+
if (steps.length === 0) {
|
|
632
|
+
return toolJson({ ok: false, error: 'batch_steps_required' }, true);
|
|
633
|
+
}
|
|
634
|
+
const maxSteps = args.maxSteps === undefined ? 30 : Number(args.maxSteps);
|
|
635
|
+
if (!Number.isInteger(maxSteps) || maxSteps < 1) {
|
|
636
|
+
return toolJson({ ok: false, error: 'invalid_max_steps', maxSteps: args.maxSteps }, true);
|
|
637
|
+
}
|
|
638
|
+
if (steps.length > maxSteps) {
|
|
639
|
+
return toolJson({ ok: false, error: 'batch_too_many_steps', stepCount: steps.length, maxSteps }, true);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const defaults = args.defaults && typeof args.defaults === 'object' ? { ...args.defaults } : {};
|
|
643
|
+
for (const key of ['adb', 'serial', 'port', 'packageName', 'artifactDir']) {
|
|
644
|
+
if (args[key] !== undefined && defaults[key] === undefined) {
|
|
645
|
+
defaults[key] = args[key];
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const normalizedSteps = [];
|
|
650
|
+
const seenIds = new Set();
|
|
651
|
+
for (let index = 0; index < steps.length; index += 1) {
|
|
652
|
+
const rawStep = steps[index] && typeof steps[index] === 'object' ? steps[index] : {};
|
|
653
|
+
const stepId = String(rawStep.id || `step_${index + 1}`);
|
|
654
|
+
if (seenIds.has(stepId)) {
|
|
655
|
+
return toolJson({ ok: false, error: 'duplicate_batch_step_id', stepId }, true);
|
|
656
|
+
}
|
|
657
|
+
seenIds.add(stepId);
|
|
658
|
+
const command = normalizeCommandName(rawStep.command);
|
|
659
|
+
if (!commandByName.has(command)) {
|
|
660
|
+
return toolJson({ ok: false, error: 'unknown_batch_step_command', stepId, command: rawStep.command || '' }, true);
|
|
661
|
+
}
|
|
662
|
+
if (command === 'batch') {
|
|
663
|
+
return toolJson({ ok: false, error: 'nested_batch_not_supported', stepId }, true);
|
|
664
|
+
}
|
|
665
|
+
normalizedSteps.push({ ...rawStep, id: stepId, command });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const stopOnError = args.stopOnError !== false;
|
|
669
|
+
const includeRaw = Boolean(args.includeRaw);
|
|
670
|
+
const maxRawChars = args.maxRawChars === undefined ? 4000 : Number(args.maxRawChars);
|
|
671
|
+
if (!Number.isInteger(maxRawChars) || maxRawChars < 0) {
|
|
672
|
+
return toolJson({ ok: false, error: 'invalid_max_raw_chars', maxRawChars: args.maxRawChars }, true);
|
|
673
|
+
}
|
|
674
|
+
const results = [];
|
|
675
|
+
let stopped = false;
|
|
676
|
+
|
|
677
|
+
for (const step of normalizedSteps) {
|
|
678
|
+
if (stopped) {
|
|
679
|
+
results.push({
|
|
680
|
+
id: step.id,
|
|
681
|
+
command: step.command,
|
|
682
|
+
status: 'skipped',
|
|
683
|
+
ok: false,
|
|
684
|
+
skipped: true,
|
|
685
|
+
reason: 'stopOnError',
|
|
686
|
+
});
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const stepStartedAtMs = Date.now();
|
|
691
|
+
const stepArgs = {
|
|
692
|
+
...defaults,
|
|
693
|
+
...(step.arguments && typeof step.arguments === 'object' ? step.arguments : {}),
|
|
694
|
+
};
|
|
695
|
+
for (const key of ['adb', 'serial', 'port', 'packageName']) {
|
|
696
|
+
if (step[key] !== undefined) {
|
|
697
|
+
stepArgs[key] = step[key];
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
const toolResult = await runner(step.command, stepArgs);
|
|
702
|
+
const parsed = parseToolResult(toolResult);
|
|
703
|
+
const passed = !parsed.isError && parsed.payload?.ok !== false;
|
|
704
|
+
const stepResult = {
|
|
705
|
+
id: step.id,
|
|
706
|
+
command: step.command,
|
|
707
|
+
status: passed ? 'passed' : 'failed',
|
|
708
|
+
ok: passed,
|
|
709
|
+
packageName: stepArgs.packageName,
|
|
710
|
+
port: stepArgs.port,
|
|
711
|
+
durationMs: Date.now() - stepStartedAtMs,
|
|
712
|
+
summary: summarizeToolPayload(parsed),
|
|
713
|
+
};
|
|
714
|
+
if (!passed) {
|
|
715
|
+
stepResult.error = parsed.payload?.error || firstTextLine(parsed.text) || 'command_failed';
|
|
716
|
+
}
|
|
717
|
+
if (includeRaw) {
|
|
718
|
+
stepResult.result = parsed.payload || undefined;
|
|
719
|
+
stepResult.rawText = parsed.payload ? undefined : truncateText(parsed.text, maxRawChars);
|
|
720
|
+
}
|
|
721
|
+
results.push(stepResult);
|
|
722
|
+
if (!passed && stopOnError) {
|
|
723
|
+
stopped = true;
|
|
724
|
+
}
|
|
725
|
+
} catch (error) {
|
|
726
|
+
const stepResult = {
|
|
727
|
+
id: step.id,
|
|
728
|
+
command: step.command,
|
|
729
|
+
status: 'failed',
|
|
730
|
+
ok: false,
|
|
731
|
+
packageName: stepArgs.packageName,
|
|
732
|
+
port: stepArgs.port,
|
|
733
|
+
durationMs: Date.now() - stepStartedAtMs,
|
|
734
|
+
error: error.message || String(error),
|
|
735
|
+
};
|
|
736
|
+
results.push(stepResult);
|
|
737
|
+
if (stopOnError) {
|
|
738
|
+
stopped = true;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const failed = results.filter((item) => item.status === 'failed').length;
|
|
744
|
+
const skipped = results.filter((item) => item.status === 'skipped').length;
|
|
745
|
+
const passed = results.filter((item) => item.status === 'passed').length;
|
|
746
|
+
return toolJson({
|
|
747
|
+
ok: failed === 0,
|
|
748
|
+
batchId: args.batchId || generatedBatchId(),
|
|
749
|
+
mode,
|
|
750
|
+
stopOnError,
|
|
751
|
+
stepCount: normalizedSteps.length,
|
|
752
|
+
passed,
|
|
753
|
+
failed,
|
|
754
|
+
skipped,
|
|
755
|
+
durationMs: Date.now() - startedAtMs,
|
|
756
|
+
steps: results,
|
|
757
|
+
}, failed > 0);
|
|
758
|
+
}
|
|
759
|
+
|
|
570
760
|
async function runBridge(command, args) {
|
|
571
761
|
return runProcess(buildBridgeCliArgs(command, args));
|
|
572
762
|
}
|
|
573
763
|
|
|
764
|
+
function parseToolResult(toolResult) {
|
|
765
|
+
const text = String(toolResult?.content?.[0]?.text || '');
|
|
766
|
+
try {
|
|
767
|
+
return {
|
|
768
|
+
isError: Boolean(toolResult?.isError),
|
|
769
|
+
text,
|
|
770
|
+
payload: JSON.parse(text),
|
|
771
|
+
};
|
|
772
|
+
} catch (_) {
|
|
773
|
+
return {
|
|
774
|
+
isError: Boolean(toolResult?.isError),
|
|
775
|
+
text,
|
|
776
|
+
payload: null,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function summarizeToolPayload(parsed) {
|
|
782
|
+
const payload = parsed.payload;
|
|
783
|
+
if (!payload || typeof payload !== 'object') {
|
|
784
|
+
return { text: truncateText(parsed.text, 500) };
|
|
785
|
+
}
|
|
786
|
+
const summary = {
|
|
787
|
+
ok: payload.ok,
|
|
788
|
+
error: payload.error || null,
|
|
789
|
+
};
|
|
790
|
+
if (payload.packageName) summary.packageName = payload.packageName;
|
|
791
|
+
if (payload.app?.packageName) summary.app = payload.app.packageName;
|
|
792
|
+
if (payload.activity) summary.activity = payload.activity;
|
|
793
|
+
if (payload.component) summary.component = payload.component;
|
|
794
|
+
if (payload.transport) summary.transport = payload.transport;
|
|
795
|
+
if (payload.source) summary.source = payload.source;
|
|
796
|
+
if (payload.path) summary.path = payload.path;
|
|
797
|
+
if (payload.debugBridge) {
|
|
798
|
+
summary.bridge = {
|
|
799
|
+
version: payload.debugBridge.version,
|
|
800
|
+
port: payload.debugBridge.port,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
if (payload.count !== undefined) summary.count = payload.count;
|
|
804
|
+
if (payload.nodeCount !== undefined) summary.nodeCount = payload.nodeCount;
|
|
805
|
+
if (Array.isArray(payload.items)) summary.items = payload.items.length;
|
|
806
|
+
if (payload.values && typeof payload.values === 'object') {
|
|
807
|
+
summary.values = Object.keys(payload.values).length;
|
|
808
|
+
}
|
|
809
|
+
if (payload.counts) summary.counts = payload.counts;
|
|
810
|
+
if (Array.isArray(payload.requests)) summary.requests = payload.requests.length;
|
|
811
|
+
if (Array.isArray(payload.console)) summary.console = payload.console.length;
|
|
812
|
+
if (payload.flutter?.layout?.operable) {
|
|
813
|
+
summary.flutterOperable = {
|
|
814
|
+
ok: payload.flutter.layout.operable.ok,
|
|
815
|
+
count: payload.flutter.layout.operable.count,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
if (payload.result && typeof payload.result === 'object') {
|
|
819
|
+
summary.result = {
|
|
820
|
+
ok: payload.result.ok,
|
|
821
|
+
error: payload.result.error || null,
|
|
822
|
+
value: truncateText(payload.result.value, 200),
|
|
823
|
+
bodyText: truncateText(payload.result.bodyText, 200),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
return summary;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function truncateText(value, maxChars) {
|
|
830
|
+
if (value === undefined || value === null) return value;
|
|
831
|
+
const text = String(value);
|
|
832
|
+
if (text.length <= maxChars) return text;
|
|
833
|
+
return `${text.slice(0, maxChars)}...`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function firstTextLine(value) {
|
|
837
|
+
const lines = String(value || '').split(/\r?\n/).filter((line) => line.trim());
|
|
838
|
+
return lines.find((line) => {
|
|
839
|
+
const text = line.trim().toLowerCase();
|
|
840
|
+
return text !== 'stderr:' && text !== 'stdout:';
|
|
841
|
+
}) || lines[0] || '';
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function generatedBatchId() {
|
|
845
|
+
return `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
846
|
+
}
|
|
847
|
+
|
|
574
848
|
function buildBridgeCliArgs(command, args = {}) {
|
|
575
849
|
const cliArgs = [cliScript, command];
|
|
576
850
|
addCommonArgs(cliArgs, args);
|
|
@@ -790,11 +1064,15 @@ function sendError(id, code, message) {
|
|
|
790
1064
|
});
|
|
791
1065
|
}
|
|
792
1066
|
|
|
793
|
-
function send(message) {
|
|
794
|
-
const body = Buffer.from(JSON.stringify(message), 'utf8');
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1067
|
+
function send(message) {
|
|
1068
|
+
const body = Buffer.from(JSON.stringify(message), 'utf8');
|
|
1069
|
+
if (responseFormat === 'line') {
|
|
1070
|
+
process.stdout.write(`${body.toString('utf8')}\n`);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
1074
|
+
process.stdout.write(body);
|
|
1075
|
+
}
|
|
798
1076
|
|
|
799
1077
|
function writeLog(text) {
|
|
800
1078
|
process.stderr.write(`${text}\n`);
|
|
@@ -806,5 +1084,7 @@ if (require.main === module) {
|
|
|
806
1084
|
|
|
807
1085
|
module.exports = {
|
|
808
1086
|
buildBridgeCliArgs,
|
|
1087
|
+
readNextMessage,
|
|
1088
|
+
runBatch,
|
|
809
1089
|
startServer,
|
|
810
1090
|
};
|