@mobileaidev/ai-app-bridge 0.2.9 → 0.2.11

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/bin/mcp-server.js CHANGED
@@ -1,1104 +1,1236 @@
1
- #!/usr/bin/env node
2
-
3
- const { spawn } = require('child_process');
4
- const path = require('path');
5
-
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
- 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, data reset, 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.',
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require('child_process');
4
+ const path = require('path');
5
+
6
+ const packageInfo = require('../package.json');
7
+ const { IOSBridgeProvider } = require('./ios-provider');
8
+ const { WebBridgeProvider } = require('./web-provider');
9
+ const bridgeDir = __dirname;
10
+ const cliScript = path.join(bridgeDir, 'ai-app-bridge.js');
11
+ const nodeBinary = process.env.AI_APP_BRIDGE_NODE || process.execPath;
12
+ const supportedProtocolVersions = ['2025-06-18', '2024-11-05'];
13
+ const defaultProtocolVersion = supportedProtocolVersions[0];
14
+ const mcpSurface = (process.env.AI_APP_BRIDGE_MCP_SURFACE || 'compact').toLowerCase();
15
+ const serverInstructions = [
16
+ 'AI App Bridge observes and controls Android, iOS, Flutter, and Web targets for agent workflows. Prefer these tools over raw adb, devicectl, or browser-specific scripts when inspecting UI, text, WebView/WKWebView, logs, network, app install, launch, and permissions.',
17
+ 'Default surface is compact: call capabilities to discover domains, then call run with a command and arguments.',
18
+ 'Always pass packageName for Android app-specific commands, or pass an explicit port. For iOS, pass bundleId plus deviceId when more than one iPhone is connected.',
17
19
  'Use freeze-app/thaw-app only as an optional stabilization control for dynamic or transient screens: thaw before reads/actions/captures, freeze after evidence capture only when it helps reasoning, and thaw before the next operation or before finishing so the app is not left frozen.',
18
20
  ].join(' ');
