@mobileaidev/ai-app-bridge 0.2.0 → 0.2.2

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 CHANGED
@@ -4,15 +4,29 @@
4
4
  npm install -g @mobileaidev/ai-app-bridge
5
5
 
6
6
  ai-app-bridge status --package-name io.github.mobileaidev.aiappbridge.sample
7
- ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
8
- ai-app-bridge install-apk --package-name io.github.mobileaidev.aiappbridge.sample --apk-path app-debug.apk
9
- ai-app-bridge screenshot --package-name io.github.mobileaidev.aiappbridge.sample
7
+ ai-app-bridge tree --package-name io.github.mobileaidev.aiappbridge.sample
8
+ ai-app-bridge install-apk --package-name io.github.mobileaidev.aiappbridge.sample --apk-path app-debug.apk
9
+ ai-app-bridge launch-app --package-name io.github.mobileaidev.aiappbridge.sample
10
+ ai-app-bridge launch-activity --package-name io.github.mobileaidev.aiappbridge.sample --activity .MainActivity --extra route=/home
11
+ ai-app-bridge screenshot --package-name io.github.mobileaidev.aiappbridge.sample
10
12
  ai-app-bridge input-text --package-name io.github.mobileaidev.aiappbridge.sample --text "中文输入" --hide-keyboard
11
13
  ai-app-bridge network --package-name io.github.mobileaidev.aiappbridge.sample --compact --url-filter /api/
12
- ai-app-bridge webview-network --package-name io.github.mobileaidev.aiappbridge.sample --duration-ms 3000
13
- ai-app-bridge-mcp
14
- ```
15
-
14
+ ai-app-bridge webview-network --package-name io.github.mobileaidev.aiappbridge.sample --duration-ms 3000
15
+ ai-app-bridge-mcp
16
+ ```
17
+
18
+ MCP defaults to a compact tool surface to avoid loading every command schema
19
+ into the model context:
20
+
21
+ - `capabilities` lists the bridge domains and command names.
22
+ - `run` executes a selected command with command-specific arguments.
23
+
24
+ This keeps install, launch, UI, Flutter, WebView, logcat, network, and
25
+ permission capabilities discoverable without exposing dozens of full schemas at
26
+ session start. Set `AI_APP_BRIDGE_MCP_SURFACE=full` before launching
27
+ `ai-app-bridge-mcp` only when a client needs the legacy one-tool-per-command
28
+ surface.
29
+
16
30
  WebView network and console capture use Android WebView DevTools/CDP when the
17
31
  target app is debuggable and WebView debugging is enabled.
18
32
 
@@ -25,5 +39,11 @@ When `screenshot` or `smoke` runs without `--out-file`, the CLI writes a unique
25
39
  PNG under `build/ai_app_bridge_artifacts` instead of reusing a stable filename
26
40
  or creating files in the project root.
27
41
  It keeps the newest 20 generated screenshots for each command prefix. Use
28
- `--artifact-dir` to choose that directory, or `--out-file` when a fixed path is
29
- intentional.
42
+ `--artifact-dir` to choose that directory, or `--out-file` when a fixed path is
43
+ intentional.
44
+
45
+ `launch-app` queries Android LAUNCHER activities before starting the app. If a
46
+ debug dependency exposes multiple launcher entries, it returns
47
+ `launcher_ambiguous` with the candidates instead of guessing. Use
48
+ `launch-activity` or `launch-app --activity/--component` to choose the intended
49
+ entry point explicitly.
@@ -68,10 +68,12 @@ Device/action commands:
68
68
  keyboard-state Read Android soft keyboard visibility from dumpsys.
69
69
  hide-keyboard Hide the Android soft keyboard when it is visible.
70
70
 
71
- App/permission commands:
72
- install-apk Install an APK and assist device-side installer screens.
73
- launch-native-test Launch the debug native Android bridge test Activity.
74
- launch-flutter Launch the Flutter Activity.
71
+ App/permission commands:
72
+ install-apk Install an APK and assist device-side installer screens.
73
+ launch-app Launch the target package LAUNCHER Activity.
74
+ launch-activity Launch an explicit Android Activity component.
75
+ launch-native-test Launch the debug native Android bridge test Activity.
76
+ launch-flutter Launch the Flutter Activity.
75
77
  permission-state Read Android runtime permission state.
76
78
  permission-grant Grant an Android runtime permission.
77
79
  permission-revoke Revoke an Android runtime permission.
@@ -92,7 +94,13 @@ Options:
92
94
  --adb-timeout-ms <ms> Timeout for ADB subprocesses.
93
95
  --out-file <path> Screenshot output path.
94
96
  --artifact-dir <path> Directory for generated screenshot/artifact defaults.
95
- --apk-path <path> APK path used by install-apk.
97
+ --apk-path <path> APK path used by install-apk.
98
+ --activity <name> Activity class for launch-activity or launch-app override.
99
+ --component <pkg/act> Explicit Android component for launch-activity.
100
+ --action <name> Intent action for launch-activity.
101
+ --category <name> Intent category for launch-activity; may be repeated.
102
+ --data <uri> Intent data URI for launch-activity.
103
+ --extra <key=value> String intent extra for launch-activity; may be repeated.
96
104
  --text <text> Text used by Unicode-safe bridge input commands.
97
105
  --value <text> Text value used by h5-input or flutter-h5-input.
98
106
  --selector <css> CSS selector used by H5 commands.
@@ -319,11 +327,15 @@ async function runCommand(command, options, ctx) {
319
327
  return appopsSet(ctx, requiredString(options.op, 'op'), requiredString(options.mode, 'mode'));
320
328
  case 'tap-uia-text':
321
329
  return tapUiaText(ctx, requiredString(options.targetText, 'targetText'), options);
322
- case 'permission-dialog':
323
- return permissionDialog(ctx, options);
324
- case 'launch-native-test':
325
- return launchNativeTest(ctx);
326
- case 'launch-flutter':
330
+ case 'permission-dialog':
331
+ return permissionDialog(ctx, options);
332
+ case 'launch-app':
333
+ return launchApp(ctx, options);
334
+ case 'launch-activity':
335
+ return launchActivity(ctx, options);
336
+ case 'launch-native-test':
337
+ return launchNativeTest(ctx);
338
+ case 'launch-flutter':
327
339
  return launchFlutter(ctx, options.initialRoute || '');
328
340
  case 'smoke':
329
341
  return smoke(ctx, options);
@@ -347,16 +359,25 @@ function parseArgs(argv) {
347
359
  const rawName = arg.slice(2);
348
360
  const name = rawName.replace(/-([a-z])/g, (_, value) => value.toUpperCase());
349
361
  const next = argv[index + 1];
350
- if (next === undefined || next.startsWith('--')) {
351
- options[name] = true;
352
- continue;
353
- }
354
- options[name] = next;
355
- index += 1;
356
- }
357
- return { command, options };
358
- }
359
-
362
+ if (next === undefined || next.startsWith('--')) {
363
+ appendOption(options, name, true);
364
+ continue;
365
+ }
366
+ appendOption(options, name, next);
367
+ index += 1;
368
+ }
369
+ return { command, options };
370
+ }
371
+
372
+ function appendOption(options, name, value) {
373
+ if (name === 'extra' || name === 'category') {
374
+ if (!Array.isArray(options[name])) options[name] = [];
375
+ options[name].push(value);
376
+ return;
377
+ }
378
+ options[name] = value;
379
+ }
380
+
360
381
  async function adb(ctx, args, { binary = false } = {}) {
361
382
  const allArgs = adbArgs(ctx, args);
362
383
  return new Promise((resolve, reject) => {
@@ -3664,13 +3685,17 @@ async function resolveLogcatPid(ctx, options) {
3664
3685
  return '';
3665
3686
  }
3666
3687
 
3667
- function filterLogcat(text, options) {
3668
- const tags = splitCsv(options.tag || options.tags);
3669
- const grep = options.grep ? String(options.grep) : '';
3670
- const grepCaseSensitive = Boolean(options.grepCaseSensitive);
3671
- const minLevel = priorityValue(options.level || options.minLevel || '');
3672
- const pid = options.pid ? String(options.pid) : '';
3673
- const lines = String(text || '').split(/\r?\n/);
3688
+ function filterLogcat(text, options) {
3689
+ const tags = splitCsv(options.tag || options.tags);
3690
+ const grep = options.grep ? String(options.grep) : '';
3691
+ const grepCaseSensitive = Boolean(options.grepCaseSensitive);
3692
+ const minLevel = priorityValue(options.level || options.minLevel || '');
3693
+ const pid = options.pid ? String(options.pid) : '';
3694
+ const requiresAppPid = options.appPid || options.packagePid || options.pid === 'current';
3695
+ if (requiresAppPid && !pid) {
3696
+ return '';
3697
+ }
3698
+ const lines = String(text || '').split(/\r?\n/);
3674
3699
  const filtered = [];
3675
3700
  let previousIncluded = false;
3676
3701
  for (const line of lines) {
@@ -3753,19 +3778,134 @@ function adbFollow(ctx, args, durationMs) {
3753
3778
  });
3754
3779
  }
3755
3780
 
3756
- async function launchNativeTest(ctx) {
3757
- const component = `${ctx.packageName}/${ctx.nativeActivity}`;
3758
- await adb(ctx, ['shell', 'am', 'start', '-n', component]);
3759
- return { ok: true, transport: 'adb', component };
3760
- }
3761
-
3762
- async function launchFlutter(ctx, initialRoute) {
3763
- const component = `${ctx.packageName}/${ctx.flutterActivity}`;
3764
- const args = ['shell', 'am', 'start', '-n', component];
3765
- if (initialRoute) args.push('-e', 'ai_app_initial_route', initialRoute);
3766
- await adb(ctx, args);
3767
- return { ok: true, transport: 'adb', component, initialRoute };
3768
- }
3781
+ async function launchApp(ctx, options = {}) {
3782
+ if (options.component || options.activity) {
3783
+ return launchActivity(ctx, options);
3784
+ }
3785
+
3786
+ const candidates = await launcherActivityCandidates(ctx);
3787
+ if (candidates.length === 0) {
3788
+ return {
3789
+ ok: false,
3790
+ error: 'launcher_not_found',
3791
+ packageName: ctx.packageName,
3792
+ launcherCandidates: candidates,
3793
+ };
3794
+ }
3795
+ if (candidates.length > 1) {
3796
+ return {
3797
+ ok: false,
3798
+ error: 'launcher_ambiguous',
3799
+ packageName: ctx.packageName,
3800
+ launcherCandidates: candidates,
3801
+ suggestion: 'Pass --component or --activity to choose the intended launcher Activity.',
3802
+ };
3803
+ }
3804
+
3805
+ return startActivity(ctx, candidates[0], options, {
3806
+ packageName: ctx.packageName,
3807
+ launcherCandidates: candidates,
3808
+ });
3809
+ }
3810
+
3811
+ async function launchActivity(ctx, options = {}) {
3812
+ const component = normalizeActivityComponent(
3813
+ ctx.packageName,
3814
+ options.component || requiredString(options.activity, 'activity'),
3815
+ );
3816
+ return startActivity(ctx, component, options, { packageName: ctx.packageName });
3817
+ }
3818
+
3819
+ async function launchNativeTest(ctx) {
3820
+ const component = normalizeActivityComponent(ctx.packageName, ctx.nativeActivity);
3821
+ return startActivity(ctx, component, {}, { packageName: ctx.packageName });
3822
+ }
3823
+
3824
+ async function launchFlutter(ctx, initialRoute) {
3825
+ const component = normalizeActivityComponent(ctx.packageName, ctx.flutterActivity);
3826
+ const options = initialRoute ? { extra: [`ai_app_initial_route=${initialRoute}`] } : {};
3827
+ const result = await startActivity(ctx, component, options, { packageName: ctx.packageName });
3828
+ return { ...result, initialRoute };
3829
+ }
3830
+
3831
+ async function launcherActivityCandidates(ctx) {
3832
+ const result = await adb(ctx, [
3833
+ 'shell',
3834
+ 'cmd',
3835
+ 'package',
3836
+ 'query-activities',
3837
+ '--brief',
3838
+ '-a',
3839
+ 'android.intent.action.MAIN',
3840
+ '-c',
3841
+ 'android.intent.category.LAUNCHER',
3842
+ ctx.packageName,
3843
+ ]);
3844
+ return parseLauncherActivityCandidates(result.stdout);
3845
+ }
3846
+
3847
+ function parseLauncherActivityCandidates(stdout) {
3848
+ const candidates = [];
3849
+ for (const line of String(stdout || '').split(/\r?\n/)) {
3850
+ const candidate = line.trim();
3851
+ if (/^[A-Za-z0-9_.$]+\/[A-Za-z0-9_.$]+$/.test(candidate) && !candidates.includes(candidate)) {
3852
+ candidates.push(candidate);
3853
+ }
3854
+ }
3855
+ return candidates;
3856
+ }
3857
+
3858
+ function normalizeActivityComponent(packageName, activityOrComponent) {
3859
+ const value = requiredString(activityOrComponent, 'activity');
3860
+ if (value.includes('/')) return value;
3861
+ return `${packageName}/${value}`;
3862
+ }
3863
+
3864
+ async function startActivity(ctx, component, options = {}, extraResult = {}) {
3865
+ const args = buildAmStartArgs(component, options);
3866
+ const result = await adb(ctx, args);
3867
+ return {
3868
+ ok: true,
3869
+ transport: 'adb',
3870
+ component,
3871
+ ...extraResult,
3872
+ stdout: result.stdout.trim(),
3873
+ stderr: result.stderr.trim(),
3874
+ };
3875
+ }
3876
+
3877
+ function buildAmStartArgs(component, options = {}) {
3878
+ const args = ['shell', 'am', 'start'];
3879
+ if (options.action) args.push('-a', options.action);
3880
+ for (const category of optionList(options.category)) {
3881
+ args.push('-c', category);
3882
+ }
3883
+ if (options.data) args.push('-d', options.data);
3884
+ for (const extra of parseStartExtras(options.extra)) {
3885
+ args.push('-e', extra.key, extra.value);
3886
+ }
3887
+ args.push('-n', component);
3888
+ return args;
3889
+ }
3890
+
3891
+ function parseStartExtras(rawExtras) {
3892
+ return optionList(rawExtras).map((rawExtra) => {
3893
+ const value = String(rawExtra);
3894
+ const separator = value.indexOf('=');
3895
+ if (separator <= 0) {
3896
+ throw new Error('extra must use key=value');
3897
+ }
3898
+ return {
3899
+ key: value.slice(0, separator),
3900
+ value: value.slice(separator + 1),
3901
+ };
3902
+ });
3903
+ }
3904
+
3905
+ function optionList(value) {
3906
+ if (value === undefined || value === null || value === false || value === '') return [];
3907
+ return Array.isArray(value) ? value : [value];
3908
+ }
3769
3909
 
3770
3910
  async function smoke(ctx, options) {
3771
3911
  const summary = {
@@ -4131,9 +4271,10 @@ module.exports = {
4131
4271
  compactUiaTree,
4132
4272
  defaultArtifactDirectory,
4133
4273
  defaultArtifactPath,
4134
- findFlutterNode,
4135
- findTappableNodeByText,
4136
- firstErrorLine,
4274
+ findFlutterNode,
4275
+ findTappableNodeByText,
4276
+ filterLogcat,
4277
+ firstErrorLine,
4137
4278
  flutterNodePoint,
4138
4279
  flutterPhysicalViewport,
4139
4280
  helpText,
@@ -4141,10 +4282,14 @@ module.exports = {
4141
4282
  isAdbInputTextSafe,
4142
4283
  isLikelyInstallerSurface,
4143
4284
  nodeTapState,
4144
- normalizeBridgeError,
4145
- parseWebViewDevToolsSockets,
4146
- parseKeyboardState,
4147
- parseUiaBounds,
4285
+ normalizeBridgeError,
4286
+ normalizeActivityComponent,
4287
+ parseWebViewDevToolsSockets,
4288
+ parseArgs,
4289
+ parseKeyboardState,
4290
+ parseLauncherActivityCandidates,
4291
+ parseStartExtras,
4292
+ parseUiaBounds,
4148
4293
  parseUiaViewport,
4149
4294
  parseComponentFromWindowLine,
4150
4295
  parseForegroundWindow,
package/bin/mcp-server.js CHANGED
@@ -4,11 +4,19 @@ const { spawn } = require('child_process');
4
4
  const path = require('path');
5
5
 
6
6
  const packageInfo = require('../package.json');
7
- const bridgeDir = __dirname;
8
- const cliScript = path.join(bridgeDir, 'ai-app-bridge.js');
9
- const nodeBinary = process.env.AI_APP_BRIDGE_NODE || process.execPath;
10
-
11
- let buffer = Buffer.alloc(0);
7
+ const bridgeDir = __dirname;
8
+ const cliScript = path.join(bridgeDir, 'ai-app-bridge.js');
9
+ const nodeBinary = process.env.AI_APP_BRIDGE_NODE || process.execPath;
10
+ const supportedProtocolVersions = ['2025-06-18', '2024-11-05'];
11
+ const defaultProtocolVersion = supportedProtocolVersions[0];
12
+ const mcpSurface = (process.env.AI_APP_BRIDGE_MCP_SURFACE || 'compact').toLowerCase();
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.',
15
+ 'Default surface is compact: call capabilities to discover domains, then call run with a command and arguments.',
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
+ ].join(' ');
18
+
19
+ let buffer = Buffer.alloc(0);
12
20
 
13
21
  process.stdin.on('data', (chunk) => {
14
22
  buffer = Buffer.concat([buffer, chunk]);
@@ -70,20 +78,27 @@ async function handleMessage(body) {
70
78
  }
71
79
 
72
80
  try {
73
- if (message.method === 'initialize') {
74
- sendResult(message.id, {
75
- protocolVersion: message.params?.protocolVersion || '2024-11-05',
76
- capabilities: {
77
- tools: {},
78
- },
79
- serverInfo: {
80
- name: 'ai-app-bridge',
81
- version: packageInfo.version,
82
- },
83
- });
84
- return;
85
- }
86
-
81
+ if (message.method === 'initialize') {
82
+ sendResult(message.id, {
83
+ protocolVersion: negotiateProtocolVersion(message.params?.protocolVersion),
84
+ capabilities: {
85
+ tools: {},
86
+ },
87
+ serverInfo: {
88
+ name: 'ai-app-bridge',
89
+ title: 'AI App Bridge',
90
+ version: packageInfo.version,
91
+ },
92
+ instructions: serverInstructions,
93
+ });
94
+ return;
95
+ }
96
+
97
+ if (message.method === 'ping') {
98
+ sendResult(message.id, {});
99
+ return;
100
+ }
101
+
87
102
  if (message.method === 'tools/list') {
88
103
  sendResult(message.id, { tools: toolDefinitions() });
89
104
  return;
@@ -101,10 +116,46 @@ async function handleMessage(body) {
101
116
  } catch (error) {
102
117
  sendError(message.id, -32000, error.message || String(error));
103
118
  }
104
- }
105
-
106
- function toolDefinitions() {
107
- return [
119
+ }
120
+
121
+ function negotiateProtocolVersion(requestedVersion) {
122
+ if (supportedProtocolVersions.includes(requestedVersion)) {
123
+ return requestedVersion;
124
+ }
125
+ return defaultProtocolVersion;
126
+ }
127
+
128
+ function toolDefinitions() {
129
+ if (mcpSurface === 'full' || mcpSurface === 'legacy') {
130
+ return fullToolDefinitions();
131
+ }
132
+ return compactToolDefinitions();
133
+ }
134
+
135
+ function compactToolDefinitions() {
136
+ return [
137
+ bridgeTool('capabilities', 'List AI App Bridge capability domains and commands. Call this first when planning Android app automation; then use run to execute the selected command.', {
138
+ domain: { type: 'string', description: 'Optional domain filter such as core, app, action, flutter, webview, or diagnostics.' },
139
+ command: { type: 'string', description: 'Optional command name for detailed arguments, such as install-apk, launch-app, tree, input-text, or webview-network.' },
140
+ includeOptions: { type: 'boolean', description: 'Include per-command argument names. Defaults to false to keep output compact.' },
141
+ }),
142
+ bridgeTool('run', 'Run an AI App Bridge command. Use capabilities first to choose the command. Always pass packageName for app-specific commands.', {
143
+ command: { type: 'string', description: 'Command name from capabilities, using CLI form such as status, install-apk, launch-app, input-text, tree, webview-network, or logcat.' },
144
+ packageName: { type: 'string', description: 'Target Android package for app-specific commands. Strongly recommended.' },
145
+ serial: { type: 'string', description: 'ADB serial when multiple devices are connected.' },
146
+ port: { type: 'number', description: 'Explicit bridge port when packageName discovery is not available.' },
147
+ adb: { type: 'string', description: 'ADB executable path or command.' },
148
+ arguments: {
149
+ type: 'object',
150
+ description: 'Command-specific arguments from capabilities. Example: {"apkPath":"app-debug.apk","allowDowngrade":true}.',
151
+ additionalProperties: true,
152
+ },
153
+ }, ['command']),
154
+ ];
155
+ }
156
+
157
+ function fullToolDefinitions() {
158
+ return [
108
159
  bridgeTool('status', 'Read compact bridge status, app info, capture counts, and Flutter layout summary. If default port 18080 times out, agents should retry with the target Android packageName so the CLI can discover the app bridge port.', {
109
160
  full: { type: 'boolean', description: 'Return the full raw status payload, including large Flutter widget dumps.' },
110
161
  }),
@@ -219,8 +270,10 @@ function toolDefinitions() {
219
270
  installerTimeoutMs: { type: 'number', description: 'Maximum time to keep assisting installer screens after adb install exits. Defaults to 90000 ms.' },
220
271
  intervalMs: { type: 'number', description: 'Installer polling interval. Defaults to 700 ms.' },
221
272
  }, ['apkPath']),
222
- bridgeTool('launch_native_test', 'Launch the debug native Android bridge test Activity.'),
223
- bridgeTool('launch_flutter', 'Launch the Flutter Activity, optionally with an initial route.'),
273
+ 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()),
274
+ bridgeTool('launch_activity', 'Launch an explicit Android Activity component, optionally with action/data/category/string extras.', launchProperties()),
275
+ bridgeTool('launch_native_test', 'Launch the debug native Android bridge test Activity.'),
276
+ bridgeTool('launch_flutter', 'Launch the Flutter Activity, optionally with an initial route.'),
224
277
  bridgeTool('tap', 'Tap device coordinates through ADB.', {
225
278
  tapX: { type: 'number' },
226
279
  tapY: { type: 'number' },
@@ -233,12 +286,12 @@ function toolDefinitions() {
233
286
  targetText: { type: 'string' },
234
287
  timeoutSec: { type: 'number' },
235
288
  }, ['targetText']),
236
- bridgeTool('input_text', 'Set native Android text through the in-app bridge. Use this for Chinese/Unicode; do not use raw adb shell input text for non-ASCII text.', {
289
+ bridgeTool('input_text', 'Set native Android text through the in-app bridge. Use this for Chinese/Unicode; always pass packageName so the tool targets the intended app.', {
237
290
  text: { type: 'string', description: 'Text to set in the focused or coordinate-matched native EditText.' },
238
291
  tapX: { type: 'number', description: 'Optional X coordinate used to choose a native EditText target.' },
239
292
  tapY: { type: 'number', description: 'Optional Y coordinate used to choose a native EditText target.' },
240
293
  hideKeyboard: { type: 'boolean', description: 'Hide the soft keyboard after setting text.' },
241
- }, ['text']),
294
+ }, ['text', 'packageName']),
242
295
  bridgeTool('keyboard_state', 'Read Android soft-keyboard visibility from dumpsys input_method.'),
243
296
  bridgeTool('hide_keyboard', 'Hide the Android soft keyboard when it is visible.', {
244
297
  force: { type: 'boolean', description: 'Send keyboard-dismiss keys even when the visibility probe says the keyboard is hidden.' },
@@ -287,14 +340,29 @@ function toolDefinitions() {
287
340
  ];
288
341
  }
289
342
 
290
- function bridgeTool(name, description, properties = {}, required = []) {
291
- return {
292
- name,
293
- description,
294
- inputSchema: baseSchema(properties, required),
295
- };
296
- }
297
-
343
+ function bridgeTool(name, description, properties = {}, required = []) {
344
+ return {
345
+ name,
346
+ description,
347
+ inputSchema: baseSchema(properties, required),
348
+ };
349
+ }
350
+
351
+ function launchProperties() {
352
+ return {
353
+ activity: { type: 'string', description: 'Activity class, such as .MainActivity or com.example.MainActivity.' },
354
+ component: { type: 'string', description: 'Explicit Android component, such as com.example/.MainActivity.' },
355
+ action: { type: 'string', description: 'Intent action for explicit Activity launch.' },
356
+ category: { type: 'string', description: 'Intent category.' },
357
+ data: { type: 'string', description: 'Intent data URI.' },
358
+ extra: {
359
+ type: 'object',
360
+ additionalProperties: { type: 'string' },
361
+ description: 'String intent extras as an object of key/value pairs.',
362
+ },
363
+ };
364
+ }
365
+
298
366
  function baseSchema(extraProperties = {}, extraRequired = []) {
299
367
  return {
300
368
  type: 'object',
@@ -316,19 +384,82 @@ function baseSchema(extraProperties = {}, extraRequired = []) {
316
384
  };
317
385
  }
318
386
 
319
- function h5TargetSchema() {
320
- return {
321
- selector: { type: 'string', description: 'CSS selector for the target DOM element.' },
387
+ function h5TargetSchema() {
388
+ return {
389
+ selector: { type: 'string', description: 'CSS selector for the target DOM element.' },
322
390
  targetText: { type: 'string', description: 'Text, value, aria-label, placeholder, id, name, or role to match.' },
323
391
  exact: { type: 'boolean', description: 'Require exact text match instead of substring match.' },
324
- };
325
- }
326
-
327
- async function callTool(name, args) {
328
- if (name === 'run_smoke') {
329
- return runSmoke(args);
330
- }
331
- const commandMap = {
392
+ };
393
+ }
394
+
395
+ const commandDefinitions = [
396
+ { command: 'status', domain: 'core', summary: 'Read bridge status, app/device metadata, capture counts, and Flutter summary.', targetApp: true, options: ['packageName', 'port', 'serial', 'full'] },
397
+ { command: 'tree', domain: 'core', summary: 'Read Android View tree from the in-app bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes', 'maxDepth'] },
398
+ { command: 'uia-tree', domain: 'core', summary: 'Read UIAutomator XML for the current foreground window.', options: ['serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes'] },
399
+ { command: 'screenshot', domain: 'core', summary: 'Capture a screenshot, with foreground package verification when packageName is supplied.', options: ['serial', 'packageName', 'outFile', 'artifactDir'] },
400
+ { command: 'logs', domain: 'core', summary: 'Read in-app log records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
401
+ { command: 'network', domain: 'core', summary: 'Read in-app network records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'compact', 'urlFilter', 'method', 'statusCode', 'noBodies', 'bodyMaxBytes', 'sinceId', 'sinceMs', 'limit'] },
402
+ { command: 'state', domain: 'core', summary: 'Read in-app state records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
403
+ { command: 'events', domain: 'core', summary: 'Read in-app event records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
404
+ { 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'] },
405
+ { 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'] },
406
+ { 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'] },
407
+ { 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'] },
408
+ { command: 'launch-native-test', domain: 'app', summary: 'Launch the debug native bridge test Activity.', targetApp: true, options: ['serial', 'packageName'] },
409
+ { command: 'launch-flutter', domain: 'app', summary: 'Launch the Flutter Activity, optionally with an initial route.', targetApp: true, options: ['serial', 'packageName', 'initialRoute'] },
410
+ { command: 'permission-state', domain: 'app', summary: 'Read Android runtime permission state.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
411
+ { command: 'permission-grant', domain: 'app', summary: 'Grant an Android runtime permission.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
412
+ { command: 'permission-revoke', domain: 'app', summary: 'Revoke an Android runtime permission.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
413
+ { command: 'permission-dialog', domain: 'app', summary: 'Tap a visible Android permission dialog allow button.', options: ['serial', 'targetText', 'buttonText', 'resourceId', 'attempts', 'intervalMs', 'exact'] },
414
+ { command: 'appops-set', domain: 'app', summary: 'Set an Android app-op mode.', targetApp: true, options: ['serial', 'packageName', 'op', 'mode'] },
415
+ { command: 'tap', domain: 'action', summary: 'Tap device coordinates through ADB.', options: ['serial', 'tapX', 'tapY'] },
416
+ { command: 'tap-text', domain: 'action', summary: 'Tap a visible Android View node by text/contentDescription through the bridge tree.', targetApp: true, options: ['serial', 'packageName', 'targetText', 'noAutoHideKeyboard'] },
417
+ { command: 'tap-uia-text', domain: 'action', summary: 'Tap a UIAutomator node by text without relying on the in-app tree.', options: ['serial', 'targetText', 'exact'] },
418
+ { command: 'wait-text', domain: 'action', summary: 'Wait until text appears in bridge status/tree or UIAutomator output.', targetApp: true, options: ['serial', 'packageName', 'targetText', 'timeoutSec', 'requireText', 'absentText', 'requireActivity'] },
419
+ { command: 'input-text', domain: 'action', summary: 'Set native Android text through the in-app bridge; use this for Chinese/Unicode.', targetApp: true, options: ['serial', 'packageName', 'text', 'tapX', 'tapY', 'hideKeyboard'] },
420
+ { command: 'keyboard-state', domain: 'action', summary: 'Read Android soft keyboard visibility.', options: ['serial'] },
421
+ { command: 'hide-keyboard', domain: 'action', summary: 'Hide the Android soft keyboard.', options: ['serial', 'force', 'intervalMs'] },
422
+ { command: 'swipe', domain: 'action', summary: 'Swipe device coordinates through ADB.', options: ['serial', 'startX', 'startY', 'endX', 'endY', 'durationMs'] },
423
+ { command: 'keyevent', domain: 'action', summary: 'Send an Android keyevent through ADB.', options: ['serial', 'keyCode'] },
424
+ { command: 'flutter-tree', domain: 'flutter', summary: 'Read the latest Flutter layout snapshot.', targetApp: true, options: ['serial', 'packageName', 'port'] },
425
+ { command: 'flutter-nodes', domain: 'flutter', summary: 'Read Flutter operable nodes.', targetApp: true, options: ['serial', 'packageName', 'port'] },
426
+ { command: 'flutter-action', domain: 'flutter', summary: 'Dispatch a raw Flutter action payload.', targetApp: true, options: ['serial', 'packageName', 'payload'] },
427
+ { command: 'tap-flutter-text', domain: 'flutter', summary: 'Tap a Flutter node by visible text.', targetApp: true, options: ['serial', 'packageName', 'targetText'] },
428
+ { command: 'input-flutter-text', domain: 'flutter', summary: 'Set Flutter TextField text through the Flutter action bridge.', targetApp: true, options: ['serial', 'packageName', 'text', 'tapX', 'tapY', 'hideKeyboard'] },
429
+ { command: 'scroll-flutter', domain: 'flutter', summary: 'Scroll Flutter content by delta or until text is visible.', targetApp: true, options: ['serial', 'packageName', 'targetText', 'delta', 'maxSwipes'] },
430
+ { command: 'h5-dom', domain: 'webview', summary: 'Read native Android WebView DOM.', targetApp: true, options: ['serial', 'packageName', 'port'] },
431
+ { command: 'h5-eval', domain: 'webview', summary: 'Execute JavaScript in the current native Android WebView.', targetApp: true, options: ['serial', 'packageName', 'script'] },
432
+ { command: 'h5-click', domain: 'webview', summary: 'Click a native WebView element by selector or text.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'exact'] },
433
+ { command: 'h5-input', domain: 'webview', summary: 'Set text in a native WebView input.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'value', 'exact'] },
434
+ { command: 'h5-wait', domain: 'webview', summary: 'Wait for native WebView text or selector.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'timeoutSec', 'intervalMs'] },
435
+ { command: 'h5-scroll', domain: 'webview', summary: 'Scroll native WebView content or a DOM element into view.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'deltaX', 'deltaY'] },
436
+ { command: 'flutter-h5-dom', domain: 'webview', summary: 'Read DOM through a Flutter H5 adapter.', targetApp: true, options: ['serial', 'packageName', 'port'] },
437
+ { command: 'flutter-h5-eval', domain: 'webview', summary: 'Execute JavaScript through a Flutter H5 adapter.', targetApp: true, options: ['serial', 'packageName', 'script'] },
438
+ { command: 'flutter-h5-click', domain: 'webview', summary: 'Click a Flutter H5 DOM element.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'exact'] },
439
+ { command: 'flutter-h5-input', domain: 'webview', summary: 'Set text in a Flutter H5 input.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'value', 'exact'] },
440
+ { command: 'flutter-h5-wait', domain: 'webview', summary: 'Wait for Flutter H5 text or selector.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'timeoutSec', 'intervalMs'] },
441
+ { command: 'flutter-h5-scroll', domain: 'webview', summary: 'Scroll Flutter H5 content or a DOM element into view.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'deltaX', 'deltaY'] },
442
+ { command: 'webview-pages', domain: 'webview', summary: 'List attachable Android WebView DevTools/CDP pages.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'keepForward'] },
443
+ { command: 'webview-network', domain: 'webview', summary: 'Capture WebView Network events through CDP.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'urlFilter', 'durationMs', 'script', 'includeResponseBody', 'bodyMaxBytes', 'maxEvents'] },
444
+ { 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'] },
445
+ { command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
446
+ { command: 'remove-forward', domain: 'advanced', summary: 'Remove the ADB port forward for the bridge.', options: ['serial', 'port'] },
447
+ { command: 'smoke', domain: 'diagnostics', summary: 'Run the native sample smoke test.', options: ['serial', 'packageName', 'outFile', 'artifactDir', 'skipFlutterLaunch'] },
448
+ ];
449
+
450
+ const commandByName = new Map(commandDefinitions.map((definition) => [definition.command, definition]));
451
+
452
+ async function callTool(name, args) {
453
+ if (name === 'capabilities') {
454
+ return toolJson(capabilityPayload(args));
455
+ }
456
+ if (name === 'run') {
457
+ return runGeneric(args);
458
+ }
459
+ if (name === 'run_smoke') {
460
+ return runSmoke(args);
461
+ }
462
+ const commandMap = {
332
463
  flutter_tree: 'flutter-tree',
333
464
  h5_dom: 'h5-dom',
334
465
  h5_eval: 'h5-eval',
@@ -346,9 +477,11 @@ async function callTool(name, args) {
346
477
  tap_flutter_text: 'tap-flutter-text',
347
478
  input_flutter_text: 'input-flutter-text',
348
479
  uia_tree: 'uia-tree',
349
- install_apk: 'install-apk',
350
- launch_native_test: 'launch-native-test',
351
- launch_flutter: 'launch-flutter',
480
+ install_apk: 'install-apk',
481
+ launch_app: 'launch-app',
482
+ launch_activity: 'launch-activity',
483
+ launch_native_test: 'launch-native-test',
484
+ launch_flutter: 'launch-flutter',
352
485
  tap_text: 'tap-text',
353
486
  wait_text: 'wait-text',
354
487
  input_text: 'input-text',
@@ -363,16 +496,86 @@ async function callTool(name, args) {
363
496
  appops_set: 'appops-set',
364
497
  tap_uia_text: 'tap-uia-text',
365
498
  permission_dialog: 'permission-dialog',
366
- };
367
- const command = commandMap[name] || name;
368
- return runBridge(command, args);
369
- }
499
+ };
500
+ const command = commandMap[name] || name;
501
+ return runBridgeChecked(command, args);
502
+ }
503
+
504
+ function capabilityPayload(args = {}) {
505
+ const includeOptions = Boolean(args.includeOptions);
506
+ const requestedCommand = args.command ? normalizeCommandName(args.command) : '';
507
+ if (requestedCommand) {
508
+ const definition = commandByName.get(requestedCommand);
509
+ return {
510
+ ok: Boolean(definition),
511
+ command: requestedCommand,
512
+ ...(definition ? shapeCommandDefinition(definition, true) : { error: 'unknown_command' }),
513
+ };
514
+ }
515
+
516
+ const requestedDomain = args.domain ? String(args.domain) : '';
517
+ const domains = {};
518
+ for (const definition of commandDefinitions) {
519
+ if (requestedDomain && definition.domain !== requestedDomain) continue;
520
+ if (!domains[definition.domain]) domains[definition.domain] = [];
521
+ domains[definition.domain].push(shapeCommandDefinition(definition, includeOptions));
522
+ }
523
+ return {
524
+ ok: true,
525
+ surface: mcpSurface === 'full' || mcpSurface === 'legacy' ? 'full' : 'compact',
526
+ 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.',
527
+ domains,
528
+ };
529
+ }
530
+
531
+ function shapeCommandDefinition(definition, includeOptions) {
532
+ return {
533
+ command: definition.command,
534
+ summary: definition.summary,
535
+ targetApp: Boolean(definition.targetApp),
536
+ ...(includeOptions ? { options: definition.options || [] } : {}),
537
+ };
538
+ }
539
+
540
+ async function runGeneric(args = {}) {
541
+ const command = normalizeCommandName(args.command);
542
+ if (!commandByName.has(command)) {
543
+ return toolText(`unknown command: ${args.command || ''}`, true);
544
+ }
545
+ const commandArgs = {
546
+ ...(args.arguments && typeof args.arguments === 'object' ? args.arguments : {}),
547
+ };
548
+ for (const key of ['adb', 'serial', 'port', 'packageName']) {
549
+ if (args[key] !== undefined && commandArgs[key] === undefined) {
550
+ commandArgs[key] = args[key];
551
+ }
552
+ }
553
+ return runBridgeChecked(command, commandArgs);
554
+ }
555
+
556
+ function normalizeCommandName(value) {
557
+ return String(value || '').trim().replace(/_/g, '-');
558
+ }
559
+
560
+ function runBridgeChecked(command, args = {}) {
561
+ const definition = commandByName.get(command);
562
+ if (definition?.targetApp && !args.packageName && !args.port) {
563
+ return toolText(`${command}: packageName or explicit port is required in MCP mode so the command cannot fall back to a default package.`, true);
564
+ }
565
+ return runBridge(command, args);
566
+ }
370
567
 
371
568
  async function runBridge(command, args) {
372
569
  const cliArgs = [cliScript, command];
373
570
  addCommonArgs(cliArgs, args);
374
- addArg(cliArgs, 'initial-route', args.initialRoute);
375
- addArg(cliArgs, 'out-file', args.outFile);
571
+ addArg(cliArgs, 'initial-route', args.initialRoute);
572
+ addArg(cliArgs, 'activity', args.activity);
573
+ addArg(cliArgs, 'component', args.component);
574
+ addArg(cliArgs, 'action', args.action);
575
+ addRepeatedArg(cliArgs, 'category', args.category);
576
+ addArg(cliArgs, 'data', args.data);
577
+ addExtraArgs(cliArgs, args.extra);
578
+ addArg(cliArgs, 'out-file', args.outFile);
376
579
  addArg(cliArgs, 'artifact-dir', args.artifactDir || defaultArtifactDirFor(command, args));
377
580
  addArg(cliArgs, 'apk-path', args.apkPath);
378
581
  addArg(cliArgs, 'tap-x', args.tapX);
@@ -459,13 +662,35 @@ function addCommonArgs(cliArgs, args) {
459
662
  addArg(cliArgs, 'package-name', args.packageName);
460
663
  }
461
664
 
462
- function addArg(cliArgs, name, value) {
463
- if (value === undefined || value === null || value === '' || value === false) {
464
- return;
465
- }
466
- cliArgs.push(`--${name}`, String(value));
467
- }
468
-
665
+ function addArg(cliArgs, name, value) {
666
+ if (value === undefined || value === null || value === '' || value === false) {
667
+ return;
668
+ }
669
+ cliArgs.push(`--${name}`, String(value));
670
+ }
671
+
672
+ function addRepeatedArg(cliArgs, name, value) {
673
+ if (Array.isArray(value)) {
674
+ for (const item of value) addArg(cliArgs, name, item);
675
+ return;
676
+ }
677
+ addArg(cliArgs, name, value);
678
+ }
679
+
680
+ function addExtraArgs(cliArgs, value) {
681
+ if (Array.isArray(value)) {
682
+ for (const item of value) addArg(cliArgs, 'extra', item);
683
+ return;
684
+ }
685
+ if (value && typeof value === 'object') {
686
+ for (const [key, extraValue] of Object.entries(value)) {
687
+ addArg(cliArgs, 'extra', `${key}=${extraValue}`);
688
+ }
689
+ return;
690
+ }
691
+ addArg(cliArgs, 'extra', value);
692
+ }
693
+
469
694
  function runProcess(cliArgs) {
470
695
  return new Promise((resolve) => {
471
696
  const child = spawn(nodeBinary, cliArgs, {
@@ -490,12 +715,23 @@ function runProcess(cliArgs) {
490
715
  code === 0 ? '' : `exitCode: ${code}`,
491
716
  retryWithPackageNameHint(cliArgs, stdout, stderr, code),
492
717
  ].filter(Boolean).join('\n\n');
493
- resolve(toolText(text || 'ok', code !== 0));
494
- });
495
- });
496
- }
497
-
498
- function retryWithPackageNameHint(cliArgs, stdout, stderr, code) {
718
+ resolve(toolText(text || emptyProcessText(cliArgs), code !== 0));
719
+ });
720
+ });
721
+ }
722
+
723
+ function emptyProcessText(cliArgs) {
724
+ const command = cliArgs[1] || '';
725
+ if (command === 'logcat' && cliArgs.includes('--app-pid')) {
726
+ return 'logcat: no matching lines for current app pid';
727
+ }
728
+ if (command === 'logcat') {
729
+ return 'logcat: no matching lines';
730
+ }
731
+ return 'ok';
732
+ }
733
+
734
+ function retryWithPackageNameHint(cliArgs, stdout, stderr, code) {
499
735
  if (code === 0 || cliArgs.includes('--package-name') || cliArgs.includes('--port')) {
500
736
  return '';
501
737
  }
@@ -506,21 +742,25 @@ function retryWithPackageNameHint(cliArgs, stdout, stderr, code) {
506
742
  return 'agentHint: Default bridge port 18080 accepted the connection but did not answer. If you know the target app, retry this tool with packageName so the CLI can discover that app bridge port.';
507
743
  }
508
744
 
509
- function toolText(text, isError = false) {
510
- return {
511
- content: [
512
- {
513
- type: 'text',
745
+ function toolText(text, isError = false) {
746
+ return {
747
+ content: [
748
+ {
749
+ type: 'text',
514
750
  text,
515
751
  },
516
752
  ],
517
- isError,
518
- };
519
- }
520
-
521
- function sendResult(id, result) {
522
- send({ jsonrpc: '2.0', id, result });
523
- }
753
+ isError,
754
+ };
755
+ }
756
+
757
+ function toolJson(value, isError = false) {
758
+ return toolText(JSON.stringify(value, null, 2), isError);
759
+ }
760
+
761
+ function sendResult(id, result) {
762
+ send({ jsonrpc: '2.0', id, result });
763
+ }
524
764
 
525
765
  function sendError(id, code, message) {
526
766
  send({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobileaidev/ai-app-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Desktop CLI and MCP server for AI App Bridge.",
5
5
  "repository": {
6
6
  "type": "git",