19
-
20
- let buffer = Buffer.alloc(0);
21
- let responseFormat = null;
22
-
23
- function startServer() {
24
- process.stdin.on('data', (chunk) => {
25
- buffer = Buffer.concat([buffer, chunk]);
26
- drainMessages();
27
- });
28
-
29
- process.stdin.on('error', () => {});
30
- }
31
-
32
- function drainMessages() {
33
- while (true) {
34
- const parsed = readNextMessage(buffer);
35
- if (!parsed) {
36
- return;
37
- }
38
- buffer = parsed.remaining;
39
- setResponseFormat(parsed.format);
40
- handleMessage(parsed.body).catch((error) => {
41
- writeLog(`unhandled message error: ${error.stack || error}`);
42
- });
43
- }
44
- }
45
-
46
- function readNextMessage(source) {
47
- const text = source.toString('utf8');
48
- if (/^Content-Length:/i.test(text)) {
49
- return readContentLengthMessage(source);
50
- }
51
- return readLineJsonMessage(source);
52
- }
53
-
54
- function readContentLengthMessage(source) {
55
- const delimiter = findHeaderDelimiter(source);
56
- const headerEnd = delimiter.index;
57
- if (headerEnd < 0) {
58
- return null;
59
- }
60
- const header = source.subarray(0, headerEnd).toString('utf8');
61
- const match = /^Content-Length:\s*(\d+)$/im.exec(header);
62
- if (!match) {
63
- return {
64
- body: source.subarray(headerEnd + delimiter.length).toString('utf8'),
65
- format: 'frame',
66
- remaining: Buffer.alloc(0),
67
- };
68
- }
69
- const contentLength = Number(match[1]);
70
- const messageStart = headerEnd + delimiter.length;
71
- const messageEnd = messageStart + contentLength;
72
- if (source.length < messageEnd) {
73
- return null;
74
- }
75
- return {
76
- body: source.subarray(messageStart, messageEnd).toString('utf8'),
77
- format: 'frame',
78
- remaining: source.subarray(messageEnd),
79
- };
80
- }
81
-
82
- function readLineJsonMessage(source) {
83
- const lfIndex = source.indexOf('\n');
84
- if (lfIndex < 0) {
85
- return null;
86
- }
87
- const lineEnd = lfIndex > 0 && source[lfIndex - 1] === 13 ? lfIndex - 1 : lfIndex;
88
- const body = source.subarray(0, lineEnd).toString('utf8');
89
- return {
90
- body,
91
- format: 'line',
92
- remaining: source.subarray(lfIndex + 1),
93
- };
94
- }
95
-
96
- function setResponseFormat(format) {
97
- if (!responseFormat) {
98
- responseFormat = format;
99
- }
100
- }
101
-
102
- function findHeaderDelimiter(source) {
103
- const crlfIndex = source.indexOf('\r\n\r\n');
104
- const lfIndex = source.indexOf('\n\n');
105
- if (crlfIndex < 0) {
106
- return { index: lfIndex, length: 2 };
107
- }
108
- if (lfIndex < 0 || crlfIndex < lfIndex) {
109
- return { index: crlfIndex, length: 4 };
110
- }
111
- return { index: lfIndex, length: 2 };
112
- }
113
-
114
- async function handleMessage(body) {
115
- let message;
116
- try {
117
- message = JSON.parse(body);
118
- } catch (error) {
119
- sendError(null, -32700, `Parse error: ${error.message}`);
120
- return;
121
- }
122
-
123
- if (!Object.prototype.hasOwnProperty.call(message, 'id')) {
124
- return;
125
- }
126
-
127
- try {
128
- if (message.method === 'initialize') {
129
- sendResult(message.id, {
130
- protocolVersion: negotiateProtocolVersion(message.params?.protocolVersion),
131
- capabilities: {
132
- tools: {},
133
- },
134
- serverInfo: {
135
- name: 'ai-app-bridge',
136
- title: 'AI App Bridge',
137
- version: packageInfo.version,
138
- },
139
- instructions: serverInstructions,
140
- });
141
- return;
142
- }
143
-
144
- if (message.method === 'ping') {
145
- sendResult(message.id, {});
146
- return;
147
- }
148
-
149
- if (message.method === 'tools/list') {
150
- sendResult(message.id, { tools: toolDefinitions() });
151
- return;
152
- }
153
-
154
- if (message.method === 'tools/call') {
155
- const name = message.params?.name;
156
- const args = message.params?.arguments || {};
157
- const result = await callTool(name, args);
158
- sendResult(message.id, result);
159
- return;
160
- }
161
-
162
- sendError(message.id, -32601, `Method not found: ${message.method}`);
163
- } catch (error) {
164
- sendError(message.id, -32000, error.message || String(error));
165
- }
166
- }
167
-
168
- function negotiateProtocolVersion(requestedVersion) {
169
- if (supportedProtocolVersions.includes(requestedVersion)) {
170
- return requestedVersion;
171
- }
172
- return defaultProtocolVersion;
173
- }
174
-
175
- function toolDefinitions() {
176
- if (mcpSurface === 'full' || mcpSurface === 'legacy') {
177
- return fullToolDefinitions();
178
- }
179
- return compactToolDefinitions();
180
- }
181
-
182
- function compactToolDefinitions() {
183
- return [
184
- 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.', {
185
- domain: { type: 'string', description: 'Optional domain filter such as core, app, action, flutter, webview, or diagnostics.' },
186
- command: { type: 'string', description: 'Optional command name for detailed arguments, such as install-apk, launch-app, tree, input-text, or webview-network.' },
187
- includeOptions: { type: 'boolean', description: 'Include per-command argument names. Defaults to false to keep output compact.' },
188
- }),
189
- bridgeTool('run', 'Run an AI App Bridge command. Use capabilities first to choose the command. Always pass packageName for app-specific commands.', {
190
- command: { type: 'string', description: 'Command name from capabilities, using CLI form such as status, install-apk, launch-app, freeze-app, thaw-app, input-text, tree, webview-network, or logcat.' },
191
- packageName: { type: 'string', description: 'Target Android package for app-specific commands. Strongly recommended.' },
192
- serial: { type: 'string', description: 'ADB serial when multiple devices are connected.' },
193
- port: { type: 'number', description: 'Explicit bridge port when packageName discovery is not available.' },
194
- adb: { type: 'string', description: 'ADB executable path or command.' },
195
- arguments: {
196
- type: 'object',
197
- description: 'Command-specific arguments from capabilities. Example: {"apkPath":"app-debug.apk","allowDowngrade":true}.',
198
- additionalProperties: true,
199
- },
200
- }, ['command']),
201
- ];
202
- }
203
-
204
- function fullToolDefinitions() {
205
- return [
206
- 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.', {
207
- full: { type: 'boolean', description: 'Return the full raw status payload, including large Flutter widget dumps.' },
208
- }),
209
- bridgeTool('tree', 'Read the Android View tree from the in-app bridge.'),
210
- bridgeTool('flutter_tree', 'Read the latest Flutter widget/layout snapshot.'),
211
- bridgeTool('flutter_nodes', 'Read Flutter operable nodes from the Flutter action bridge.'),
212
- bridgeTool('tap_flutter_text', 'Tap a Flutter node by visible text through the Flutter-aware bridge path.', {
213
- targetText: { type: 'string', description: 'Flutter node text to tap.' },
214
- }, ['targetText']),
215
- bridgeTool('input_flutter_text', 'Set Flutter TextField text through the Flutter action bridge. Use this for Flutter Chinese/Unicode input; do not use raw adb shell input text.', {
216
- text: { type: 'string', description: 'Text to set.' },
217
- tapX: { type: 'number', description: 'Optional physical X coordinate for the Flutter input target.' },
218
- tapY: { type: 'number', description: 'Optional physical Y coordinate for the Flutter input target.' },
219
- hideKeyboard: { type: 'boolean', description: 'Hide the soft keyboard after setting text.' },
220
- }, ['text']),
221
- bridgeTool('h5_dom', 'Read native Android WebView DOM from the current Activity.'),
222
- bridgeTool('h5_eval', 'Execute debug JavaScript in the current native Android WebView.', {
223
- script: { type: 'string' },
224
- }, ['script']),
225
- bridgeTool('h5_click', 'Click a native Android WebView DOM element by CSS selector or text.', h5TargetSchema(), []),
226
- bridgeTool('h5_input', 'Set text in a native Android WebView input by CSS selector or text.', {
227
- ...h5TargetSchema(),
228
- value: { type: 'string', description: 'Text value to set.' },
229
- }, ['value']),
230
- bridgeTool('h5_wait', 'Wait for a native Android WebView DOM element or body text.', {
231
- ...h5TargetSchema(),
232
- timeoutSec: { type: 'number', description: 'Maximum wait time. Defaults to 10 seconds.' },
233
- intervalMs: { type: 'number', description: 'Polling interval. Defaults to 500 ms.' },
234
- }),
235
- bridgeTool('h5_scroll', 'Scroll a native Android WebView or scroll a DOM element into view.', {
236
- ...h5TargetSchema(),
237
- deltaX: { type: 'number', description: 'Window scroll delta X when no selector/text is supplied.' },
238
- deltaY: { type: 'number', description: 'Window scroll delta Y when no selector/text is supplied.' },
239
- }),
240
- bridgeTool('flutter_h5_dom', 'Read DOM through a Flutter-registered H5 adapter.'),
241
- bridgeTool('flutter_h5_eval', 'Execute JavaScript through a Flutter-registered H5 adapter.', {
242
- script: { type: 'string' },
243
- }, ['script']),
244
- bridgeTool('flutter_h5_click', 'Click a Flutter H5 DOM element by CSS selector or text.', h5TargetSchema(), []),
245
- bridgeTool('flutter_h5_input', 'Set text in a Flutter H5 input by CSS selector or text.', {
246
- ...h5TargetSchema(),
247
- value: { type: 'string', description: 'Text value to set.' },
248
- }, ['value']),
249
- bridgeTool('flutter_h5_wait', 'Wait for a Flutter H5 DOM element or body text.', {
250
- ...h5TargetSchema(),
251
- timeoutSec: { type: 'number', description: 'Maximum wait time. Defaults to 10 seconds.' },
252
- intervalMs: { type: 'number', description: 'Polling interval. Defaults to 500 ms.' },
253
- }),
254
- bridgeTool('flutter_h5_scroll', 'Scroll a Flutter H5 document or DOM element into view.', {
255
- ...h5TargetSchema(),
256
- deltaX: { type: 'number', description: 'Window scroll delta X when no selector/text is supplied.' },
257
- deltaY: { type: 'number', description: 'Window scroll delta Y when no selector/text is supplied.' },
258
- }),
259
- bridgeTool('logs', 'Read generic in-app log records.'),
21
+ const iosProvider = new IOSBridgeProvider();
22
+ const webProvider = new WebBridgeProvider();
23
+
24
+ let buffer = Buffer.alloc(0);
25
+ let responseFormat = null;
26
+
27
+ function startServer() {
28
+ process.stdin.on('data', (chunk) => {
29
+ buffer = Buffer.concat([buffer, chunk]);
30
+ drainMessages();
31
+ });
32
+
33
+ process.stdin.on('error', () => {});
34
+ }
35
+
36
+ function drainMessages() {
37
+ while (true) {
38
+ const parsed = readNextMessage(buffer);
39
+ if (!parsed) {
40
+ return;
41
+ }
42
+ buffer = parsed.remaining;
43
+ setResponseFormat(parsed.format);
44
+ handleMessage(parsed.body).catch((error) => {
45
+ writeLog(`unhandled message error: ${error.stack || error}`);
46
+ });
47
+ }
48
+ }
49
+
50
+ function readNextMessage(source) {
51
+ const text = source.toString('utf8');
52
+ if (/^Content-Length:/i.test(text)) {
53
+ return readContentLengthMessage(source);
54
+ }
55
+ return readLineJsonMessage(source);
56
+ }
57
+
58
+ function readContentLengthMessage(source) {
59
+ const delimiter = findHeaderDelimiter(source);
60
+ const headerEnd = delimiter.index;
61
+ if (headerEnd < 0) {
62
+ return null;
63
+ }
64
+ const header = source.subarray(0, headerEnd).toString('utf8');
65
+ const match = /^Content-Length:\s*(\d+)$/im.exec(header);
66
+ if (!match) {
67
+ return {
68
+ body: source.subarray(headerEnd + delimiter.length).toString('utf8'),
69
+ format: 'frame',
70
+ remaining: Buffer.alloc(0),
71
+ };
72
+ }
73
+ const contentLength = Number(match[1]);
74
+ const messageStart = headerEnd + delimiter.length;
75
+ const messageEnd = messageStart + contentLength;
76
+ if (source.length < messageEnd) {
77
+ return null;
78
+ }
79
+ return {
80
+ body: source.subarray(messageStart, messageEnd).toString('utf8'),
81
+ format: 'frame',
82
+ remaining: source.subarray(messageEnd),
83
+ };
84
+ }
85
+
86
+ function readLineJsonMessage(source) {
87
+ const lfIndex = source.indexOf('\n');
88
+ if (lfIndex < 0) {
89
+ return null;
90
+ }
91
+ const lineEnd = lfIndex > 0 && source[lfIndex - 1] === 13 ? lfIndex - 1 : lfIndex;
92
+ const body = source.subarray(0, lineEnd).toString('utf8');
93
+ return {
94
+ body,
95
+ format: 'line',
96
+ remaining: source.subarray(lfIndex + 1),
97
+ };
98
+ }
99
+
100
+ function setResponseFormat(format) {
101
+ if (!responseFormat) {
102
+ responseFormat = format;
103
+ }
104
+ }
105
+
106
+ function findHeaderDelimiter(source) {
107
+ const crlfIndex = source.indexOf('\r\n\r\n');
108
+ const lfIndex = source.indexOf('\n\n');
109
+ if (crlfIndex < 0) {
110
+ return { index: lfIndex, length: 2 };
111
+ }
112
+ if (lfIndex < 0 || crlfIndex < lfIndex) {
113
+ return { index: crlfIndex, length: 4 };
114
+ }
115
+ return { index: lfIndex, length: 2 };
116
+ }
117
+
118
+ async function handleMessage(body) {
119
+ let message;
120
+ try {
121
+ message = JSON.parse(body);
122
+ } catch (error) {
123
+ sendError(null, -32700, `Parse error: ${error.message}`);
124
+ return;
125
+ }
126
+
127
+ if (!Object.prototype.hasOwnProperty.call(message, 'id')) {
128
+ return;
129
+ }
130
+
131
+ try {
132
+ if (message.method === 'initialize') {
133
+ sendResult(message.id, {
134
+ protocolVersion: negotiateProtocolVersion(message.params?.protocolVersion),
135
+ capabilities: {
136
+ tools: {},
137
+ },
138
+ serverInfo: {
139
+ name: 'ai-app-bridge',
140
+ title: 'AI App Bridge',
141
+ version: packageInfo.version,
142
+ },
143
+ instructions: serverInstructions,
144
+ });
145
+ return;
146
+ }
147
+
148
+ if (message.method === 'ping') {
149
+ sendResult(message.id, {});
150
+ return;
151
+ }
152
+
153
+ if (message.method === 'tools/list') {
154
+ sendResult(message.id, { tools: toolDefinitions() });
155
+ return;
156
+ }
157
+
158
+ if (message.method === 'tools/call') {
159
+ const name = message.params?.name;
160
+ const args = message.params?.arguments || {};
161
+ const result = await callTool(name, args);
162
+ sendResult(message.id, result);
163
+ return;
164
+ }
165
+
166
+ sendError(message.id, -32601, `Method not found: ${message.method}`);
167
+ } catch (error) {
168
+ sendError(message.id, -32000, error.message || String(error));
169
+ }
170
+ }
171
+
172
+ function negotiateProtocolVersion(requestedVersion) {
173
+ if (supportedProtocolVersions.includes(requestedVersion)) {
174
+ return requestedVersion;
175
+ }
176
+ return defaultProtocolVersion;
177
+ }
178
+
179
+ function toolDefinitions() {
180
+ if (mcpSurface === 'full' || mcpSurface === 'legacy') {
181
+ return fullToolDefinitions();
182
+ }
183
+ return compactToolDefinitions();
184
+ }
185
+
186
+ function compactToolDefinitions() {
187
+ return [
188
+ bridgeTool('capabilities', 'List AI App Bridge capability domains and commands. Call this first when planning app automation; then use run to execute the selected command.', {
189
+ domain: { type: 'string', description: 'Optional domain filter such as core, app, action, flutter, webview, ios, web, or diagnostics.' },
190
+ command: { type: 'string', description: 'Optional command name for detailed arguments, such as install-apk, launch-app, tree, input-text, webview-network, ios-setup, or ios-tap.' },
191
+ includeOptions: { type: 'boolean', description: 'Include per-command argument names. Defaults to false to keep output compact.' },
192
+ }),
193
+ bridgeTool('run', 'Run an AI App Bridge command. Use capabilities first to choose the command. Pass packageName for Android app commands and bundleId for iOS app commands.', {
194
+ command: { type: 'string', description: 'Command name from capabilities, using CLI form such as status, install-apk, launch-app, input-text, webview-network, ios-doctor, ios-setup, ios-status, ios-tap, or web-status.' },
195
+ packageName: { type: 'string', description: 'Target Android package for app-specific commands. Strongly recommended.' },
196
+ serial: { type: 'string', description: 'ADB serial when multiple devices are connected.' },
197
+ port: { type: 'number', description: 'Explicit bridge port when packageName discovery is not available.' },
198
+ bundleId: { type: 'string', description: 'Target iOS app bundle identifier for ios-* commands.' },
199
+ deviceId: { type: 'string', description: 'iOS devicectl identifier, UDID, serial number, or device name.' },
200
+ iosHost: { type: 'string', description: 'iOS runtime host or CoreDevice tunnel IP.' },
201
+ iosPort: { type: 'number', description: 'iOS runtime port when auto-discovery is unavailable.' },
202
+ runtimeUrl: { type: 'string', description: 'Explicit iOS runtime base URL.' },
203
+ wdaUrl: { type: 'string', description: 'WebDriverAgent base URL for iOS full-control commands.' },
204
+ wdaSessionId: { type: 'string', description: 'Existing WebDriverAgent session id to reuse.' },
205
+ wdaBundleId: { type: 'string', description: 'Unique WebDriverAgentRunner bundle id for signing.' },
206
+ accessibilityId: { type: 'string', description: 'iOS accessibility identifier for element input.' },
207
+ elementId: { type: 'string', description: 'Existing WDA element id for element input.' },
208
+ adb: { type: 'string', description: 'ADB executable path or command.' },
209
+ arguments: {
210
+ type: 'object',
211
+ description: 'Command-specific arguments from capabilities. Example: {"apkPath":"app-debug.apk","allowDowngrade":true}.',
212
+ additionalProperties: true,
213
+ },
214
+ }, ['command']),
215
+ ];
216
+ }
217
+
218
+ function fullToolDefinitions() {
219
+ return [
220
+ 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.', {
221
+ full: { type: 'boolean', description: 'Return the full raw status payload, including large Flutter widget dumps.' },
222
+ }),
223
+ bridgeTool('tree', 'Read the Android View tree from the in-app bridge.'),
224
+ bridgeTool('flutter_tree', 'Read the latest Flutter widget/layout snapshot.'),
225
+ bridgeTool('flutter_nodes', 'Read Flutter operable nodes from the Flutter action bridge.'),
226
+ bridgeTool('tap_flutter_text', 'Tap a Flutter node by visible text through the Flutter-aware bridge path.', {
227
+ targetText: { type: 'string', description: 'Flutter node text to tap.' },
228
+ }, ['targetText']),
229
+ bridgeTool('input_flutter_text', 'Set Flutter TextField text through the Flutter action bridge. Use this for Flutter Chinese/Unicode input; do not use raw adb shell input text.', {
230
+ text: { type: 'string', description: 'Text to set.' },
231
+ tapX: { type: 'number', description: 'Optional physical X coordinate for the Flutter input target.' },
232
+ tapY: { type: 'number', description: 'Optional physical Y coordinate for the Flutter input target.' },
233
+ hideKeyboard: { type: 'boolean', description: 'Hide the soft keyboard after setting text.' },
234
+ }, ['text']),
235
+ bridgeTool('h5_dom', 'Read native Android WebView DOM from the current Activity.'),
236
+ bridgeTool('h5_eval', 'Execute debug JavaScript in the current native Android WebView.', {
237
+ script: { type: 'string' },
238
+ }, ['script']),
239
+ bridgeTool('h5_click', 'Click a native Android WebView DOM element by CSS selector or text.', h5TargetSchema(), []),
240
+ bridgeTool('h5_input', 'Set text in a native Android WebView input by CSS selector or text.', {
241
+ ...h5TargetSchema(),
242
+ value: { type: 'string', description: 'Text value to set.' },
243
+ }, ['value']),
244
+ bridgeTool('h5_wait', 'Wait for a native Android WebView DOM element or body text.', {
245
+ ...h5TargetSchema(),
246
+ timeoutSec: { type: 'number', description: 'Maximum wait time. Defaults to 10 seconds.' },
247
+ intervalMs: { type: 'number', description: 'Polling interval. Defaults to 500 ms.' },
248
+ }),
249
+ bridgeTool('h5_scroll', 'Scroll a native Android WebView or scroll a DOM element into view.', {
250
+ ...h5TargetSchema(),
251
+ deltaX: { type: 'number', description: 'Window scroll delta X when no selector/text is supplied.' },
252
+ deltaY: { type: 'number', description: 'Window scroll delta Y when no selector/text is supplied.' },
253
+ }),
254
+ bridgeTool('flutter_h5_dom', 'Read DOM through a Flutter-registered H5 adapter.'),
255
+ bridgeTool('flutter_h5_eval', 'Execute JavaScript through a Flutter-registered H5 adapter.', {
256
+ script: { type: 'string' },
257
+ }, ['script']),
258
+ bridgeTool('flutter_h5_click', 'Click a Flutter H5 DOM element by CSS selector or text.', h5TargetSchema(), []),
259
+ bridgeTool('flutter_h5_input', 'Set text in a Flutter H5 input by CSS selector or text.', {
260
+ ...h5TargetSchema(),
261
+ value: { type: 'string', description: 'Text value to set.' },
262
+ }, ['value']),
263
+ bridgeTool('flutter_h5_wait', 'Wait for a Flutter H5 DOM element or body text.', {
264
+ ...h5TargetSchema(),
265
+ timeoutSec: { type: 'number', description: 'Maximum wait time. Defaults to 10 seconds.' },
266
+ intervalMs: { type: 'number', description: 'Polling interval. Defaults to 500 ms.' },
267
+ }),
268
+ bridgeTool('flutter_h5_scroll', 'Scroll a Flutter H5 document or DOM element into view.', {
269
+ ...h5TargetSchema(),
270
+ deltaX: { type: 'number', description: 'Window scroll delta X when no selector/text is supplied.' },
271
+ deltaY: { type: 'number', description: 'Window scroll delta Y when no selector/text is supplied.' },
272
+ }),
273
+ bridgeTool('logs', 'Read generic in-app log records.'),
260
274
  bridgeTool('freeze_app', 'Optionally stop target app processes with SIGSTOP when a dynamic or transient screen needs stable evidence for review.', {
261
- pid: { type: 'string', description: 'Optional explicit process id. Defaults to all processes named packageName or packageName:*.' },
262
- }, ['packageName']),
275
+ pid: { type: 'string', description: 'Optional explicit process id. Defaults to all processes named packageName or packageName:*.' },
276
+ }, ['packageName']),
263
277
  bridgeTool('thaw_app', 'Resume target app processes with SIGCONT before reads, waits, captures, or actions, and before finishing any task that used freeze-app.', {
264
- pid: { type: 'string', description: 'Optional explicit process id. Defaults to all processes named packageName or packageName:*.' },
265
- }, ['packageName']),
266
- bridgeTool('logcat', 'Read Android logcat through ADB with optional pid/tag/level/grep filters.', {
267
- pid: { type: 'string', description: 'Use "current" for the current app pid, or pass a numeric pid.' },
268
- appPid: { type: 'boolean', description: 'Filter by the current package pid.' },
269
- tag: { type: 'string', description: 'Comma-separated exact logcat tags.' },
270
- level: { type: 'string', description: 'Minimum Android log level: V,D,I,W,E,F.' },
271
- grep: { type: 'string', description: 'Substring filter applied after pid/tag/level.' },
272
- lines: { type: 'number', description: 'Input logcat tail line count before filtering.' },
273
- since: { type: 'string', description: 'Passed to adb logcat -T.' },
274
- follow: { type: 'boolean', description: 'Follow live logs for durationSec seconds.' },
275
- durationSec: { type: 'number', description: 'Bounded live follow duration. Max 60 seconds.' },
276
- clear: { type: 'boolean', description: 'Clear logcat before reading/following.' },
277
- }),
278
- bridgeTool('network', 'Read generic in-app network records.', {
279
- compact: { type: 'boolean', description: 'Return one-line-sized network record summaries without bodies.' },
280
- urlFilter: { type: 'string', description: 'Only retain records whose URL contains this string.' },
281
- method: { type: 'string', description: 'Only retain records with this HTTP method.' },
282
- statusCode: { type: 'number', description: 'Only retain records with this HTTP status.' },
283
- noBodies: { type: 'boolean', description: 'Omit requestBody and responseBody fields from full output.' },
284
- bodyMaxBytes: { type: 'number', description: 'Maximum request/response body bytes retained per record.' },
285
- }),
286
- bridgeTool('webview_pages', 'List attachable Android WebView DevTools/CDP pages for the target package.', {
287
- webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
288
- socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
289
- targetId: { type: 'string', description: 'Optional CDP target/page id.' },
290
- pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
291
- keepForward: { type: 'boolean', description: 'Leave the adb forward active after listing pages.' },
292
- }),
293
- bridgeTool('webview_network', 'Capture WebView fetch/XHR/resource Network events through Chrome DevTools Protocol.', {
294
- webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
295
- socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
296
- targetId: { type: 'string', description: 'Optional CDP target/page id.' },
297
- pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
298
- urlFilter: { type: 'string', description: 'Only retain Network requests whose URL contains this string.' },
299
- durationMs: { type: 'number', description: 'Capture duration after attach. Defaults to 3000 ms.' },
300
- script: { type: 'string', description: 'JavaScript expression to evaluate after Network/Runtime are enabled.' },
301
- includeResponseBody: { type: 'boolean', description: 'Fetch response bodies with Network.getResponseBody when available.' },
302
- bodyMaxBytes: { type: 'number', description: 'Maximum response/request body bytes retained per event.' },
303
- maxEvents: { type: 'number', description: 'Maximum raw CDP events retained. Defaults to 200.' },
304
- }),
305
- bridgeTool('webview_console', 'Capture WebView console and log events through Chrome DevTools Protocol.', {
306
- webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
307
- socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
308
- targetId: { type: 'string', description: 'Optional CDP target/page id.' },
309
- pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
310
- durationMs: { type: 'number', description: 'Capture duration after attach. Defaults to 3000 ms.' },
311
- script: { type: 'string', description: 'JavaScript expression to evaluate after Runtime is enabled.' },
312
- maxEvents: { type: 'number', description: 'Maximum raw CDP events retained. Defaults to 200.' },
313
- }),
314
- bridgeTool('state', 'Read generic in-app state records.'),
315
- bridgeTool('events', 'Read generic in-app event records.'),
316
- bridgeTool('uia_tree', 'Read UIAutomator XML for the current device window.'),
317
- bridgeTool('screenshot', 'Capture an ADB screenshot.'),
318
- bridgeTool('install_apk', 'Install an APK through ADB while assisting device-side package-installer confirmation screens with UIAutomator.', {
319
- apkPath: { type: 'string', description: 'Absolute or workspace-relative APK path.' },
320
- allowDowngrade: { type: 'boolean', description: 'Pass -d to adb install.' },
321
- streaming: { type: 'boolean', description: 'Use streaming install instead of the default --no-streaming mode.' },
322
- installTimeoutMs: { type: 'number', description: 'Maximum time for adb install. Defaults to 180000 ms.' },
323
- installerTimeoutMs: { type: 'number', description: 'Maximum time to keep assisting installer screens after adb install exits. Defaults to 90000 ms.' },
324
- intervalMs: { type: 'number', description: 'Installer polling interval. Defaults to 700 ms.' },
325
- }, ['apkPath']),
326
- 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']),
327
- 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()),
328
- bridgeTool('launch_activity', 'Launch an explicit Android Activity component, optionally with action/data/category/string extras.', launchProperties()),
329
- bridgeTool('launch_native_test', 'Launch the debug native Android bridge test Activity.'),
330
- bridgeTool('launch_flutter', 'Launch the Flutter Activity, optionally with an initial route.'),
331
- bridgeTool('tap', 'Tap device coordinates through ADB.', {
332
- tapX: { type: 'number' },
333
- tapY: { type: 'number' },
334
- }, ['tapX', 'tapY']),
335
- bridgeTool('tap_text', 'Tap the center of an Android View node by exact text or contentDescription.', {
336
- targetText: { type: 'string' },
337
- noAutoHideKeyboard: { type: 'boolean', description: 'Disable the default keyboard-risk guard before tapping lower-screen app nodes.' },
338
- }, ['targetText']),
339
- bridgeTool('wait_text', 'Wait until text appears in status, Android tree, or UIAutomator tree.', {
340
- targetText: { type: 'string' },
341
- timeoutSec: { type: 'number' },
342
- }, ['targetText']),
343
- 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.', {
344
- text: { type: 'string', description: 'Text to set in the focused or coordinate-matched native EditText.' },
345
- tapX: { type: 'number', description: 'Optional X coordinate used to choose a native EditText target.' },
346
- tapY: { type: 'number', description: 'Optional Y coordinate used to choose a native EditText target.' },
347
- hideKeyboard: { type: 'boolean', description: 'Hide the soft keyboard after setting text.' },
348
- }, ['text', 'packageName']),
349
- bridgeTool('keyboard_state', 'Read Android soft-keyboard visibility from dumpsys input_method.'),
350
- bridgeTool('hide_keyboard', 'Hide the Android soft keyboard when it is visible.', {
351
- force: { type: 'boolean', description: 'Send keyboard-dismiss keys even when the visibility probe says the keyboard is hidden.' },
352
- intervalMs: { type: 'number', description: 'Delay between dismiss attempts. Defaults to 500 ms.' },
353
- }),
354
- bridgeTool('swipe', 'Swipe device coordinates through ADB.', {
355
- startX: { type: 'number' },
356
- startY: { type: 'number' },
357
- endX: { type: 'number' },
358
- endY: { type: 'number' },
359
- durationMs: { type: 'number' },
360
- }, ['startX', 'startY', 'endX', 'endY']),
361
- bridgeTool('keyevent', 'Send an Android keyevent through ADB.', {
362
- keyCode: { type: 'number' },
363
- }, ['keyCode']),
364
- bridgeTool('permission_state', 'Read Android runtime permission state from dumpsys package.', {
365
- permission: { type: 'string' },
366
- }, ['permission']),
367
- bridgeTool('permission_grant', 'Grant an Android runtime permission with adb pm grant, then read state.', {
368
- permission: { type: 'string' },
369
- }, ['permission']),
370
- bridgeTool('permission_revoke', 'Revoke an Android runtime permission with adb pm revoke, then read state.', {
371
- permission: { type: 'string' },
372
- }, ['permission']),
373
- bridgeTool('appops_set', 'Set an Android app-op mode with adb appops set.', {
374
- op: { type: 'string' },
375
- mode: { type: 'string' },
376
- }, ['op', 'mode']),
377
- bridgeTool('tap_uia_text', 'Tap a UIAutomator node by text without relying on the in-app tree.', {
378
- targetText: { type: 'string' },
379
- exact: { type: 'boolean' },
380
- }, ['targetText']),
381
- bridgeTool('permission_dialog', 'Tap a visible Android permission dialog allow button through UIAutomator.', {
382
- targetText: { type: 'string', description: 'Optional custom allow-button text.' },
383
- buttonText: { type: 'string', description: 'Optional comma-separated allow-button texts.' },
384
- resourceId: { type: 'string', description: 'Optional permission button resource id.' },
385
- attempts: { type: 'number' },
386
- intervalMs: { type: 'number' },
387
- exact: { type: 'boolean' },
388
- }),
389
- {
390
- name: 'run_smoke',
391
- description: 'Run the full Android + Flutter bridge smoke test.',
392
- inputSchema: baseSchema(),
393
- },
394
- ];
395
- }
396
-
397
- function bridgeTool(name, description, properties = {}, required = []) {
398
- return {
399
- name,
400
- description,
401
- inputSchema: baseSchema(properties, required),
402
- };
403
- }
404
-
405
- function launchProperties() {
406
- return {
407
- activity: { type: 'string', description: 'Activity class, such as .MainActivity or com.example.MainActivity.' },
408
- component: { type: 'string', description: 'Explicit Android component, such as com.example/.MainActivity.' },
409
- action: { type: 'string', description: 'Intent action for explicit Activity launch.' },
410
- category: { type: 'string', description: 'Intent category.' },
411
- data: { type: 'string', description: 'Intent data URI.' },
412
- extra: {
413
- type: 'object',
414
- additionalProperties: { type: 'string' },
415
- description: 'String intent extras as an object of key/value pairs.',
416
- },
417
- };
418
- }
419
-
420
- function baseSchema(extraProperties = {}, extraRequired = []) {
421
- return {
422
- type: 'object',
423
- properties: {
424
- serial: { type: 'string', description: 'ADB serial. Optional when one device is connected.' },
425
- adb: { type: 'string', description: 'ADB executable path or command.' },
426
- port: { type: 'number', description: 'Raw bridge port override. Defaults to 18080; agents should prefer packageName when targeting a known app.' },
427
- packageName: { type: 'string', description: 'Target Android package name. Use this when default 18080 is unreachable or multiple bridge-enabled apps are installed; the CLI discovers the app bridge port from package-private state.' },
428
- initialRoute: { type: 'string', description: 'Flutter initial route for launch_flutter.' },
429
- outFile: { type: 'string', description: 'Screenshot output path for screenshot.' },
430
- artifactDir: { type: 'string', description: 'Directory for generated default artifacts such as screenshots.' },
431
- sinceId: { type: 'number', description: 'Capture query lower bound by record id.' },
432
- sinceMs: { type: 'number', description: 'Capture query lower bound by timestamp milliseconds.' },
433
- limit: { type: 'number', description: 'Maximum capture records to return.' },
434
- ...extraProperties,
435
- },
436
- required: extraRequired,
437
- additionalProperties: false,
438
- };
439
- }
440
-
441
- function h5TargetSchema() {
442
- return {
443
- selector: { type: 'string', description: 'CSS selector for the target DOM element.' },
444
- targetText: { type: 'string', description: 'Text, value, aria-label, placeholder, id, name, or role to match.' },
445
- exact: { type: 'boolean', description: 'Require exact text match instead of substring match.' },
446
- };
447
- }
448
-
449
- const commandDefinitions = [
450
- { command: 'status', domain: 'core', summary: 'Read bridge status, app/device metadata, capture counts, and Flutter summary.', targetApp: true, options: ['packageName', 'port', 'serial', 'full'] },
451
- { 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'] },
452
- { command: 'uia-tree', domain: 'core', summary: 'Read UIAutomator XML for the current foreground window.', options: ['serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes'] },
453
- { command: 'screenshot', domain: 'core', summary: 'Capture a screenshot, with foreground package verification when packageName is supplied.', options: ['serial', 'packageName', 'outFile', 'artifactDir'] },
454
- { command: 'logs', domain: 'core', summary: 'Read in-app log records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
455
- { 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'] },
456
- { command: 'state', domain: 'core', summary: 'Read in-app state records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
457
- { command: 'events', domain: 'core', summary: 'Read in-app event records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
458
- { 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'] },
459
- { 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'] },
460
- { command: 'clear-app-data', domain: 'app', summary: 'Clear target app local data through the bridge runtime.', targetApp: true, options: ['serial', 'packageName'] },
278
+ pid: { type: 'string', description: 'Optional explicit process id. Defaults to all processes named packageName or packageName:*.' },
279
+ }, ['packageName']),
280
+ bridgeTool('logcat', 'Read Android logcat through ADB with optional pid/tag/level/grep filters.', {
281
+ pid: { type: 'string', description: 'Use "current" for the current app pid, or pass a numeric pid.' },
282
+ appPid: { type: 'boolean', description: 'Filter by the current package pid.' },
283
+ tag: { type: 'string', description: 'Comma-separated exact logcat tags.' },
284
+ level: { type: 'string', description: 'Minimum Android log level: V,D,I,W,E,F.' },
285
+ grep: { type: 'string', description: 'Substring filter applied after pid/tag/level.' },
286
+ lines: { type: 'number', description: 'Input logcat tail line count before filtering.' },
287
+ since: { type: 'string', description: 'Passed to adb logcat -T.' },
288
+ follow: { type: 'boolean', description: 'Follow live logs for durationSec seconds.' },
289
+ durationSec: { type: 'number', description: 'Bounded live follow duration. Max 60 seconds.' },
290
+ clear: { type: 'boolean', description: 'Clear logcat before reading/following.' },
291
+ }),
292
+ bridgeTool('network', 'Read generic in-app network records.', {
293
+ compact: { type: 'boolean', description: 'Return one-line-sized network record summaries without bodies.' },
294
+ urlFilter: { type: 'string', description: 'Only retain records whose URL contains this string.' },
295
+ method: { type: 'string', description: 'Only retain records with this HTTP method.' },
296
+ statusCode: { type: 'number', description: 'Only retain records with this HTTP status.' },
297
+ noBodies: { type: 'boolean', description: 'Omit requestBody and responseBody fields from full output.' },
298
+ bodyMaxBytes: { type: 'number', description: 'Maximum request/response body bytes retained per record.' },
299
+ }),
300
+ bridgeTool('webview_pages', 'List attachable Android WebView DevTools/CDP pages for the target package.', {
301
+ webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
302
+ socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
303
+ targetId: { type: 'string', description: 'Optional CDP target/page id.' },
304
+ pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
305
+ keepForward: { type: 'boolean', description: 'Leave the adb forward active after listing pages.' },
306
+ }),
307
+ bridgeTool('webview_network', 'Capture WebView fetch/XHR/resource Network events through Chrome DevTools Protocol.', {
308
+ webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
309
+ socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
310
+ targetId: { type: 'string', description: 'Optional CDP target/page id.' },
311
+ pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
312
+ urlFilter: { type: 'string', description: 'Only retain Network requests whose URL contains this string.' },
313
+ durationMs: { type: 'number', description: 'Capture duration after attach. Defaults to 3000 ms.' },
314
+ script: { type: 'string', description: 'JavaScript expression to evaluate after Network/Runtime are enabled.' },
315
+ includeResponseBody: { type: 'boolean', description: 'Fetch response bodies with Network.getResponseBody when available.' },
316
+ bodyMaxBytes: { type: 'number', description: 'Maximum response/request body bytes retained per event.' },
317
+ maxEvents: { type: 'number', description: 'Maximum raw CDP events retained. Defaults to 200.' },
318
+ }),
319
+ bridgeTool('webview_console', 'Capture WebView console and log events through Chrome DevTools Protocol.', {
320
+ webviewPort: { type: 'number', description: 'Local port used for adb forward. Defaults to the first free port at or above 9222.' },
321
+ socketName: { type: 'string', description: 'Explicit webview_devtools_remote socket name.' },
322
+ targetId: { type: 'string', description: 'Optional CDP target/page id.' },
323
+ pageUrlFilter: { type: 'string', description: 'Prefer a WebView page whose URL contains this string.' },
324
+ durationMs: { type: 'number', description: 'Capture duration after attach. Defaults to 3000 ms.' },
325
+ script: { type: 'string', description: 'JavaScript expression to evaluate after Runtime is enabled.' },
326
+ maxEvents: { type: 'number', description: 'Maximum raw CDP events retained. Defaults to 200.' },
327
+ }),
328
+ bridgeTool('state', 'Read generic in-app state records.'),
329
+ bridgeTool('events', 'Read generic in-app event records.'),
330
+ bridgeTool('uia_tree', 'Read UIAutomator XML for the current device window.'),
331
+ bridgeTool('screenshot', 'Capture an ADB screenshot.'),
332
+ bridgeTool('install_apk', 'Install an APK through ADB while assisting device-side package-installer confirmation screens with UIAutomator.', {
333
+ apkPath: { type: 'string', description: 'Absolute or workspace-relative APK path.' },
334
+ allowDowngrade: { type: 'boolean', description: 'Pass -d to adb install.' },
335
+ streaming: { type: 'boolean', description: 'Use streaming install instead of the default --no-streaming mode.' },
336
+ installTimeoutMs: { type: 'number', description: 'Maximum time for adb install. Defaults to 180000 ms.' },
337
+ installerTimeoutMs: { type: 'number', description: 'Maximum time to keep assisting installer screens after adb install exits. Defaults to 90000 ms.' },
338
+ intervalMs: { type: 'number', description: 'Installer polling interval. Defaults to 700 ms.' },
339
+ }, ['apkPath']),
340
+ 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']),
341
+ 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()),
342
+ bridgeTool('launch_activity', 'Launch an explicit Android Activity component, optionally with action/data/category/string extras.', launchProperties()),
343
+ bridgeTool('launch_native_test', 'Launch the debug native Android bridge test Activity.'),
344
+ bridgeTool('launch_flutter', 'Launch the Flutter Activity, optionally with an initial route.'),
345
+ bridgeTool('tap', 'Tap device coordinates through ADB.', {
346
+ tapX: { type: 'number' },
347
+ tapY: { type: 'number' },
348
+ }, ['tapX', 'tapY']),
349
+ bridgeTool('tap_text', 'Tap the center of an Android View node by exact text or contentDescription.', {
350
+ targetText: { type: 'string' },
351
+ noAutoHideKeyboard: { type: 'boolean', description: 'Disable the default keyboard-risk guard before tapping lower-screen app nodes.' },
352
+ }, ['targetText']),
353
+ bridgeTool('wait_text', 'Wait until text appears in status, Android tree, or UIAutomator tree.', {
354
+ targetText: { type: 'string' },
355
+ timeoutSec: { type: 'number' },
356
+ }, ['targetText']),
357
+ 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.', {
358
+ text: { type: 'string', description: 'Text to set in the focused or coordinate-matched native EditText.' },
359
+ tapX: { type: 'number', description: 'Optional X coordinate used to choose a native EditText target.' },
360
+ tapY: { type: 'number', description: 'Optional Y coordinate used to choose a native EditText target.' },
361
+ hideKeyboard: { type: 'boolean', description: 'Hide the soft keyboard after setting text.' },
362
+ }, ['text', 'packageName']),
363
+ bridgeTool('keyboard_state', 'Read Android soft-keyboard visibility from dumpsys input_method.'),
364
+ bridgeTool('hide_keyboard', 'Hide the Android soft keyboard when it is visible.', {
365
+ force: { type: 'boolean', description: 'Send keyboard-dismiss keys even when the visibility probe says the keyboard is hidden.' },
366
+ intervalMs: { type: 'number', description: 'Delay between dismiss attempts. Defaults to 500 ms.' },
367
+ }),
368
+ bridgeTool('swipe', 'Swipe device coordinates through ADB.', {
369
+ startX: { type: 'number' },
370
+ startY: { type: 'number' },
371
+ endX: { type: 'number' },
372
+ endY: { type: 'number' },
373
+ durationMs: { type: 'number' },
374
+ }, ['startX', 'startY', 'endX', 'endY']),
375
+ bridgeTool('keyevent', 'Send an Android keyevent through ADB.', {
376
+ keyCode: { type: 'number' },
377
+ }, ['keyCode']),
378
+ bridgeTool('permission_state', 'Read Android runtime permission state from dumpsys package.', {
379
+ permission: { type: 'string' },
380
+ }, ['permission']),
381
+ bridgeTool('permission_grant', 'Grant an Android runtime permission with adb pm grant, then read state.', {
382
+ permission: { type: 'string' },
383
+ }, ['permission']),
384
+ bridgeTool('permission_revoke', 'Revoke an Android runtime permission with adb pm revoke, then read state.', {
385
+ permission: { type: 'string' },
386
+ }, ['permission']),
387
+ bridgeTool('appops_set', 'Set an Android app-op mode with adb appops set.', {
388
+ op: { type: 'string' },
389
+ mode: { type: 'string' },
390
+ }, ['op', 'mode']),
391
+ bridgeTool('tap_uia_text', 'Tap a UIAutomator node by text without relying on the in-app tree.', {
392
+ targetText: { type: 'string' },
393
+ exact: { type: 'boolean' },
394
+ }, ['targetText']),
395
+ bridgeTool('permission_dialog', 'Tap a visible Android permission dialog allow button through UIAutomator.', {
396
+ targetText: { type: 'string', description: 'Optional custom allow-button text.' },
397
+ buttonText: { type: 'string', description: 'Optional comma-separated allow-button texts.' },
398
+ resourceId: { type: 'string', description: 'Optional permission button resource id.' },
399
+ attempts: { type: 'number' },
400
+ intervalMs: { type: 'number' },
401
+ exact: { type: 'boolean' },
402
+ }),
403
+ {
404
+ name: 'run_smoke',
405
+ description: 'Run the full Android + Flutter bridge smoke test.',
406
+ inputSchema: baseSchema(),
407
+ },
408
+ ];
409
+ }
410
+
411
+ function bridgeTool(name, description, properties = {}, required = []) {
412
+ return {
413
+ name,
414
+ description,
415
+ inputSchema: baseSchema(properties, required),
416
+ };
417
+ }
418
+
419
+ function launchProperties() {
420
+ return {
421
+ activity: { type: 'string', description: 'Activity class, such as .MainActivity or com.example.MainActivity.' },
422
+ component: { type: 'string', description: 'Explicit Android component, such as com.example/.MainActivity.' },
423
+ action: { type: 'string', description: 'Intent action for explicit Activity launch.' },
424
+ category: { type: 'string', description: 'Intent category.' },
425
+ data: { type: 'string', description: 'Intent data URI.' },
426
+ extra: {
427
+ type: 'object',
428
+ additionalProperties: { type: 'string' },
429
+ description: 'String intent extras as an object of key/value pairs.',
430
+ },
431
+ };
432
+ }
433
+
434
+ function baseSchema(extraProperties = {}, extraRequired = []) {
435
+ return {
436
+ type: 'object',
437
+ properties: {
438
+ serial: { type: 'string', description: 'ADB serial. Optional when one device is connected.' },
439
+ adb: { type: 'string', description: 'ADB executable path or command.' },
440
+ port: { type: 'number', description: 'Raw bridge port override. Defaults to 18080; agents should prefer packageName when targeting a known app.' },
441
+ packageName: { type: 'string', description: 'Target Android package name. Use this when default 18080 is unreachable or multiple bridge-enabled apps are installed; the CLI discovers the app bridge port from package-private state.' },
442
+ initialRoute: { type: 'string', description: 'Flutter initial route for launch_flutter.' },
443
+ outFile: { type: 'string', description: 'Screenshot output path for screenshot.' },
444
+ artifactDir: { type: 'string', description: 'Directory for generated default artifacts such as screenshots.' },
445
+ sinceId: { type: 'number', description: 'Capture query lower bound by record id.' },
446
+ sinceMs: { type: 'number', description: 'Capture query lower bound by timestamp milliseconds.' },
447
+ limit: { type: 'number', description: 'Maximum capture records to return.' },
448
+ ...extraProperties,
449
+ },
450
+ required: extraRequired,
451
+ additionalProperties: false,
452
+ };
453
+ }
454
+
455
+ function h5TargetSchema() {
456
+ return {
457
+ selector: { type: 'string', description: 'CSS selector for the target DOM element.' },
458
+ targetText: { type: 'string', description: 'Text, value, aria-label, placeholder, id, name, or role to match.' },
459
+ exact: { type: 'boolean', description: 'Require exact text match instead of substring match.' },
460
+ };
461
+ }
462
+
463
+ const commandDefinitions = [
464
+ { command: 'status', domain: 'core', summary: 'Read bridge status, app/device metadata, capture counts, and Flutter summary.', targetApp: true, options: ['packageName', 'port', 'serial', 'full'] },
465
+ { 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'] },
466
+ { command: 'uia-tree', domain: 'core', summary: 'Read UIAutomator XML for the current foreground window.', options: ['serial', 'compact', 'textFilter', 'resourceIdFilter', 'classFilter', 'visibleOnly', 'maxNodes'] },
467
+ { command: 'screenshot', domain: 'core', summary: 'Capture a screenshot, with foreground package verification when packageName is supplied.', options: ['serial', 'packageName', 'outFile', 'artifactDir'] },
468
+ { command: 'logs', domain: 'core', summary: 'Read in-app log records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
469
+ { 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'] },
470
+ { command: 'state', domain: 'core', summary: 'Read in-app state records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
471
+ { command: 'events', domain: 'core', summary: 'Read in-app event records from the bridge.', targetApp: true, options: ['packageName', 'port', 'serial', 'sinceId', 'sinceMs', 'limit'] },
472
+ { 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'] },
473
+ { 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'] },
474
+ { command: 'clear-app-data', domain: 'app', summary: 'Clear target app local data through the bridge runtime.', targetApp: true, options: ['serial', 'packageName'] },
461
475
  { command: 'freeze-app', domain: 'app', summary: 'Optionally stop target app processes with SIGSTOP when dynamic UI needs stable evidence.', targetApp: true, options: ['serial', 'packageName', 'pid'] },
462
476
  { command: 'thaw-app', domain: 'app', summary: 'Resume target app processes with SIGCONT before reads, waits, captures, actions, or final handoff.', targetApp: true, options: ['serial', 'packageName', 'pid'] },
463
- { 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'] },
464
- { 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'] },
465
- { command: 'launch-native-test', domain: 'app', summary: 'Launch the debug native bridge test Activity.', targetApp: true, options: ['serial', 'packageName'] },
466
- { command: 'launch-flutter', domain: 'app', summary: 'Launch the Flutter Activity, optionally with an initial route.', targetApp: true, options: ['serial', 'packageName', 'initialRoute'] },
467
- { command: 'permission-state', domain: 'app', summary: 'Read Android runtime permission state.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
468
- { command: 'permission-grant', domain: 'app', summary: 'Grant an Android runtime permission.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
469
- { command: 'permission-revoke', domain: 'app', summary: 'Revoke an Android runtime permission.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
470
- { command: 'permission-dialog', domain: 'app', summary: 'Tap a visible Android permission dialog allow button.', options: ['serial', 'targetText', 'buttonText', 'resourceId', 'attempts', 'intervalMs', 'exact'] },
471
- { command: 'appops-set', domain: 'app', summary: 'Set an Android app-op mode.', targetApp: true, options: ['serial', 'packageName', 'op', 'mode'] },
472
- { command: 'tap', domain: 'action', summary: 'Tap device coordinates through ADB.', options: ['serial', 'tapX', 'tapY'] },
473
- { 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'] },
474
- { command: 'tap-uia-text', domain: 'action', summary: 'Tap a UIAutomator node by text without relying on the in-app tree.', options: ['serial', 'targetText', 'exact'] },
475
- { 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'] },
476
- { 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'] },
477
- { command: 'keyboard-state', domain: 'action', summary: 'Read Android soft keyboard visibility.', options: ['serial'] },
478
- { command: 'hide-keyboard', domain: 'action', summary: 'Hide the Android soft keyboard.', options: ['serial', 'force', 'intervalMs'] },
479
- { command: 'swipe', domain: 'action', summary: 'Swipe device coordinates through ADB.', options: ['serial', 'startX', 'startY', 'endX', 'endY', 'durationMs'] },
480
- { command: 'keyevent', domain: 'action', summary: 'Send an Android keyevent through ADB.', options: ['serial', 'keyCode'] },
481
- { command: 'flutter-tree', domain: 'flutter', summary: 'Read the latest Flutter layout snapshot.', targetApp: true, options: ['serial', 'packageName', 'port'] },
482
- { command: 'flutter-nodes', domain: 'flutter', summary: 'Read Flutter operable nodes.', targetApp: true, options: ['serial', 'packageName', 'port'] },
483
- { command: 'flutter-action', domain: 'flutter', summary: 'Dispatch a raw Flutter action payload.', targetApp: true, options: ['serial', 'packageName', 'payload'] },
484
- { command: 'tap-flutter-text', domain: 'flutter', summary: 'Tap a Flutter node by visible text.', targetApp: true, options: ['serial', 'packageName', 'targetText'] },
485
- { 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'] },
486
- { command: 'scroll-flutter', domain: 'flutter', summary: 'Scroll Flutter content by delta or until text is visible.', targetApp: true, options: ['serial', 'packageName', 'targetText', 'delta', 'maxSwipes'] },
487
- { command: 'h5-dom', domain: 'webview', summary: 'Read native Android WebView DOM.', targetApp: true, options: ['serial', 'packageName', 'port'] },
488
- { command: 'h5-eval', domain: 'webview', summary: 'Execute JavaScript in the current native Android WebView.', targetApp: true, options: ['serial', 'packageName', 'script'] },
489
- { command: 'h5-click', domain: 'webview', summary: 'Click a native WebView element by selector or text.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'exact'] },
490
- { command: 'h5-input', domain: 'webview', summary: 'Set text in a native WebView input.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'value', 'exact'] },
491
- { command: 'h5-wait', domain: 'webview', summary: 'Wait for native WebView text or selector.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'timeoutSec', 'intervalMs'] },
492
- { 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'] },
493
- { command: 'flutter-h5-dom', domain: 'webview', summary: 'Read DOM through a Flutter H5 adapter.', targetApp: true, options: ['serial', 'packageName', 'port'] },
494
- { command: 'flutter-h5-eval', domain: 'webview', summary: 'Execute JavaScript through a Flutter H5 adapter.', targetApp: true, options: ['serial', 'packageName', 'script'] },
495
- { command: 'flutter-h5-click', domain: 'webview', summary: 'Click a Flutter H5 DOM element.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'exact'] },
496
- { command: 'flutter-h5-input', domain: 'webview', summary: 'Set text in a Flutter H5 input.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'value', 'exact'] },
497
- { command: 'flutter-h5-wait', domain: 'webview', summary: 'Wait for Flutter H5 text or selector.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'timeoutSec', 'intervalMs'] },
498
- { 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'] },
499
- { command: 'webview-pages', domain: 'webview', summary: 'List attachable Android WebView DevTools/CDP pages.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'keepForward'] },
500
- { 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'] },
501
- { 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'] },
502
- { command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
503
- { command: 'remove-forward', domain: 'advanced', summary: 'Remove the ADB port forward for the bridge.', options: ['serial', 'port'] },
504
- { command: 'batch', domain: 'advanced', summary: 'Run multiple AI App Bridge commands serially in one MCP call.', options: ['defaults', 'steps', 'stopOnError', 'includeRaw', 'maxRawChars'] },
505
- { command: 'smoke', domain: 'diagnostics', summary: 'Run the native sample smoke test.', options: ['serial', 'packageName', 'outFile', 'artifactDir', 'skipFlutterLaunch'] },
506
- ];
507
-
508
- const commandByName = new Map(commandDefinitions.map((definition) => [definition.command, definition]));
509
-
510
- async function callTool(name, args) {
511
- if (name === 'capabilities') {
512
- return toolJson(capabilityPayload(args));
513
- }
514
- if (name === 'run') {
515
- return runGeneric(args);
516
- }
517
- if (name === 'run_smoke') {
518
- return runSmoke(args);
519
- }
520
- const commandMap = {
521
- flutter_tree: 'flutter-tree',
522
- h5_dom: 'h5-dom',
523
- h5_eval: 'h5-eval',
524
- h5_click: 'h5-click',
525
- h5_input: 'h5-input',
526
- h5_wait: 'h5-wait',
527
- h5_scroll: 'h5-scroll',
528
- freeze_app: 'freeze-app',
529
- thaw_app: 'thaw-app',
530
- flutter_h5_dom: 'flutter-h5-dom',
531
- flutter_h5_eval: 'flutter-h5-eval',
532
- flutter_h5_click: 'flutter-h5-click',
533
- flutter_h5_input: 'flutter-h5-input',
534
- flutter_h5_wait: 'flutter-h5-wait',
535
- flutter_h5_scroll: 'flutter-h5-scroll',
536
- flutter_nodes: 'flutter-nodes',
537
- tap_flutter_text: 'tap-flutter-text',
538
- input_flutter_text: 'input-flutter-text',
539
- uia_tree: 'uia-tree',
540
- install_apk: 'install-apk',
541
- clear_app_data: 'clear-app-data',
542
- launch_app: 'launch-app',
543
- launch_activity: 'launch-activity',
544
- launch_native_test: 'launch-native-test',
545
- launch_flutter: 'launch-flutter',
546
- tap_text: 'tap-text',
547
- wait_text: 'wait-text',
548
- input_text: 'input-text',
549
- keyboard_state: 'keyboard-state',
550
- hide_keyboard: 'hide-keyboard',
551
- webview_pages: 'webview-pages',
552
- webview_network: 'webview-network',
553
- webview_console: 'webview-console',
554
- permission_state: 'permission-state',
555
- permission_grant: 'permission-grant',
556
- permission_revoke: 'permission-revoke',
557
- appops_set: 'appops-set',
558
- tap_uia_text: 'tap-uia-text',
559
- permission_dialog: 'permission-dialog',
560
- };
561
- const command = commandMap[name] || name;
562
- return runBridgeChecked(command, args);
563
- }
564
-
565
- function capabilityPayload(args = {}) {
566
- const includeOptions = Boolean(args.includeOptions);
567
- const requestedCommand = args.command ? normalizeCommandName(args.command) : '';
568
- if (requestedCommand) {
569
- const definition = commandByName.get(requestedCommand);
570
- return {
571
- ok: Boolean(definition),
572
- command: requestedCommand,
573
- ...(definition ? shapeCommandDefinition(definition, true) : { error: 'unknown_command' }),
574
- };
575
- }
576
-
577
- const requestedDomain = args.domain ? String(args.domain) : '';
578
- const domains = {};
579
- for (const definition of commandDefinitions) {
580
- if (requestedDomain && definition.domain !== requestedDomain) continue;
581
- if (!domains[definition.domain]) domains[definition.domain] = [];
582
- domains[definition.domain].push(shapeCommandDefinition(definition, includeOptions));
583
- }
584
- return {
585
- ok: true,
586
- surface: mcpSurface === 'full' || mcpSurface === 'legacy' ? 'full' : 'compact',
587
- usage: 'Use run with one of these command names. Prefer packageName for app-specific commands; install-apk, clear-app-data, launch-app, freeze/thaw, UI, WebView, logcat, network, and permission workflows are supported.',
588
- domains,
589
- };
590
- }
591
-
592
- function shapeCommandDefinition(definition, includeOptions) {
593
- return {
594
- command: definition.command,
595
- summary: definition.summary,
596
- targetApp: Boolean(definition.targetApp),
597
- ...(includeOptions ? { options: definition.options || [] } : {}),
598
- };
599
- }
600
-
601
- async function runGeneric(args = {}) {
602
- const command = normalizeCommandName(args.command);
603
- if (!commandByName.has(command)) {
604
- return toolText(`unknown command: ${args.command || ''}`, true);
605
- }
606
- const commandArgs = {
607
- ...(args.arguments && typeof args.arguments === 'object' ? args.arguments : {}),
608
- };
609
- for (const key of ['adb', 'serial', 'port', 'packageName']) {
610
- if (args[key] !== undefined && commandArgs[key] === undefined) {
611
- commandArgs[key] = args[key];
612
- }
613
- }
614
- if (command === 'batch') {
615
- return runBatch(commandArgs);
616
- }
617
- return runBridgeChecked(command, commandArgs);
618
- }
619
-
620
- function normalizeCommandName(value) {
621
- return String(value || '').trim().replace(/_/g, '-');
622
- }
623
-
624
- function runBridgeChecked(command, args = {}) {
625
- if (command === 'clear-app-data' && !args.packageName) {
626
- return toolText('clear-app-data: packageName is required in MCP mode so the command cannot clear a default package.', true);
627
- }
628
- if ((command === 'freeze-app' || command === 'thaw-app') && !args.packageName) {
629
- return toolText(`${command}: packageName is required in MCP mode so the command cannot signal a default package.`, true);
630
- }
631
- const definition = commandByName.get(command);
632
- if (definition?.targetApp && !args.packageName && !args.port) {
633
- return toolText(`${command}: packageName or explicit port is required in MCP mode so the command cannot fall back to a default package.`, true);
634
- }
635
- return runBridge(command, args);
636
- }
637
-
638
- async function runBatch(args = {}, runner = runBridgeChecked) {
639
- const startedAtMs = Date.now();
640
- const mode = args.mode ? String(args.mode) : 'serial';
641
- if (mode !== 'serial') {
642
- return toolJson({ ok: false, error: 'batch_mode_not_supported', mode }, true);
643
- }
644
- const steps = Array.isArray(args.steps) ? args.steps : [];
645
- if (steps.length === 0) {
646
- return toolJson({ ok: false, error: 'batch_steps_required' }, true);
647
- }
648
- const maxSteps = args.maxSteps === undefined ? 30 : Number(args.maxSteps);
649
- if (!Number.isInteger(maxSteps) || maxSteps < 1) {
650
- return toolJson({ ok: false, error: 'invalid_max_steps', maxSteps: args.maxSteps }, true);
651
- }
652
- if (steps.length > maxSteps) {
653
- return toolJson({ ok: false, error: 'batch_too_many_steps', stepCount: steps.length, maxSteps }, true);
654
- }
655
-
656
- const defaults = args.defaults && typeof args.defaults === 'object' ? { ...args.defaults } : {};
657
- for (const key of ['adb', 'serial', 'port', 'packageName', 'artifactDir']) {
658
- if (args[key] !== undefined && defaults[key] === undefined) {
659
- defaults[key] = args[key];
660
- }
661
- }
662
-
663
- const normalizedSteps = [];
664
- const seenIds = new Set();
665
- for (let index = 0; index < steps.length; index += 1) {
666
- const rawStep = steps[index] && typeof steps[index] === 'object' ? steps[index] : {};
667
- const stepId = String(rawStep.id || `step_${index + 1}`);
668
- if (seenIds.has(stepId)) {
669
- return toolJson({ ok: false, error: 'duplicate_batch_step_id', stepId }, true);
670
- }
671
- seenIds.add(stepId);
672
- const command = normalizeCommandName(rawStep.command);
673
- if (!commandByName.has(command)) {
674
- return toolJson({ ok: false, error: 'unknown_batch_step_command', stepId, command: rawStep.command || '' }, true);
675
- }
676
- if (command === 'batch') {
677
- return toolJson({ ok: false, error: 'nested_batch_not_supported', stepId }, true);
678
- }
679
- normalizedSteps.push({ ...rawStep, id: stepId, command });
680
- }
681
-
682
- const stopOnError = args.stopOnError !== false;
683
- const includeRaw = Boolean(args.includeRaw);
684
- const maxRawChars = args.maxRawChars === undefined ? 4000 : Number(args.maxRawChars);
685
- if (!Number.isInteger(maxRawChars) || maxRawChars < 0) {
686
- return toolJson({ ok: false, error: 'invalid_max_raw_chars', maxRawChars: args.maxRawChars }, true);
687
- }
688
- const results = [];
689
- let stopped = false;
690
-
691
- for (const step of normalizedSteps) {
692
- if (stopped) {
693
- results.push({
694
- id: step.id,
695
- command: step.command,
696
- status: 'skipped',
697
- ok: false,
698
- skipped: true,
699
- reason: 'stopOnError',
700
- });
701
- continue;
702
- }
703
-
704
- const stepStartedAtMs = Date.now();
705
- const stepArgs = {
706
- ...defaults,
707
- ...(step.arguments && typeof step.arguments === 'object' ? step.arguments : {}),
708
- };
709
- for (const key of ['adb', 'serial', 'port', 'packageName']) {
710
- if (step[key] !== undefined) {
711
- stepArgs[key] = step[key];
712
- }
713
- }
714
- try {
715
- const toolResult = await runner(step.command, stepArgs);
716
- const parsed = parseToolResult(toolResult);
717
- const passed = !parsed.isError && parsed.payload?.ok !== false;
718
- const stepResult = {
719
- id: step.id,
720
- command: step.command,
721
- status: passed ? 'passed' : 'failed',
722
- ok: passed,
723
- packageName: stepArgs.packageName,
724
- port: stepArgs.port,
725
- durationMs: Date.now() - stepStartedAtMs,
726
- summary: summarizeToolPayload(parsed),
727
- };
728
- if (!passed) {
729
- stepResult.error = parsed.payload?.error || firstTextLine(parsed.text) || 'command_failed';
730
- }
731
- if (includeRaw) {
732
- stepResult.result = parsed.payload || undefined;
733
- stepResult.rawText = parsed.payload ? undefined : truncateText(parsed.text, maxRawChars);
734
- }
735
- results.push(stepResult);
736
- if (!passed && stopOnError) {
737
- stopped = true;
738
- }
739
- } catch (error) {
740
- const stepResult = {
741
- id: step.id,
742
- command: step.command,
743
- status: 'failed',
744
- ok: false,
745
- packageName: stepArgs.packageName,
746
- port: stepArgs.port,
747
- durationMs: Date.now() - stepStartedAtMs,
748
- error: error.message || String(error),
749
- };
750
- results.push(stepResult);
751
- if (stopOnError) {
752
- stopped = true;
753
- }
754
- }
755
- }
756
-
757
- const failed = results.filter((item) => item.status === 'failed').length;
758
- const skipped = results.filter((item) => item.status === 'skipped').length;
759
- const passed = results.filter((item) => item.status === 'passed').length;
760
- return toolJson({
761
- ok: failed === 0,
762
- batchId: args.batchId || generatedBatchId(),
763
- mode,
764
- stopOnError,
765
- stepCount: normalizedSteps.length,
766
- passed,
767
- failed,
768
- skipped,
769
- durationMs: Date.now() - startedAtMs,
770
- steps: results,
771
- }, failed > 0);
772
- }
773
-
774
- async function runBridge(command, args) {
775
- return runProcess(buildBridgeCliArgs(command, args));
776
- }
777
-
778
- function parseToolResult(toolResult) {
779
- const text = String(toolResult?.content?.[0]?.text || '');
780
- try {
781
- return {
782
- isError: Boolean(toolResult?.isError),
783
- text,
784
- payload: JSON.parse(text),
785
- };
786
- } catch (_) {
787
- return {
788
- isError: Boolean(toolResult?.isError),
789
- text,
790
- payload: null,
791
- };
792
- }
793
- }
794
-
795
- function summarizeToolPayload(parsed) {
796
- const payload = parsed.payload;
797
- if (!payload || typeof payload !== 'object') {
798
- return { text: truncateText(parsed.text, 500) };
799
- }
800
- const summary = {
801
- ok: payload.ok,
802
- error: payload.error || null,
803
- };
804
- if (payload.packageName) summary.packageName = payload.packageName;
805
- if (payload.app?.packageName) summary.app = payload.app.packageName;
806
- if (payload.activity) summary.activity = payload.activity;
807
- if (payload.component) summary.component = payload.component;
808
- if (payload.transport) summary.transport = payload.transport;
809
- if (payload.source) summary.source = payload.source;
810
- if (payload.path) summary.path = payload.path;
811
- if (payload.debugBridge) {
812
- summary.bridge = {
813
- version: payload.debugBridge.version,
814
- port: payload.debugBridge.port,
815
- };
816
- }
817
- if (payload.count !== undefined) summary.count = payload.count;
818
- if (payload.nodeCount !== undefined) summary.nodeCount = payload.nodeCount;
819
- if (Array.isArray(payload.items)) summary.items = payload.items.length;
820
- if (payload.values && typeof payload.values === 'object') {
821
- summary.values = Object.keys(payload.values).length;
822
- }
823
- if (payload.counts) summary.counts = payload.counts;
824
- if (Array.isArray(payload.requests)) summary.requests = payload.requests.length;
825
- if (Array.isArray(payload.console)) summary.console = payload.console.length;
826
- if (payload.flutter?.layout?.operable) {
827
- summary.flutterOperable = {
828
- ok: payload.flutter.layout.operable.ok,
829
- count: payload.flutter.layout.operable.count,
830
- };
831
- }
832
- if (payload.result && typeof payload.result === 'object') {
833
- summary.result = {
834
- ok: payload.result.ok,
835
- error: payload.result.error || null,
836
- value: truncateText(payload.result.value, 200),
837
- bodyText: truncateText(payload.result.bodyText, 200),
838
- };
839
- }
840
- return summary;
841
- }
842
-
843
- function truncateText(value, maxChars) {
844
- if (value === undefined || value === null) return value;
845
- const text = String(value);
846
- if (text.length <= maxChars) return text;
847
- return `${text.slice(0, maxChars)}...`;
848
- }
849
-
850
- function firstTextLine(value) {
851
- const lines = String(value || '').split(/\r?\n/).filter((line) => line.trim());
852
- return lines.find((line) => {
853
- const text = line.trim().toLowerCase();
854
- return text !== 'stderr:' && text !== 'stdout:';
855
- }) || lines[0] || '';
856
- }
857
-
858
- function generatedBatchId() {
859
- return `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
860
- }
861
-
862
- function buildBridgeCliArgs(command, args = {}) {
863
- const cliArgs = [cliScript, command];
864
- addCommonArgs(cliArgs, args);
865
- addArg(cliArgs, 'initial-route', args.initialRoute);
866
- addArg(cliArgs, 'activity', args.activity);
867
- addArg(cliArgs, 'component', args.component);
868
- addArg(cliArgs, 'action', args.action);
869
- addRepeatedArg(cliArgs, 'category', args.category);
870
- addArg(cliArgs, 'data', args.data);
871
- addExtraArgs(cliArgs, args.extra);
872
- addArg(cliArgs, 'out-file', args.outFile);
873
- addArg(cliArgs, 'artifact-dir', args.artifactDir || defaultArtifactDirFor(command, args));
874
- addArg(cliArgs, 'apk-path', args.apkPath);
875
- addArg(cliArgs, 'tap-x', args.tapX);
876
- addArg(cliArgs, 'tap-y', args.tapY);
877
- addArg(cliArgs, 'target-text', args.targetText);
878
- addArg(cliArgs, 'no-auto-hide-keyboard', args.noAutoHideKeyboard);
879
- addArg(cliArgs, 'timeout-sec', args.timeoutSec);
880
- addArg(cliArgs, 'text', args.text);
881
- addArg(cliArgs, 'hide-keyboard', args.hideKeyboard);
882
- addArg(cliArgs, 'force', args.force);
883
- addArg(cliArgs, 'start-x', args.startX);
884
- addArg(cliArgs, 'start-y', args.startY);
885
- addArg(cliArgs, 'end-x', args.endX);
886
- addArg(cliArgs, 'end-y', args.endY);
887
- addArg(cliArgs, 'duration-ms', args.durationMs);
888
- addArg(cliArgs, 'allow-downgrade', args.allowDowngrade);
889
- addArg(cliArgs, 'streaming', args.streaming);
890
- addArg(cliArgs, 'install-timeout-ms', args.installTimeoutMs);
891
- addArg(cliArgs, 'installer-timeout-ms', args.installerTimeoutMs);
892
- addArg(cliArgs, 'webview-port', args.webviewPort);
893
- addArg(cliArgs, 'socket-name', args.socketName);
894
- addArg(cliArgs, 'target-id', args.targetId);
895
- addArg(cliArgs, 'page-url-filter', args.pageUrlFilter);
896
- addArg(cliArgs, 'url-filter', args.urlFilter);
897
- addArg(cliArgs, 'method', args.method);
898
- addArg(cliArgs, 'status-code', args.statusCode);
899
- addArg(cliArgs, 'compact', args.compact);
900
- addArg(cliArgs, 'full', args.full);
901
- addArg(cliArgs, 'text-filter', args.textFilter);
902
- addArg(cliArgs, 'resource-id-filter', args.resourceIdFilter);
903
- addArg(cliArgs, 'class-filter', args.classFilter);
904
- addArg(cliArgs, 'visible-only', args.visibleOnly);
905
- addArg(cliArgs, 'max-nodes', args.maxNodes);
906
- addArg(cliArgs, 'max-depth', args.maxDepth);
907
- addArg(cliArgs, 'no-bodies', args.noBodies);
908
- addArg(cliArgs, 'duration-ms', args.durationMs);
909
- addArg(cliArgs, 'include-response-body', args.includeResponseBody);
910
- addArg(cliArgs, 'body-max-bytes', args.bodyMaxBytes);
911
- addArg(cliArgs, 'max-events', args.maxEvents);
912
- addArg(cliArgs, 'keep-forward', args.keepForward);
913
- addArg(cliArgs, 'key-code', args.keyCode);
914
- addArg(cliArgs, 'payload', args.payload);
915
- addArg(cliArgs, 'delta', args.delta);
916
- addArg(cliArgs, 'max-swipes', args.maxSwipes);
917
- addArg(cliArgs, 'permission', args.permission);
918
- addArg(cliArgs, 'op', args.op);
919
- addArg(cliArgs, 'mode', args.mode);
920
- addArg(cliArgs, 'script', args.script);
921
- addArg(cliArgs, 'selector', args.selector);
922
- addArg(cliArgs, 'target-text', args.targetText);
923
- addArg(cliArgs, 'value', args.value);
924
- addArg(cliArgs, 'exact', args.exact);
925
- addArg(cliArgs, 'button-text', args.buttonText);
926
- addArg(cliArgs, 'resource-id', args.resourceId);
927
- addArg(cliArgs, 'attempts', args.attempts);
928
- addArg(cliArgs, 'interval-ms', args.intervalMs);
929
- addArg(cliArgs, 'delta-x', args.deltaX);
930
- addArg(cliArgs, 'delta-y', args.deltaY);
931
- addArg(cliArgs, 'require-text', args.requireText);
932
- addArg(cliArgs, 'absent-text', args.absentText);
933
- addArg(cliArgs, 'require-activity', args.requireActivity);
934
- addArg(cliArgs, 'since-id', args.sinceId);
935
- addArg(cliArgs, 'since-ms', args.sinceMs);
936
- addArg(cliArgs, 'limit', args.limit);
937
- addArg(cliArgs, 'pid', args.pid);
938
- addArg(cliArgs, 'app-pid', args.appPid);
939
- addArg(cliArgs, 'tag', args.tag);
940
- addArg(cliArgs, 'level', args.level);
941
- addArg(cliArgs, 'grep', args.grep);
942
- addArg(cliArgs, 'lines', args.lines);
943
- addArg(cliArgs, 'since', args.since);
944
- addArg(cliArgs, 'follow', args.follow);
945
- addArg(cliArgs, 'duration-sec', args.durationSec);
946
- addArg(cliArgs, 'clear', args.clear);
947
- addArg(cliArgs, 'skip-flutter-launch', args.skipFlutterLaunch);
948
- return cliArgs;
949
- }
950
-
951
- async function runSmoke(args) {
952
- const cliArgs = [cliScript, 'smoke'];
953
- addCommonArgs(cliArgs, args);
954
- addArg(cliArgs, 'out-file', args.outFile);
955
- addArg(cliArgs, 'artifact-dir', args.artifactDir || defaultArtifactDirFor('smoke', args));
956
- addArg(cliArgs, 'skip-flutter-launch', args.skipFlutterLaunch);
957
- return runProcess(cliArgs);
958
- }
959
-
960
- function defaultArtifactDirFor(command, args) {
961
- if (args.outFile) return '';
962
- if (command !== 'screenshot' && command !== 'smoke') return '';
963
- return path.join(process.cwd(), 'build', 'ai_app_bridge_artifacts');
964
- }
965
-
966
- function addCommonArgs(cliArgs, args) {
967
- addArg(cliArgs, 'adb', args.adb);
968
- addArg(cliArgs, 'serial', args.serial);
969
- addArg(cliArgs, 'port', args.port);
970
- addArg(cliArgs, 'package-name', args.packageName);
971
- }
972
-
973
- function addArg(cliArgs, name, value) {
974
- if (value === undefined || value === null || value === '' || value === false) {
975
- return;
976
- }
977
- cliArgs.push(`--${name}`, String(value));
978
- }
979
-
980
- function addRepeatedArg(cliArgs, name, value) {
981
- if (Array.isArray(value)) {
982
- for (const item of value) addArg(cliArgs, name, item);
983
- return;
984
- }
985
- addArg(cliArgs, name, value);
986
- }
987
-
988
- function addExtraArgs(cliArgs, value) {
989
- if (Array.isArray(value)) {
990
- for (const item of value) addArg(cliArgs, 'extra', item);
991
- return;
992
- }
993
- if (value && typeof value === 'object') {
994
- for (const [key, extraValue] of Object.entries(value)) {
995
- addArg(cliArgs, 'extra', `${key}=${extraValue}`);
996
- }
997
- return;
998
- }
999
- addArg(cliArgs, 'extra', value);
1000
- }
1001
-
1002
- function runProcess(cliArgs) {
1003
- return new Promise((resolve) => {
1004
- const child = spawn(nodeBinary, cliArgs, {
1005
- cwd: bridgeDir,
1006
- windowsHide: true,
1007
- });
1008
- let stdout = '';
1009
- let stderr = '';
1010
- child.stdout.on('data', (chunk) => {
1011
- stdout += chunk.toString();
1012
- });
1013
- child.stderr.on('data', (chunk) => {
1014
- stderr += chunk.toString();
1015
- });
1016
- child.on('error', (error) => {
1017
- resolve(toolText(`failed to start Node bridge CLI: ${error.message}`, true));
1018
- });
1019
- child.on('close', (code) => {
1020
- const text = [
1021
- stdout.trim(),
1022
- stderr.trim() ? `stderr:\n${stderr.trim()}` : '',
1023
- code === 0 ? '' : `exitCode: ${code}`,
1024
- retryWithPackageNameHint(cliArgs, stdout, stderr, code),
1025
- ].filter(Boolean).join('\n\n');
1026
- resolve(toolText(text || emptyProcessText(cliArgs), code !== 0));
1027
- });
1028
- });
1029
- }
1030
-
1031
- function emptyProcessText(cliArgs) {
1032
- const command = cliArgs[1] || '';
1033
- if (command === 'logcat' && cliArgs.includes('--app-pid')) {
1034
- return 'logcat: no matching lines for current app pid';
1035
- }
1036
- if (command === 'logcat') {
1037
- return 'logcat: no matching lines';
1038
- }
1039
- return 'ok';
1040
- }
1041
-
1042
- function retryWithPackageNameHint(cliArgs, stdout, stderr, code) {
1043
- if (code === 0 || cliArgs.includes('--package-name') || cliArgs.includes('--port')) {
1044
- return '';
1045
- }
1046
- const output = `${stdout}\n${stderr}`;
1047
- if (!/HTTP timeout: http:\/\/127\.0\.0\.1:18080\//.test(output)) {
1048
- return '';
1049
- }
1050
- 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.';
1051
- }
1052
-
1053
- function toolText(text, isError = false) {
1054
- return {
1055
- content: [
1056
- {
1057
- type: 'text',
1058
- text,
1059
- },
1060
- ],
1061
- isError,
1062
- };
1063
- }
1064
-
1065
- function toolJson(value, isError = false) {
1066
- return toolText(JSON.stringify(value, null, 2), isError);
1067
- }
1068
-
1069
- function sendResult(id, result) {
1070
- send({ jsonrpc: '2.0', id, result });
1071
- }
1072
-
1073
- function sendError(id, code, message) {
1074
- send({
1075
- jsonrpc: '2.0',
1076
- id,
1077
- error: { code, message },
1078
- });
1079
- }
1080
-
1081
- function send(message) {
1082
- const body = Buffer.from(JSON.stringify(message), 'utf8');
1083
- if (responseFormat === 'line') {
1084
- process.stdout.write(`${body.toString('utf8')}\n`);
1085
- return;
1086
- }
1087
- process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
1088
- process.stdout.write(body);
1089
- }
1090
-
1091
- function writeLog(text) {
1092
- process.stderr.write(`${text}\n`);
1093
- }
1094
-
1095
- if (require.main === module) {
1096
- startServer();
1097
- }
1098
-
1099
- module.exports = {
1100
- buildBridgeCliArgs,
1101
- readNextMessage,
1102
- runBatch,
1103
- startServer,
1104
- };
477
+ { 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'] },
478
+ { 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'] },
479
+ { command: 'launch-native-test', domain: 'app', summary: 'Launch the debug native bridge test Activity.', targetApp: true, options: ['serial', 'packageName'] },
480
+ { command: 'launch-flutter', domain: 'app', summary: 'Launch the Flutter Activity, optionally with an initial route.', targetApp: true, options: ['serial', 'packageName', 'initialRoute'] },
481
+ { command: 'permission-state', domain: 'app', summary: 'Read Android runtime permission state.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
482
+ { command: 'permission-grant', domain: 'app', summary: 'Grant an Android runtime permission.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
483
+ { command: 'permission-revoke', domain: 'app', summary: 'Revoke an Android runtime permission.', targetApp: true, options: ['serial', 'packageName', 'permission'] },
484
+ { command: 'permission-dialog', domain: 'app', summary: 'Tap a visible Android permission dialog allow button.', options: ['serial', 'targetText', 'buttonText', 'resourceId', 'attempts', 'intervalMs', 'exact'] },
485
+ { command: 'appops-set', domain: 'app', summary: 'Set an Android app-op mode.', targetApp: true, options: ['serial', 'packageName', 'op', 'mode'] },
486
+ { command: 'tap', domain: 'action', summary: 'Tap device coordinates through ADB.', options: ['serial', 'tapX', 'tapY'] },
487
+ { 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'] },
488
+ { command: 'tap-uia-text', domain: 'action', summary: 'Tap a UIAutomator node by text without relying on the in-app tree.', options: ['serial', 'targetText', 'exact'] },
489
+ { 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'] },
490
+ { 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'] },
491
+ { command: 'keyboard-state', domain: 'action', summary: 'Read Android soft keyboard visibility.', options: ['serial'] },
492
+ { command: 'hide-keyboard', domain: 'action', summary: 'Hide the Android soft keyboard.', options: ['serial', 'force', 'intervalMs'] },
493
+ { command: 'swipe', domain: 'action', summary: 'Swipe device coordinates through ADB.', options: ['serial', 'startX', 'startY', 'endX', 'endY', 'durationMs'] },
494
+ { command: 'keyevent', domain: 'action', summary: 'Send an Android keyevent through ADB.', options: ['serial', 'keyCode'] },
495
+ { command: 'flutter-tree', domain: 'flutter', summary: 'Read the latest Flutter layout snapshot.', targetApp: true, options: ['serial', 'packageName', 'port'] },
496
+ { command: 'flutter-nodes', domain: 'flutter', summary: 'Read Flutter operable nodes.', targetApp: true, options: ['serial', 'packageName', 'port'] },
497
+ { command: 'flutter-action', domain: 'flutter', summary: 'Dispatch a raw Flutter action payload.', targetApp: true, options: ['serial', 'packageName', 'payload'] },
498
+ { command: 'tap-flutter-text', domain: 'flutter', summary: 'Tap a Flutter node by visible text.', targetApp: true, options: ['serial', 'packageName', 'targetText'] },
499
+ { 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'] },
500
+ { command: 'scroll-flutter', domain: 'flutter', summary: 'Scroll Flutter content by delta or until text is visible.', targetApp: true, options: ['serial', 'packageName', 'targetText', 'delta', 'maxSwipes'] },
501
+ { command: 'h5-dom', domain: 'webview', summary: 'Read native Android WebView DOM.', targetApp: true, options: ['serial', 'packageName', 'port'] },
502
+ { command: 'h5-eval', domain: 'webview', summary: 'Execute JavaScript in the current native Android WebView.', targetApp: true, options: ['serial', 'packageName', 'script'] },
503
+ { command: 'h5-click', domain: 'webview', summary: 'Click a native WebView element by selector or text.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'exact'] },
504
+ { command: 'h5-input', domain: 'webview', summary: 'Set text in a native WebView input.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'value', 'exact'] },
505
+ { command: 'h5-wait', domain: 'webview', summary: 'Wait for native WebView text or selector.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'timeoutSec', 'intervalMs'] },
506
+ { 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'] },
507
+ { command: 'flutter-h5-dom', domain: 'webview', summary: 'Read DOM through a Flutter H5 adapter.', targetApp: true, options: ['serial', 'packageName', 'port'] },
508
+ { command: 'flutter-h5-eval', domain: 'webview', summary: 'Execute JavaScript through a Flutter H5 adapter.', targetApp: true, options: ['serial', 'packageName', 'script'] },
509
+ { command: 'flutter-h5-click', domain: 'webview', summary: 'Click a Flutter H5 DOM element.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'exact'] },
510
+ { command: 'flutter-h5-input', domain: 'webview', summary: 'Set text in a Flutter H5 input.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'value', 'exact'] },
511
+ { command: 'flutter-h5-wait', domain: 'webview', summary: 'Wait for Flutter H5 text or selector.', targetApp: true, options: ['serial', 'packageName', 'selector', 'targetText', 'timeoutSec', 'intervalMs'] },
512
+ { 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'] },
513
+ { command: 'webview-pages', domain: 'webview', summary: 'List attachable Android WebView DevTools/CDP pages.', targetApp: true, options: ['serial', 'packageName', 'webviewPort', 'socketName', 'targetId', 'pageUrlFilter', 'keepForward'] },
514
+ { 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'] },
515
+ { 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'] },
516
+ { command: 'ios-doctor', domain: 'ios', summary: 'Check Xcode, connected iPhone, AiAppBridgeIOS runtime, and WebDriverAgent readiness.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'wdaUrl'] },
517
+ { command: 'ios-setup', domain: 'ios', summary: 'Verify full-control iOS setup; can install/launch the app and start WDA when signing inputs are supplied.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'appPath', 'iosHost', 'iosPort', 'runtimeUrl', 'wdaUrl', 'wdaProjectPath', 'wdaBundleId', 'teamId', 'startWda'] },
518
+ { command: 'ios-devices', domain: 'ios', summary: 'List iOS devices known to xcrun devicectl.', targetKind: 'ios-device', options: ['deviceId'] },
519
+ { command: 'ios-install-app', domain: 'ios', summary: 'Install an iOS .app bundle through devicectl.', targetKind: 'ios-app', options: ['deviceId', 'appPath'] },
520
+ { command: 'ios-launch-app', domain: 'ios', summary: 'Launch an iOS app by bundle identifier through devicectl.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'terminateExisting'] },
521
+ { command: 'ios-status', domain: 'ios', summary: 'Read AiAppBridgeIOS runtime status.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
522
+ { command: 'ios-tree', domain: 'ios', summary: 'Read UIKit tree from the AiAppBridgeIOS runtime.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
523
+ { command: 'ios-logs', domain: 'ios', summary: 'Read in-app iOS log records.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'sinceId', 'sinceMs', 'limit'] },
524
+ { command: 'ios-network', domain: 'ios', summary: 'Read in-app iOS network records.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'sinceId', 'sinceMs', 'limit'] },
525
+ { command: 'ios-state', domain: 'ios', summary: 'Read in-app iOS state records.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'sinceId', 'sinceMs', 'limit'] },
526
+ { command: 'ios-events', domain: 'ios', summary: 'Read in-app iOS event records.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'sinceId', 'sinceMs', 'limit'] },
527
+ { command: 'ios-h5-dom', domain: 'ios', summary: 'Read WKWebView DOM from the AiAppBridgeIOS runtime.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
528
+ { command: 'ios-h5-eval', domain: 'ios', summary: 'Execute JavaScript in WKWebView through the AiAppBridgeIOS runtime.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'script'] },
529
+ { command: 'ios-flutter-tree', domain: 'ios', summary: 'Read Flutter iOS layout snapshot from the runtime.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
530
+ { command: 'ios-flutter-nodes', domain: 'ios', summary: 'Read Flutter iOS operable nodes from the runtime.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl'] },
531
+ { command: 'ios-flutter-action', domain: 'ios', summary: 'Dispatch a Flutter iOS action through the runtime.', targetKind: 'ios-app', options: ['deviceId', 'bundleId', 'iosHost', 'iosPort', 'runtimeUrl', 'payload'] },
532
+ { command: 'ios-screenshot', domain: 'ios', summary: 'Capture an iOS device screenshot through devicectl.', targetKind: 'ios-device', options: ['deviceId', 'outFile', 'artifactDir', 'displayUniqueId'] },
533
+ { command: 'ios-wda-status', domain: 'ios', summary: 'Check WebDriverAgent status.', targetKind: 'ios-device', options: ['wdaUrl'] },
534
+ { command: 'ios-uia-tree', domain: 'ios', summary: 'Read the XCUITest/WebDriverAgent UI tree.', targetKind: 'ios-device', options: ['bundleId', 'wdaUrl', 'wdaSessionId'] },
535
+ { command: 'ios-tap', domain: 'ios', summary: 'Tap iOS device coordinates through WebDriverAgent.', targetKind: 'ios-device', options: ['bundleId', 'wdaUrl', 'wdaSessionId', 'tapX', 'tapY'] },
536
+ { command: 'ios-input', domain: 'ios', summary: 'Type text through WebDriverAgent, optionally tapping coordinates first.', targetKind: 'ios-device', options: ['bundleId', 'wdaUrl', 'wdaSessionId', 'text', 'tapX', 'tapY', 'accessibilityId', 'elementId', 'clearFirst'] },
537
+ { command: 'ios-swipe', domain: 'ios', summary: 'Swipe through WebDriverAgent.', targetKind: 'ios-device', options: ['bundleId', 'wdaUrl', 'wdaSessionId', 'startX', 'startY', 'endX', 'endY', 'durationMs'] },
538
+ { command: 'web-provider-status', domain: 'web', summary: 'Read desktop Web Bridge provider status.', options: [] },
539
+ { command: 'web-session-start', domain: 'web', summary: 'Start the desktop Web Bridge WebSocket session server.', options: ['host', 'webPort', 'path', 'token'] },
540
+ { command: 'web-connect-info', domain: 'web', summary: 'Read the Web Bridge endpoint and token for SDK clients.', options: [] },
541
+ { command: 'web-sessions', domain: 'web', summary: 'List connected Web Bridge SDK sessions.', options: [] },
542
+ { command: 'web-status', domain: 'web', summary: 'Read status and capture counts for a Web Bridge session.', targetKind: 'web-target', options: ['sessionId'] },
543
+ { command: 'web-dom', domain: 'web', summary: 'Read or refresh a Web Bridge DOM snapshot.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'refresh', 'timeoutMs'] },
544
+ { command: 'web-logs', domain: 'web', summary: 'Read Web Bridge log records.', targetKind: 'web-target', options: ['sessionId', 'sinceId', 'sinceMs', 'limit'] },
545
+ { command: 'web-network', domain: 'web', summary: 'Read Web Bridge network records.', targetKind: 'web-target', options: ['sessionId', 'sinceId', 'sinceMs', 'limit'] },
546
+ { command: 'web-state', domain: 'web', summary: 'Read Web Bridge state records.', targetKind: 'web-target', options: ['sessionId', 'sinceId', 'sinceMs', 'limit'] },
547
+ { command: 'web-events', domain: 'web', summary: 'Read Web Bridge event records.', targetKind: 'web-target', options: ['sessionId', 'sinceId', 'sinceMs', 'limit'] },
548
+ { command: 'web-command', domain: 'web', summary: 'Run a whitelisted command in a connected Web Bridge SDK session.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'name', 'arguments', 'timeoutMs'] },
549
+ { command: 'web-click', domain: 'web', summary: 'Click a DOM element through the Web Bridge SDK command path.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'targetText', 'timeoutMs'] },
550
+ { command: 'web-input', domain: 'web', summary: 'Set text in a DOM input through the Web Bridge SDK command path.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'value', 'timeoutMs'] },
551
+ { command: 'web-wait', domain: 'web', summary: 'Wait for text or selector through the Web Bridge SDK command path.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'targetText', 'timeoutMs'] },
552
+ { command: 'web-scroll', domain: 'web', summary: 'Scroll a Web Bridge DOM target.', targetKind: 'web-target', options: ['sessionId', 'targetId', 'selector', 'deltaX', 'deltaY', 'timeoutMs'] },
553
+ { command: 'forward', domain: 'advanced', summary: 'Create the ADB port forward for the bridge.', targetApp: true, options: ['serial', 'packageName', 'port'] },
554
+ { command: 'remove-forward', domain: 'advanced', summary: 'Remove the ADB port forward for the bridge.', options: ['serial', 'port'] },
555
+ { command: 'batch', domain: 'advanced', summary: 'Run multiple AI App Bridge commands serially in one MCP call.', options: ['defaults', 'steps', 'stopOnError', 'includeRaw', 'maxRawChars'] },
556
+ { command: 'smoke', domain: 'diagnostics', summary: 'Run the native sample smoke test.', options: ['serial', 'packageName', 'outFile', 'artifactDir', 'skipFlutterLaunch'] },
557
+ ];
558
+
559
+ const commandByName = new Map(commandDefinitions.map((definition) => [definition.command, definition]));
560
+
561
+ async function callTool(name, args) {
562
+ if (name === 'capabilities') {
563
+ return toolJson(capabilityPayload(args));
564
+ }
565
+ if (name === 'run') {
566
+ return runGeneric(args);
567
+ }
568
+ if (name === 'run_smoke') {
569
+ return runSmoke(args);
570
+ }
571
+ const commandMap = {
572
+ flutter_tree: 'flutter-tree',
573
+ h5_dom: 'h5-dom',
574
+ h5_eval: 'h5-eval',
575
+ h5_click: 'h5-click',
576
+ h5_input: 'h5-input',
577
+ h5_wait: 'h5-wait',
578
+ h5_scroll: 'h5-scroll',
579
+ freeze_app: 'freeze-app',
580
+ thaw_app: 'thaw-app',
581
+ flutter_h5_dom: 'flutter-h5-dom',
582
+ flutter_h5_eval: 'flutter-h5-eval',
583
+ flutter_h5_click: 'flutter-h5-click',
584
+ flutter_h5_input: 'flutter-h5-input',
585
+ flutter_h5_wait: 'flutter-h5-wait',
586
+ flutter_h5_scroll: 'flutter-h5-scroll',
587
+ flutter_nodes: 'flutter-nodes',
588
+ tap_flutter_text: 'tap-flutter-text',
589
+ input_flutter_text: 'input-flutter-text',
590
+ uia_tree: 'uia-tree',
591
+ install_apk: 'install-apk',
592
+ clear_app_data: 'clear-app-data',
593
+ launch_app: 'launch-app',
594
+ launch_activity: 'launch-activity',
595
+ launch_native_test: 'launch-native-test',
596
+ launch_flutter: 'launch-flutter',
597
+ tap_text: 'tap-text',
598
+ wait_text: 'wait-text',
599
+ input_text: 'input-text',
600
+ keyboard_state: 'keyboard-state',
601
+ hide_keyboard: 'hide-keyboard',
602
+ webview_pages: 'webview-pages',
603
+ webview_network: 'webview-network',
604
+ webview_console: 'webview-console',
605
+ permission_state: 'permission-state',
606
+ permission_grant: 'permission-grant',
607
+ permission_revoke: 'permission-revoke',
608
+ appops_set: 'appops-set',
609
+ tap_uia_text: 'tap-uia-text',
610
+ permission_dialog: 'permission-dialog',
611
+ };
612
+ const command = commandMap[name] || normalizeCommandName(name);
613
+ return runBridgeChecked(command, args);
614
+ }
615
+
616
+ function capabilityPayload(args = {}) {
617
+ const includeOptions = Boolean(args.includeOptions);
618
+ const requestedCommand = args.command ? normalizeCommandName(args.command) : '';
619
+ if (requestedCommand) {
620
+ const definition = commandByName.get(requestedCommand);
621
+ return {
622
+ ok: Boolean(definition),
623
+ command: requestedCommand,
624
+ ...(definition ? shapeCommandDefinition(definition, true) : { error: 'unknown_command' }),
625
+ };
626
+ }
627
+
628
+ const requestedDomain = args.domain ? String(args.domain) : '';
629
+ const domains = {};
630
+ for (const definition of commandDefinitions) {
631
+ if (requestedDomain && definition.domain !== requestedDomain) continue;
632
+ if (!domains[definition.domain]) domains[definition.domain] = [];
633
+ domains[definition.domain].push(shapeCommandDefinition(definition, includeOptions));
634
+ }
635
+ return {
636
+ ok: true,
637
+ surface: mcpSurface === 'full' || mcpSurface === 'legacy' ? 'full' : 'compact',
638
+ usage: 'Use run with one of these command names. Prefer packageName for app-specific commands; install-apk, clear-app-data, launch-app, freeze/thaw, UI, WebView, logcat, network, and permission workflows are supported.',
639
+ domains,
640
+ };
641
+ }
642
+
643
+ function shapeCommandDefinition(definition, includeOptions) {
644
+ return {
645
+ command: definition.command,
646
+ summary: definition.summary,
647
+ targetApp: Boolean(definition.targetApp),
648
+ targetKind: definition.targetKind || (definition.targetApp ? 'android-app' : 'none'),
649
+ ...(includeOptions ? { options: definition.options || [] } : {}),
650
+ };
651
+ }
652
+
653
+ async function runGeneric(args = {}) {
654
+ const command = normalizeCommandName(args.command);
655
+ if (!commandByName.has(command)) {
656
+ return toolText(`unknown command: ${args.command || ''}`, true);
657
+ }
658
+ const commandArgs = {
659
+ ...(args.arguments && typeof args.arguments === 'object' ? args.arguments : {}),
660
+ };
661
+ for (const key of sharedRunArgKeys()) {
662
+ if (args[key] !== undefined && commandArgs[key] === undefined) {
663
+ commandArgs[key] = args[key];
664
+ }
665
+ }
666
+ if (command === 'batch') {
667
+ return runBatch(commandArgs);
668
+ }
669
+ return runBridgeChecked(command, commandArgs);
670
+ }
671
+
672
+ function normalizeCommandName(value) {
673
+ return String(value || '').trim().replace(/_/g, '-');
674
+ }
675
+
676
+ function sharedRunArgKeys() {
677
+ return [
678
+ 'adb',
679
+ 'serial',
680
+ 'port',
681
+ 'packageName',
682
+ 'artifactDir',
683
+ 'sessionId',
684
+ 'targetId',
685
+ 'webPort',
686
+ 'deviceId',
687
+ 'bundleId',
688
+ 'iosHost',
689
+ 'iosPort',
690
+ 'runtimeUrl',
691
+ 'wdaUrl',
692
+ 'wdaSessionId',
693
+ 'wdaProjectPath',
694
+ 'wdaBundleId',
695
+ 'accessibilityId',
696
+ 'elementId',
697
+ 'teamId',
698
+ 'appPath',
699
+ 'xcodebuild',
700
+ 'devicectl',
701
+ ];
702
+ }
703
+
704
+ function runBridgeChecked(command, args = {}) {
705
+ const definition = commandByName.get(command);
706
+ if (definition?.domain === 'ios') {
707
+ return runIOSChecked(command, args);
708
+ }
709
+ if (definition?.domain === 'web') {
710
+ return runWebChecked(command, args);
711
+ }
712
+ if (command === 'clear-app-data' && !args.packageName) {
713
+ return toolText('clear-app-data: packageName is required in MCP mode so the command cannot clear a default package.', true);
714
+ }
715
+ if ((command === 'freeze-app' || command === 'thaw-app') && !args.packageName) {
716
+ return toolText(`${command}: packageName is required in MCP mode so the command cannot signal a default package.`, true);
717
+ }
718
+ if (definition?.targetApp && !args.packageName && !args.port) {
719
+ return toolText(`${command}: packageName or explicit port is required in MCP mode so the command cannot fall back to a default package.`, true);
720
+ }
721
+ return runBridge(command, args);
722
+ }
723
+
724
+ async function runWebChecked(command, args = {}) {
725
+ const result = await webProvider.run(command, args);
726
+ return toolJson(result, result?.ok === false);
727
+ }
728
+
729
+ async function runIOSChecked(command, args = {}) {
730
+ const result = await iosProvider.run(command, args);
731
+ return toolJson(result, result?.ok === false);
732
+ }
733
+
734
+ async function runBatch(args = {}, runner = runBridgeChecked) {
735
+ const startedAtMs = Date.now();
736
+ const mode = args.mode ? String(args.mode) : 'serial';
737
+ if (mode !== 'serial') {
738
+ return toolJson({ ok: false, error: 'batch_mode_not_supported', mode }, true);
739
+ }
740
+ const steps = Array.isArray(args.steps) ? args.steps : [];
741
+ if (steps.length === 0) {
742
+ return toolJson({ ok: false, error: 'batch_steps_required' }, true);
743
+ }
744
+ const maxSteps = args.maxSteps === undefined ? 30 : Number(args.maxSteps);
745
+ if (!Number.isInteger(maxSteps) || maxSteps < 1) {
746
+ return toolJson({ ok: false, error: 'invalid_max_steps', maxSteps: args.maxSteps }, true);
747
+ }
748
+ if (steps.length > maxSteps) {
749
+ return toolJson({ ok: false, error: 'batch_too_many_steps', stepCount: steps.length, maxSteps }, true);
750
+ }
751
+
752
+ const defaults = args.defaults && typeof args.defaults === 'object' ? { ...args.defaults } : {};
753
+ for (const key of sharedRunArgKeys()) {
754
+ if (args[key] !== undefined && defaults[key] === undefined) {
755
+ defaults[key] = args[key];
756
+ }
757
+ }
758
+
759
+ const normalizedSteps = [];
760
+ const seenIds = new Set();
761
+ for (let index = 0; index < steps.length; index += 1) {
762
+ const rawStep = steps[index] && typeof steps[index] === 'object' ? steps[index] : {};
763
+ const stepId = String(rawStep.id || `step_${index + 1}`);
764
+ if (seenIds.has(stepId)) {
765
+ return toolJson({ ok: false, error: 'duplicate_batch_step_id', stepId }, true);
766
+ }
767
+ seenIds.add(stepId);
768
+ const command = normalizeCommandName(rawStep.command);
769
+ if (!commandByName.has(command)) {
770
+ return toolJson({ ok: false, error: 'unknown_batch_step_command', stepId, command: rawStep.command || '' }, true);
771
+ }
772
+ if (command === 'batch') {
773
+ return toolJson({ ok: false, error: 'nested_batch_not_supported', stepId }, true);
774
+ }
775
+ normalizedSteps.push({ ...rawStep, id: stepId, command });
776
+ }
777
+
778
+ const stopOnError = args.stopOnError !== false;
779
+ const includeRaw = Boolean(args.includeRaw);
780
+ const maxRawChars = args.maxRawChars === undefined ? 4000 : Number(args.maxRawChars);
781
+ if (!Number.isInteger(maxRawChars) || maxRawChars < 0) {
782
+ return toolJson({ ok: false, error: 'invalid_max_raw_chars', maxRawChars: args.maxRawChars }, true);
783
+ }
784
+ const results = [];
785
+ let stopped = false;
786
+
787
+ for (const step of normalizedSteps) {
788
+ if (stopped) {
789
+ results.push({
790
+ id: step.id,
791
+ command: step.command,
792
+ status: 'skipped',
793
+ ok: false,
794
+ skipped: true,
795
+ reason: 'stopOnError',
796
+ });
797
+ continue;
798
+ }
799
+
800
+ const stepStartedAtMs = Date.now();
801
+ const stepArgs = {
802
+ ...defaults,
803
+ ...(step.arguments && typeof step.arguments === 'object' ? step.arguments : {}),
804
+ };
805
+ for (const key of sharedRunArgKeys()) {
806
+ if (step[key] !== undefined) {
807
+ stepArgs[key] = step[key];
808
+ }
809
+ }
810
+ try {
811
+ const toolResult = await runner(step.command, stepArgs);
812
+ const parsed = parseToolResult(toolResult);
813
+ const passed = !parsed.isError && parsed.payload?.ok !== false;
814
+ const stepResult = {
815
+ id: step.id,
816
+ command: step.command,
817
+ status: passed ? 'passed' : 'failed',
818
+ ok: passed,
819
+ packageName: stepArgs.packageName,
820
+ bundleId: stepArgs.bundleId,
821
+ deviceId: stepArgs.deviceId,
822
+ sessionId: stepArgs.sessionId,
823
+ targetId: stepArgs.targetId,
824
+ port: stepArgs.port,
825
+ iosPort: stepArgs.iosPort,
826
+ durationMs: Date.now() - stepStartedAtMs,
827
+ summary: summarizeToolPayload(parsed),
828
+ };
829
+ if (!passed) {
830
+ stepResult.error = parsed.payload?.error || firstTextLine(parsed.text) || 'command_failed';
831
+ }
832
+ if (includeRaw) {
833
+ stepResult.result = parsed.payload || undefined;
834
+ stepResult.rawText = parsed.payload ? undefined : truncateText(parsed.text, maxRawChars);
835
+ }
836
+ results.push(stepResult);
837
+ if (!passed && stopOnError) {
838
+ stopped = true;
839
+ }
840
+ } catch (error) {
841
+ const stepResult = {
842
+ id: step.id,
843
+ command: step.command,
844
+ status: 'failed',
845
+ ok: false,
846
+ packageName: stepArgs.packageName,
847
+ bundleId: stepArgs.bundleId,
848
+ deviceId: stepArgs.deviceId,
849
+ port: stepArgs.port,
850
+ iosPort: stepArgs.iosPort,
851
+ durationMs: Date.now() - stepStartedAtMs,
852
+ error: error.message || String(error),
853
+ };
854
+ results.push(stepResult);
855
+ if (stopOnError) {
856
+ stopped = true;
857
+ }
858
+ }
859
+ }
860
+
861
+ const failed = results.filter((item) => item.status === 'failed').length;
862
+ const skipped = results.filter((item) => item.status === 'skipped').length;
863
+ const passed = results.filter((item) => item.status === 'passed').length;
864
+ return toolJson({
865
+ ok: failed === 0,
866
+ batchId: args.batchId || generatedBatchId(),
867
+ mode,
868
+ stopOnError,
869
+ stepCount: normalizedSteps.length,
870
+ passed,
871
+ failed,
872
+ skipped,
873
+ durationMs: Date.now() - startedAtMs,
874
+ steps: results,
875
+ }, failed > 0);
876
+ }
877
+
878
+ async function runBridge(command, args) {
879
+ return runProcess(buildBridgeCliArgs(command, args));
880
+ }
881
+
882
+ function parseToolResult(toolResult) {
883
+ const text = String(toolResult?.content?.[0]?.text || '');
884
+ try {
885
+ return {
886
+ isError: Boolean(toolResult?.isError),
887
+ text,
888
+ payload: JSON.parse(text),
889
+ };
890
+ } catch (_) {
891
+ return {
892
+ isError: Boolean(toolResult?.isError),
893
+ text,
894
+ payload: null,
895
+ };
896
+ }
897
+ }
898
+
899
+ function summarizeToolPayload(parsed) {
900
+ const payload = parsed.payload;
901
+ if (!payload || typeof payload !== 'object') {
902
+ return { text: truncateText(parsed.text, 500) };
903
+ }
904
+ const summary = {
905
+ ok: payload.ok,
906
+ error: payload.error || null,
907
+ };
908
+ if (payload.packageName) summary.packageName = payload.packageName;
909
+ if (payload.bundleId) summary.bundleId = payload.bundleId;
910
+ if (payload.endpoint) summary.endpoint = payload.endpoint;
911
+ if (payload.wdaUrl) summary.wdaUrl = payload.wdaUrl;
912
+ if (payload.device?.identifier) summary.deviceId = payload.device.identifier;
913
+ if (payload.selectedDevice?.identifier) summary.deviceId = payload.selectedDevice.identifier;
914
+ if (payload.sessionId) summary.sessionId = payload.sessionId;
915
+ if (payload.targetId) summary.targetId = payload.targetId;
916
+ if (payload.session?.sessionId) summary.sessionId = payload.session.sessionId;
917
+ if (payload.app?.packageName) summary.app = payload.app.packageName;
918
+ if (payload.activity) summary.activity = payload.activity;
919
+ if (payload.component) summary.component = payload.component;
920
+ if (payload.transport) summary.transport = payload.transport;
921
+ if (payload.source) summary.source = payload.source;
922
+ if (payload.path) summary.path = payload.path;
923
+ if (payload.debugBridge) {
924
+ summary.bridge = {
925
+ version: payload.debugBridge.version,
926
+ port: payload.debugBridge.port,
927
+ };
928
+ }
929
+ if (payload.count !== undefined) summary.count = payload.count;
930
+ if (payload.sessionCount !== undefined) summary.sessionCount = payload.sessionCount;
931
+ if (payload.nodeCount !== undefined) summary.nodeCount = payload.nodeCount;
932
+ if (Array.isArray(payload.items)) summary.items = payload.items.length;
933
+ if (payload.values && typeof payload.values === 'object') {
934
+ summary.values = Object.keys(payload.values).length;
935
+ }
936
+ if (payload.counts) summary.counts = payload.counts;
937
+ if (Array.isArray(payload.requests)) summary.requests = payload.requests.length;
938
+ if (Array.isArray(payload.console)) summary.console = payload.console.length;
939
+ if (payload.flutter?.layout?.operable) {
940
+ summary.flutterOperable = {
941
+ ok: payload.flutter.layout.operable.ok,
942
+ count: payload.flutter.layout.operable.count,
943
+ };
944
+ }
945
+ if (payload.result && typeof payload.result === 'object') {
946
+ summary.result = {
947
+ ok: payload.result.ok,
948
+ error: payload.result.error || null,
949
+ value: truncateText(payload.result.value, 200),
950
+ bodyText: truncateText(payload.result.bodyText, 200),
951
+ };
952
+ }
953
+ return summary;
954
+ }
955
+
956
+ function truncateText(value, maxChars) {
957
+ if (value === undefined || value === null) return value;
958
+ const text = String(value);
959
+ if (text.length <= maxChars) return text;
960
+ return `${text.slice(0, maxChars)}...`;
961
+ }
962
+
963
+ function firstTextLine(value) {
964
+ const lines = String(value || '').split(/\r?\n/).filter((line) => line.trim());
965
+ return lines.find((line) => {
966
+ const text = line.trim().toLowerCase();
967
+ return text !== 'stderr:' && text !== 'stdout:';
968
+ }) || lines[0] || '';
969
+ }
970
+
971
+ function generatedBatchId() {
972
+ return `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
973
+ }
974
+
975
+ function buildBridgeCliArgs(command, args = {}) {
976
+ const cliArgs = [cliScript, command];
977
+ addCommonArgs(cliArgs, args);
978
+ addArg(cliArgs, 'device-id', args.deviceId);
979
+ addArg(cliArgs, 'bundle-id', args.bundleId);
980
+ addArg(cliArgs, 'app-path', args.appPath);
981
+ addArg(cliArgs, 'ios-host', args.iosHost);
982
+ addArg(cliArgs, 'ios-port', args.iosPort);
983
+ addArg(cliArgs, 'runtime-url', args.runtimeUrl);
984
+ addArg(cliArgs, 'wda-url', args.wdaUrl);
985
+ addArg(cliArgs, 'wda-session-id', args.wdaSessionId);
986
+ addArg(cliArgs, 'wda-project-path', args.wdaProjectPath);
987
+ addArg(cliArgs, 'wda-bundle-id', args.wdaBundleId);
988
+ addArg(cliArgs, 'accessibility-id', args.accessibilityId);
989
+ addArg(cliArgs, 'element-id', args.elementId);
990
+ addArg(cliArgs, 'clear-first', args.clearFirst);
991
+ addArg(cliArgs, 'team-id', args.teamId);
992
+ addArg(cliArgs, 'start-wda', args.startWda);
993
+ addArg(cliArgs, 'devicectl', args.devicectl);
994
+ addArg(cliArgs, 'xcodebuild', args.xcodebuild);
995
+ addArg(cliArgs, 'display-unique-id', args.displayUniqueId);
996
+ addArg(cliArgs, 'terminate-existing', args.terminateExisting);
997
+ addArg(cliArgs, 'initial-route', args.initialRoute);
998
+ addArg(cliArgs, 'activity', args.activity);
999
+ addArg(cliArgs, 'component', args.component);
1000
+ addArg(cliArgs, 'action', args.action);
1001
+ addRepeatedArg(cliArgs, 'category', args.category);
1002
+ addArg(cliArgs, 'data', args.data);
1003
+ addExtraArgs(cliArgs, args.extra);
1004
+ addArg(cliArgs, 'out-file', args.outFile);
1005
+ addArg(cliArgs, 'artifact-dir', args.artifactDir || defaultArtifactDirFor(command, args));
1006
+ addArg(cliArgs, 'apk-path', args.apkPath);
1007
+ addArg(cliArgs, 'tap-x', args.tapX);
1008
+ addArg(cliArgs, 'tap-y', args.tapY);
1009
+ addArg(cliArgs, 'target-text', args.targetText);
1010
+ addArg(cliArgs, 'no-auto-hide-keyboard', args.noAutoHideKeyboard);
1011
+ addArg(cliArgs, 'timeout-sec', args.timeoutSec);
1012
+ addArg(cliArgs, 'text', args.text);
1013
+ addArg(cliArgs, 'hide-keyboard', args.hideKeyboard);
1014
+ addArg(cliArgs, 'force', args.force);
1015
+ addArg(cliArgs, 'start-x', args.startX);
1016
+ addArg(cliArgs, 'start-y', args.startY);
1017
+ addArg(cliArgs, 'end-x', args.endX);
1018
+ addArg(cliArgs, 'end-y', args.endY);
1019
+ addArg(cliArgs, 'duration-ms', args.durationMs);
1020
+ addArg(cliArgs, 'allow-downgrade', args.allowDowngrade);
1021
+ addArg(cliArgs, 'streaming', args.streaming);
1022
+ addArg(cliArgs, 'install-timeout-ms', args.installTimeoutMs);
1023
+ addArg(cliArgs, 'installer-timeout-ms', args.installerTimeoutMs);
1024
+ addArg(cliArgs, 'webview-port', args.webviewPort);
1025
+ addArg(cliArgs, 'socket-name', args.socketName);
1026
+ addArg(cliArgs, 'target-id', args.targetId);
1027
+ addArg(cliArgs, 'page-url-filter', args.pageUrlFilter);
1028
+ addArg(cliArgs, 'url-filter', args.urlFilter);
1029
+ addArg(cliArgs, 'method', args.method);
1030
+ addArg(cliArgs, 'status-code', args.statusCode);
1031
+ addArg(cliArgs, 'compact', args.compact);
1032
+ addArg(cliArgs, 'full', args.full);
1033
+ addArg(cliArgs, 'text-filter', args.textFilter);
1034
+ addArg(cliArgs, 'resource-id-filter', args.resourceIdFilter);
1035
+ addArg(cliArgs, 'class-filter', args.classFilter);
1036
+ addArg(cliArgs, 'visible-only', args.visibleOnly);
1037
+ addArg(cliArgs, 'max-nodes', args.maxNodes);
1038
+ addArg(cliArgs, 'max-depth', args.maxDepth);
1039
+ addArg(cliArgs, 'no-bodies', args.noBodies);
1040
+ addArg(cliArgs, 'duration-ms', args.durationMs);
1041
+ addArg(cliArgs, 'include-response-body', args.includeResponseBody);
1042
+ addArg(cliArgs, 'body-max-bytes', args.bodyMaxBytes);
1043
+ addArg(cliArgs, 'max-events', args.maxEvents);
1044
+ addArg(cliArgs, 'keep-forward', args.keepForward);
1045
+ addArg(cliArgs, 'key-code', args.keyCode);
1046
+ addArg(cliArgs, 'payload', args.payload);
1047
+ addArg(cliArgs, 'delta', args.delta);
1048
+ addArg(cliArgs, 'max-swipes', args.maxSwipes);
1049
+ addArg(cliArgs, 'permission', args.permission);
1050
+ addArg(cliArgs, 'op', args.op);
1051
+ addArg(cliArgs, 'mode', args.mode);
1052
+ addArg(cliArgs, 'script', args.script);
1053
+ addArg(cliArgs, 'selector', args.selector);
1054
+ addArg(cliArgs, 'target-text', args.targetText);
1055
+ addArg(cliArgs, 'value', args.value);
1056
+ addArg(cliArgs, 'exact', args.exact);
1057
+ addArg(cliArgs, 'button-text', args.buttonText);
1058
+ addArg(cliArgs, 'resource-id', args.resourceId);
1059
+ addArg(cliArgs, 'attempts', args.attempts);
1060
+ addArg(cliArgs, 'interval-ms', args.intervalMs);
1061
+ addArg(cliArgs, 'delta-x', args.deltaX);
1062
+ addArg(cliArgs, 'delta-y', args.deltaY);
1063
+ addArg(cliArgs, 'require-text', args.requireText);
1064
+ addArg(cliArgs, 'absent-text', args.absentText);
1065
+ addArg(cliArgs, 'require-activity', args.requireActivity);
1066
+ addArg(cliArgs, 'since-id', args.sinceId);
1067
+ addArg(cliArgs, 'since-ms', args.sinceMs);
1068
+ addArg(cliArgs, 'limit', args.limit);
1069
+ addArg(cliArgs, 'pid', args.pid);
1070
+ addArg(cliArgs, 'app-pid', args.appPid);
1071
+ addArg(cliArgs, 'tag', args.tag);
1072
+ addArg(cliArgs, 'level', args.level);
1073
+ addArg(cliArgs, 'grep', args.grep);
1074
+ addArg(cliArgs, 'lines', args.lines);
1075
+ addArg(cliArgs, 'since', args.since);
1076
+ addArg(cliArgs, 'follow', args.follow);
1077
+ addArg(cliArgs, 'duration-sec', args.durationSec);
1078
+ addArg(cliArgs, 'clear', args.clear);
1079
+ addArg(cliArgs, 'skip-flutter-launch', args.skipFlutterLaunch);
1080
+ return cliArgs;
1081
+ }
1082
+
1083
+ async function runSmoke(args) {
1084
+ const cliArgs = [cliScript, 'smoke'];
1085
+ addCommonArgs(cliArgs, args);
1086
+ addArg(cliArgs, 'out-file', args.outFile);
1087
+ addArg(cliArgs, 'artifact-dir', args.artifactDir || defaultArtifactDirFor('smoke', args));
1088
+ addArg(cliArgs, 'skip-flutter-launch', args.skipFlutterLaunch);
1089
+ return runProcess(cliArgs);
1090
+ }
1091
+
1092
+ function defaultArtifactDirFor(command, args) {
1093
+ if (args.outFile) return '';
1094
+ if (command !== 'screenshot' && command !== 'smoke') return '';
1095
+ return path.join(process.cwd(), 'build', 'ai_app_bridge_artifacts');
1096
+ }
1097
+
1098
+ function addCommonArgs(cliArgs, args) {
1099
+ addArg(cliArgs, 'adb', args.adb);
1100
+ addArg(cliArgs, 'serial', args.serial);
1101
+ addArg(cliArgs, 'port', args.port);
1102
+ addArg(cliArgs, 'package-name', args.packageName);
1103
+ }
1104
+
1105
+ function addArg(cliArgs, name, value) {
1106
+ if (value === undefined || value === null || value === '' || value === false) {
1107
+ return;
1108
+ }
1109
+ cliArgs.push(`--${name}`, String(value));
1110
+ }
1111
+
1112
+ function addRepeatedArg(cliArgs, name, value) {
1113
+ if (Array.isArray(value)) {
1114
+ for (const item of value) addArg(cliArgs, name, item);
1115
+ return;
1116
+ }
1117
+ addArg(cliArgs, name, value);
1118
+ }
1119
+
1120
+ function addExtraArgs(cliArgs, value) {
1121
+ if (Array.isArray(value)) {
1122
+ for (const item of value) addArg(cliArgs, 'extra', item);
1123
+ return;
1124
+ }
1125
+ if (value && typeof value === 'object') {
1126
+ for (const [key, extraValue] of Object.entries(value)) {
1127
+ addArg(cliArgs, 'extra', `${key}=${extraValue}`);
1128
+ }
1129
+ return;
1130
+ }
1131
+ addArg(cliArgs, 'extra', value);
1132
+ }
1133
+
1134
+ function runProcess(cliArgs) {
1135
+ return new Promise((resolve) => {
1136
+ const child = spawn(nodeBinary, cliArgs, {
1137
+ cwd: bridgeDir,
1138
+ windowsHide: true,
1139
+ });
1140
+ let stdout = '';
1141
+ let stderr = '';
1142
+ child.stdout.on('data', (chunk) => {
1143
+ stdout += chunk.toString();
1144
+ });
1145
+ child.stderr.on('data', (chunk) => {
1146
+ stderr += chunk.toString();
1147
+ });
1148
+ child.on('error', (error) => {
1149
+ resolve(toolText(`failed to start Node bridge CLI: ${error.message}`, true));
1150
+ });
1151
+ child.on('close', (code) => {
1152
+ const text = [
1153
+ stdout.trim(),
1154
+ stderr.trim() ? `stderr:\n${stderr.trim()}` : '',
1155
+ code === 0 ? '' : `exitCode: ${code}`,
1156
+ retryWithPackageNameHint(cliArgs, stdout, stderr, code),
1157
+ ].filter(Boolean).join('\n\n');
1158
+ resolve(toolText(text || emptyProcessText(cliArgs), code !== 0));
1159
+ });
1160
+ });
1161
+ }
1162
+
1163
+ function emptyProcessText(cliArgs) {
1164
+ const command = cliArgs[1] || '';
1165
+ if (command === 'logcat' && cliArgs.includes('--app-pid')) {
1166
+ return 'logcat: no matching lines for current app pid';
1167
+ }
1168
+ if (command === 'logcat') {
1169
+ return 'logcat: no matching lines';
1170
+ }
1171
+ return 'ok';
1172
+ }
1173
+
1174
+ function retryWithPackageNameHint(cliArgs, stdout, stderr, code) {
1175
+ if (code === 0 || cliArgs.includes('--package-name') || cliArgs.includes('--port')) {
1176
+ return '';
1177
+ }
1178
+ const output = `${stdout}\n${stderr}`;
1179
+ if (!/HTTP timeout: http:\/\/127\.0\.0\.1:18080\//.test(output)) {
1180
+ return '';
1181
+ }
1182
+ 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.';
1183
+ }
1184
+
1185
+ function toolText(text, isError = false) {
1186
+ return {
1187
+ content: [
1188
+ {
1189
+ type: 'text',
1190
+ text,
1191
+ },
1192
+ ],
1193
+ isError,
1194
+ };
1195
+ }
1196
+
1197
+ function toolJson(value, isError = false) {
1198
+ return toolText(JSON.stringify(value, null, 2), isError);
1199
+ }
1200
+
1201
+ function sendResult(id, result) {
1202
+ send({ jsonrpc: '2.0', id, result });
1203
+ }
1204
+
1205
+ function sendError(id, code, message) {
1206
+ send({
1207
+ jsonrpc: '2.0',
1208
+ id,
1209
+ error: { code, message },
1210
+ });
1211
+ }
1212
+
1213
+ function send(message) {
1214
+ const body = Buffer.from(JSON.stringify(message), 'utf8');
1215
+ if (responseFormat === 'line') {
1216
+ process.stdout.write(`${body.toString('utf8')}\n`);
1217
+ return;
1218
+ }
1219
+ process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
1220
+ process.stdout.write(body);
1221
+ }
1222
+
1223
+ function writeLog(text) {
1224
+ process.stderr.write(`${text}\n`);
1225
+ }
1226
+
1227
+ if (require.main === module) {
1228
+ startServer();
1229
+ }
1230
+
1231
+ module.exports = {
1232
+ buildBridgeCliArgs,
1233
+ readNextMessage,
1234
+ runBatch,
1235
+ startServer,
1236
+ };