aiphone-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js ADDED
@@ -0,0 +1,1163 @@
1
+ /**
2
+ * aiphone-mcp — MCP server for AI-powered Android device control.
3
+ *
4
+ * Exposes MCP tools that mirror the Dart lib/ implementation so an LLM
5
+ * in LM Studio (or any MCP host) can directly control Android devices via ADB.
6
+ *
7
+ * Usage (LM Studio config):
8
+ * "aiphone": { "command": "npx", "args": ["aiphone-mcp"] }
9
+ *
10
+ * Optional env vars:
11
+ * AIPHONE_ADB_PATH – path to adb binary (default: adb)
12
+ */
13
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import {
16
+ CallToolRequestSchema,
17
+ ListToolsRequestSchema,
18
+ } from '@modelcontextprotocol/sdk/types.js';
19
+
20
+ import * as adbClient from './adb.js';
21
+ import { parseUiXml, compactElements, findElement } from './uiparser.js';
22
+ import { processScreenshot, mimeType } from './image.js';
23
+ // ── Bootstrap ────────────────────────────────────────────────────────────────
24
+
25
+ const ADB_PATH = process.env.AIPHONE_ADB_PATH || 'adb';
26
+
27
+ const server = new Server(
28
+ { name: 'aiphone-mcp', version: '1.0.0' },
29
+ { capabilities: { tools: {} } },
30
+ );
31
+
32
+ // ── Tool definitions ─────────────────────────────────────────────────────────
33
+
34
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
35
+ tools: [
36
+ // ── Device discovery ────────────────────────────────────────────────────
37
+ {
38
+ name: 'list_devices',
39
+ description:
40
+ 'Lists all currently connected and online ADB Android device serials. ' +
41
+ 'Call this first to discover which devices are available.',
42
+ inputSchema: { type: 'object', properties: {}, required: [] },
43
+ },
44
+
45
+ // ── Screen observation ──────────────────────────────────────────────────
46
+ {
47
+ name: 'take_screenshot',
48
+ description:
49
+ 'Takes a screenshot of the device screen and returns it as an optimized image. ' +
50
+ 'Use this ONLY when: (1) the user explicitly asks for a screenshot, (2) the user wants to observe visual/image content that cannot be read as text (e.g. photos, media, stories, drawings), or (3) the user asks to capture, compress, or convert the screen image. ' +
51
+ 'Do NOT call this just to check UI state, read text, find elements, or verify actions — use get_ui_elements instead, which is faster. ' +
52
+ 'If the information is accessible via UI elements, prefer get_ui_elements over taking a screenshot. ' +
53
+ 'Supports max_width/max_height resizing and webp/jpeg/png output. Defaults to WebP at 75%.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ device_id: {
58
+ type: 'string',
59
+ description: 'ADB device serial (from list_devices).',
60
+ },
61
+ max_width: {
62
+ type: 'integer',
63
+ description: 'Maximum output width in pixels. Image is scaled down proportionally. Default 1080.',
64
+ default: 1080,
65
+ },
66
+ max_height: {
67
+ type: 'integer',
68
+ description: 'Maximum output height in pixels. Image is scaled down proportionally. Default 1920.',
69
+ default: 1920,
70
+ },
71
+ format: {
72
+ type: 'string',
73
+ enum: ['webp', 'jpeg', 'png'],
74
+ description: 'Output image format. webp = smallest, png = lossless. Default webp.',
75
+ default: 'webp',
76
+ },
77
+ quality: {
78
+ type: 'integer',
79
+ description: 'Compression quality 1–100 (not used for png). Default 75.',
80
+ default: 75,
81
+ },
82
+ },
83
+ required: [],
84
+ },
85
+ },
86
+ {
87
+ name: 'get_ui_elements',
88
+ description:
89
+ 'Returns a structured list of interactive UI elements (id, text, content_desc, bounds, clickable flag). ' +
90
+ 'Use this to get tap targets and element coordinates — NOT as a substitute for visual observation. ',
91
+ inputSchema: {
92
+ type: 'object',
93
+ properties: {
94
+ device_id: {
95
+ type: 'string',
96
+ description: 'ADB device serial (from list_devices).',
97
+ },
98
+ limit: {
99
+ type: 'integer',
100
+ description: 'Max number of elements to return (default 30, max 150). Prioritises clickable elements.',
101
+ default: 30,
102
+ },
103
+ },
104
+ required: [],
105
+ },
106
+ },
107
+
108
+ // ── Touch actions ───────────────────────────────────────────────────────
109
+ {
110
+ name: 'tap',
111
+ description:
112
+ 'Taps the device screen at absolute coordinates (x, y). ' +
113
+ 'Obtain x,y from the center of an element\'s bounds returned by get_ui_elements. ' +
114
+ 'IMPORTANT: Avoid tapping profile pictures, avatars, or person thumbnails unless the user explicitly asks to view a profile image, story, or similar media. ' +
115
+ 'For example, to open a chat/conversation, tap the conversation row (name/text area/contact name) — NOT the contact\'s avatar on the left.',
116
+ inputSchema: {
117
+ type: 'object',
118
+ properties: {
119
+ device_id: { type: 'string', description: 'ADB device serial.' },
120
+ x: { type: 'integer', description: 'X coordinate in device pixels.' },
121
+ y: { type: 'integer', description: 'Y coordinate in device pixels.' },
122
+ },
123
+ required: ['x', 'y'],
124
+ },
125
+ },
126
+ {
127
+ name: 'double_tap',
128
+ description: 'Double-taps the device screen at absolute coordinates (x, y).',
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ device_id: { type: 'string', description: 'ADB device serial.' },
133
+ x: { type: 'integer', description: 'X coordinate.' },
134
+ y: { type: 'integer', description: 'Y coordinate.' },
135
+ },
136
+ required: ['x', 'y'],
137
+ },
138
+ },
139
+ {
140
+ name: 'tap_element',
141
+ description:
142
+ 'Taps the center of a UI element identified by its bounds [x1, y1, x2, y2] from get_ui_elements. ' +
143
+ 'IMPORTANT: Avoid tapping profile pictures, avatars, or person thumbnails unless the user explicitly asks to view a profile image, story, or similar media. ' +
144
+ 'For example, to open a chat/conversation, tap the conversation row (name/text area/contact name) — NOT the contact\'s avatar.',
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: {
148
+ device_id: { type: 'string', description: 'ADB device serial.' },
149
+ bounds: {
150
+ type: 'array',
151
+ items: { type: 'integer' },
152
+ minItems: 4,
153
+ maxItems: 4,
154
+ description: 'Element bounds as [x1, y1, x2, y2].',
155
+ },
156
+ },
157
+ required: ['bounds'],
158
+ },
159
+ },
160
+
161
+ // ── Text input ──────────────────────────────────────────────────────────
162
+ {
163
+ name: 'type_text',
164
+ description:
165
+ 'Types text into the currently focused input field via ADB. ' +
166
+ 'Tap the target field first to focus it, then call this tool.',
167
+ inputSchema: {
168
+ type: 'object',
169
+ properties: {
170
+ device_id: { type: 'string', description: 'ADB device serial.' },
171
+ text: { type: 'string', description: 'Text to type.' },
172
+ },
173
+ required: ['text'],
174
+ },
175
+ },
176
+
177
+ // ── Swipe / scroll ──────────────────────────────────────────────────────
178
+ {
179
+ name: 'swipe',
180
+ description:
181
+ 'Performs a swipe gesture. Use directional shortcuts (up/down/left/right) or explicit coordinates. ' +
182
+ 'Direction finger movements: up = top→bottom, down = bottom→top, left = left→right, right = right→left. ' +
183
+ 'Use cx to set the X column for up/down swipes, cy to set the Y row for left/right swipes.',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: {
187
+ device_id: { type: 'string', description: 'ADB device serial.' },
188
+ direction: {
189
+ type: 'string',
190
+ enum: ['up', 'down', 'left', 'right'],
191
+ description:
192
+ 'Directional swipe: up = finger top→bottom, down = finger bottom→top, ' +
193
+ 'left = finger left→right, right = finger right→left. Mutually exclusive with x1/y1/x2/y2.',
194
+ },
195
+ cx: {
196
+ type: 'integer',
197
+ description: 'X center of the swipe for up/down directional swipes (default: screen horizontal center).',
198
+ },
199
+ cy: {
200
+ type: 'integer',
201
+ description: 'Y center of the swipe for left/right directional swipes (default: screen vertical center).',
202
+ },
203
+ x1: { type: 'integer', description: 'Start X (custom swipe).' },
204
+ y1: { type: 'integer', description: 'Start Y (custom swipe).' },
205
+ x2: { type: 'integer', description: 'End X (custom swipe).' },
206
+ y2: { type: 'integer', description: 'End Y (custom swipe).' },
207
+ duration_ms: {
208
+ type: 'integer',
209
+ description: 'Swipe duration in milliseconds (default 300).',
210
+ default: 300,
211
+ },
212
+ },
213
+ required: [],
214
+ },
215
+ },
216
+
217
+ // ── Key events ──────────────────────────────────────────────────────────
218
+ {
219
+ name: 'press_key',
220
+ description:
221
+ 'Presses a hardware or virtual key on the device. ' +
222
+ 'Use named keys: back, home, recent, enter, search, menu, delete, ' +
223
+ 'power, volume_up, volume_down, zoom_in, zoom_out. ' +
224
+ 'Or pass a numeric Android keycode.',
225
+ inputSchema: {
226
+ type: 'object',
227
+ properties: {
228
+ device_id: { type: 'string', description: 'ADB device serial.' },
229
+ key: {
230
+ type: 'string',
231
+ description: 'Key name (back|home|recent|enter|search|menu|delete|...) or numeric keycode.',
232
+ },
233
+ },
234
+ required: ['key'],
235
+ },
236
+ },
237
+
238
+ // ── App control ─────────────────────────────────────────────────────────
239
+ {
240
+ name: 'open_app',
241
+ description:
242
+ 'Launches an Android app by its package name using the LAUNCHER intent. ' +
243
+ 'Use list_installed_apps to discover package names.',
244
+ inputSchema: {
245
+ type: 'object',
246
+ properties: {
247
+ device_id: { type: 'string', description: 'ADB device serial.' },
248
+ package_name: {
249
+ type: 'string',
250
+ description: 'Android package name (e.g. com.instagram.android).',
251
+ },
252
+ },
253
+ required: ['package_name'],
254
+ },
255
+ },
256
+ {
257
+ name: 'open_url',
258
+ description: 'Opens a URL in the device default browser via Android intent.',
259
+ inputSchema: {
260
+ type: 'object',
261
+ properties: {
262
+ device_id: { type: 'string', description: 'ADB device serial.' },
263
+ url: { type: 'string', description: 'Full URL starting with http:// or https://.' },
264
+ },
265
+ required: ['url'],
266
+ },
267
+ },
268
+ {
269
+ name: 'list_installed_apps',
270
+ description: 'Returns all installed package names on the device.',
271
+ inputSchema: {
272
+ type: 'object',
273
+ properties: {
274
+ device_id: { type: 'string', description: 'ADB device serial.' },
275
+ },
276
+ required: [],
277
+ },
278
+ },
279
+
280
+ // ── Foreground app ───────────────────────────────────────────────────────
281
+ {
282
+ name: 'get_foreground_app',
283
+ description:
284
+ 'Returns the app currently visible to the user (foreground). ' +
285
+ 'Shows the active window focus and focused app from dumpsys window, ' +
286
+ 'including package name and activity. Use this to confirm which app is open before acting.',
287
+ inputSchema: {
288
+ type: 'object',
289
+ properties: {
290
+ device_id: { type: 'string', description: 'ADB device serial (from list_devices).' },
291
+ },
292
+ required: [],
293
+ },
294
+ },
295
+
296
+ // ── Wireless ADB ─────────────────────────────────────────────────────────
297
+ {
298
+ name: 'adb_connect',
299
+ description:
300
+ 'Connects to an Android device over TCP/IP (wireless ADB). ' +
301
+ 'Call enable_wireless_adb + get_device_ip first (while device is on USB), then disconnect USB and call this.',
302
+ inputSchema: {
303
+ type: 'object',
304
+ properties: {
305
+ ip: { type: 'string', description: 'Device IP address (e.g. 192.168.1.42).' },
306
+ port: { type: 'integer', description: 'ADB TCP port (default 5555).', default: 5555 },
307
+ },
308
+ required: ['ip'],
309
+ },
310
+ },
311
+ {
312
+ name: 'adb_disconnect',
313
+ description: 'Disconnects a TCP/IP ADB device. Omit target to disconnect all wireless devices.',
314
+ inputSchema: {
315
+ type: 'object',
316
+ properties: {
317
+ target: { type: 'string', description: 'IP:port to disconnect (e.g. 192.168.1.42:5555). Omit to disconnect all.' },
318
+ },
319
+ required: [],
320
+ },
321
+ },
322
+ {
323
+ name: 'enable_wireless_adb',
324
+ description:
325
+ 'Switches a USB-connected device to TCP/IP mode for wireless ADB. ' +
326
+ 'Device must be on USB first. Follow with get_device_ip, then disconnect USB, then adb_connect.',
327
+ inputSchema: {
328
+ type: 'object',
329
+ properties: {
330
+ device_id: { type: 'string', description: 'ADB device serial of the USB-connected device.' },
331
+ port: { type: 'integer', description: 'TCP port to listen on (default 5555).', default: 5555 },
332
+ },
333
+ required: [],
334
+ },
335
+ },
336
+ {
337
+ name: 'get_device_ip',
338
+ description:
339
+ "Returns the device's current WiFi IP address. " +
340
+ 'Use this after enable_wireless_adb to get the IP needed for adb_connect.',
341
+ inputSchema: {
342
+ type: 'object',
343
+ properties: {
344
+ device_id: { type: 'string', description: 'ADB device serial.' },
345
+ },
346
+ required: [],
347
+ },
348
+ },
349
+
350
+ // ── App control (extended) ───────────────────────────────────────────────
351
+ {
352
+ name: 'force_stop_app',
353
+ description: 'Force-stops an app by package name. Equivalent to Settings → App → Force Stop. Use to reset app state.',
354
+ inputSchema: {
355
+ type: 'object',
356
+ properties: {
357
+ device_id: { type: 'string', description: 'ADB device serial.' },
358
+ package_name: { type: 'string', description: 'Android package name (e.g. com.instagram.android).' },
359
+ },
360
+ required: ['package_name'],
361
+ },
362
+ },
363
+ {
364
+ name: 'is_app_installed',
365
+ description: 'Checks if an app package is installed on the device. Returns installed/not-installed.',
366
+ inputSchema: {
367
+ type: 'object',
368
+ properties: {
369
+ device_id: { type: 'string', description: 'ADB device serial.' },
370
+ package_name: { type: 'string', description: 'Android package name to check.' },
371
+ },
372
+ required: ['package_name'],
373
+ },
374
+ },
375
+
376
+ // ── Screen info ──────────────────────────────────────────────────────────
377
+ {
378
+ name: 'get_screen_size',
379
+ description: 'Returns the physical screen resolution (width x height in pixels) of the device.',
380
+ inputSchema: {
381
+ type: 'object',
382
+ properties: {
383
+ device_id: { type: 'string', description: 'ADB device serial.' },
384
+ },
385
+ required: [],
386
+ },
387
+ },
388
+ {
389
+ name: 'dump_ui_xml',
390
+ description:
391
+ 'Returns the raw UIAutomator XML hierarchy string. ' +
392
+ 'Use get_ui_elements for parsed/structured data, or this for full raw detail.',
393
+ inputSchema: {
394
+ type: 'object',
395
+ properties: {
396
+ device_id: { type: 'string', description: 'ADB device serial.' },
397
+ },
398
+ required: [],
399
+ },
400
+ },
401
+
402
+ // ── Element selector tools ───────────────────────────────────────────────
403
+ {
404
+ name: 'find_element',
405
+ description:
406
+ 'Finds a UI element using a flexible selector object. Returns id, bounds, text, and properties. ' +
407
+ 'Selector priority: resourceId (exact) → text (substring) → contentDesc (substring) → className (exact). ' +
408
+ 'All selector fields are optional but at least one must be provided.',
409
+ inputSchema: {
410
+ type: 'object',
411
+ properties: {
412
+ device_id: { type: 'string', description: 'ADB device serial.' },
413
+ selector: {
414
+ type: 'object',
415
+ description: 'Selector — provide one or more fields.',
416
+ properties: {
417
+ text: { type: 'string', description: 'Substring match against element text.' },
418
+ resourceId: { type: 'string', description: 'Exact match against resource-id (e.g. com.app:id/login_button).' },
419
+ contentDesc: { type: 'string', description: 'Substring match against content-desc.' },
420
+ className: { type: 'string', description: 'Exact match against class (e.g. android.widget.Button).' },
421
+ clickableOnly:{ type: 'boolean', description: 'If true, only match clickable elements.' },
422
+ },
423
+ },
424
+ },
425
+ required: ['selector'],
426
+ },
427
+ },
428
+ {
429
+ name: 'tap_by_selector',
430
+ description:
431
+ 'Finds a UI element by selector then taps its center. ' +
432
+ 'Preferred over raw tap when element text or resource-id is known.',
433
+ inputSchema: {
434
+ type: 'object',
435
+ properties: {
436
+ device_id: { type: 'string', description: 'ADB device serial.' },
437
+ selector: {
438
+ type: 'object',
439
+ properties: {
440
+ text: { type: 'string' },
441
+ resourceId: { type: 'string' },
442
+ contentDesc: { type: 'string' },
443
+ className: { type: 'string' },
444
+ clickableOnly:{ type: 'boolean' },
445
+ },
446
+ },
447
+ },
448
+ required: ['selector'],
449
+ },
450
+ },
451
+ {
452
+ name: 'wait_for_element',
453
+ description:
454
+ 'Polls the UI hierarchy until an element matching the selector appears, or times out. ' +
455
+ 'Use after actions that trigger loading, navigation, or animations.',
456
+ inputSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ device_id: { type: 'string', description: 'ADB device serial.' },
460
+ selector: {
461
+ type: 'object',
462
+ properties: {
463
+ text: { type: 'string' },
464
+ resourceId: { type: 'string' },
465
+ contentDesc: { type: 'string' },
466
+ className: { type: 'string' },
467
+ clickableOnly:{ type: 'boolean' },
468
+ },
469
+ },
470
+ timeout_seconds: { type: 'number', description: 'Max wait time in seconds (default 10, max 60).', default: 10 },
471
+ },
472
+ required: ['selector'],
473
+ },
474
+ },
475
+ {
476
+ name: 'type_in_element',
477
+ description:
478
+ 'Finds an input element by selector, taps it to focus, clears existing text, then types new text. ' +
479
+ 'The complete "fill a field" action.',
480
+ inputSchema: {
481
+ type: 'object',
482
+ properties: {
483
+ device_id: { type: 'string', description: 'ADB device serial.' },
484
+ selector: {
485
+ type: 'object',
486
+ description: 'Selector for the input field.',
487
+ properties: {
488
+ text: { type: 'string' },
489
+ resourceId: { type: 'string' },
490
+ contentDesc: { type: 'string' },
491
+ className: { type: 'string' },
492
+ },
493
+ },
494
+ text: { type: 'string', description: 'Text to type into the field.' },
495
+ },
496
+ required: ['selector', 'text'],
497
+ },
498
+ },
499
+ {
500
+ name: 'assert_element_exists',
501
+ description: 'Verifies a UI element matching the selector exists on screen. Returns PASS/FAIL with element details.',
502
+ inputSchema: {
503
+ type: 'object',
504
+ properties: {
505
+ device_id: { type: 'string', description: 'ADB device serial.' },
506
+ selector: {
507
+ type: 'object',
508
+ properties: {
509
+ text: { type: 'string' },
510
+ resourceId: { type: 'string' },
511
+ contentDesc: { type: 'string' },
512
+ className: { type: 'string' },
513
+ clickableOnly:{ type: 'boolean' },
514
+ },
515
+ },
516
+ },
517
+ required: ['selector'],
518
+ },
519
+ },
520
+
521
+ // ── Navigation shortcuts ─────────────────────────────────────────────────
522
+ {
523
+ name: 'go_home',
524
+ description: 'Presses the Home button, returning to the Android home screen.',
525
+ inputSchema: {
526
+ type: 'object',
527
+ properties: { device_id: { type: 'string', description: 'ADB device serial.' } },
528
+ required: [],
529
+ },
530
+ },
531
+ {
532
+ name: 'go_back',
533
+ description: 'Presses the Back button to navigate to the previous screen.',
534
+ inputSchema: {
535
+ type: 'object',
536
+ properties: { device_id: { type: 'string', description: 'ADB device serial.' } },
537
+ required: [],
538
+ },
539
+ },
540
+ {
541
+ name: 'open_recents',
542
+ description: 'Opens the recent apps / app switcher screen.',
543
+ inputSchema: {
544
+ type: 'object',
545
+ properties: { device_id: { type: 'string', description: 'ADB device serial.' } },
546
+ required: [],
547
+ },
548
+ },
549
+
550
+ // ── State validation ─────────────────────────────────────────────────────
551
+ {
552
+ name: 'assert_foreground_app',
553
+ description:
554
+ 'Checks that a specific app package is currently in the foreground. ' +
555
+ 'Returns PASS/FAIL with current mCurrentFocus and mFocusedApp.',
556
+ inputSchema: {
557
+ type: 'object',
558
+ properties: {
559
+ device_id: { type: 'string', description: 'ADB device serial.' },
560
+ package_name: { type: 'string', description: 'Expected foreground package name.' },
561
+ },
562
+ required: ['package_name'],
563
+ },
564
+ },
565
+
566
+ // ── Device info ─────────────────────────────────────────────────────────
567
+ {
568
+ name: 'get_device_info',
569
+ description:
570
+ 'Returns comprehensive device hardware and system information retrieved via ADB. ' +
571
+ 'Includes: model, brand, manufacturer, device codename, CPU ABI, board platform, serial number; ' +
572
+ 'Android version, SDK level, build ID/type/fingerprint; ' +
573
+ 'screen resolution and density; ' +
574
+ 'battery level, status, health, plug state, voltage (mV), temperature (°C); ' +
575
+ 'RAM totals (total/free/available/cached in bytes); ' +
576
+ 'storage partitions (/data, /sdcard) with size/used/available; ' +
577
+ 'and all active network interfaces with IP addresses.',
578
+ inputSchema: {
579
+ type: 'object',
580
+ properties: {
581
+ device_id: { type: 'string', description: 'ADB device serial (from list_devices).' },
582
+ },
583
+ required: [],
584
+ },
585
+ },
586
+
587
+ // ── Rotation ────────────────────────────────────────────────────────────
588
+ {
589
+ name: 'rotate_screen',
590
+ description:
591
+ 'Sets device rotation. 0=portrait, 1=landscape, 2=reverse portrait, 3=reverse landscape.',
592
+ inputSchema: {
593
+ type: 'object',
594
+ properties: {
595
+ device_id: { type: 'string', description: 'ADB device serial.' },
596
+ rotation: {
597
+ type: 'integer',
598
+ enum: [0, 1, 2, 3],
599
+ description: '0=portrait, 1=landscape, 2=reverse portrait, 3=reverse landscape.',
600
+ },
601
+ },
602
+ required: ['rotation'],
603
+ },
604
+ },
605
+
606
+ // ── Timing ──────────────────────────────────────────────────────────────
607
+ {
608
+ name: 'delay',
609
+ description:
610
+ 'Waits (sleeps) for a specified number of milliseconds before continuing. ' +
611
+ 'Use this to pause between actions when the app needs time to load, animate, or settle. ' +
612
+ 'Maximum 10 000 ms (10 seconds).',
613
+ inputSchema: {
614
+ type: 'object',
615
+ properties: {
616
+ ms: {
617
+ type: 'integer',
618
+ description: 'Duration to wait in milliseconds (1–10 000).',
619
+ minimum: 1,
620
+ maximum: 10000,
621
+ },
622
+ },
623
+ required: ['ms'],
624
+ },
625
+ },
626
+
627
+ {
628
+ name: 'post_notification',
629
+ description: 'Posts a system notification on the device via `cmd notification post`.',
630
+ inputSchema: {
631
+ type: 'object',
632
+ properties: {
633
+ device_id: { type: 'string', description: 'ADB device serial.' },
634
+ title: { type: 'string', description: 'Notification title.' },
635
+ text: { type: 'string', description: 'Notification body text.' },
636
+ tag: { type: 'string', description: 'Notification tag (default: aiphone).', default: 'aiphone' },
637
+ style: {
638
+ type: 'string',
639
+ enum: ['bigtext', 'inbox', 'media'],
640
+ description: 'Notification style (default: bigtext).',
641
+ default: 'bigtext',
642
+ },
643
+ },
644
+ required: ['title', 'text'],
645
+ },
646
+ },
647
+
648
+ {
649
+ name: 'dump_notifications',
650
+ description: 'Returns the raw output of `dumpsys notification` — active notifications, history, and listener state.',
651
+ inputSchema: {
652
+ type: 'object',
653
+ properties: {
654
+ device_id: { type: 'string', description: 'ADB device serial.' },
655
+ },
656
+ required: [],
657
+ },
658
+ },
659
+
660
+ {
661
+ name: 'set_wifi',
662
+ description: 'Enables or disables WiFi on the device via `svc wifi`.',
663
+ inputSchema: {
664
+ type: 'object',
665
+ properties: {
666
+ device_id: { type: 'string', description: 'ADB device serial.' },
667
+ enable: { type: 'boolean', description: 'true to enable, false to disable.' },
668
+ },
669
+ required: ['enable'],
670
+ },
671
+ },
672
+
673
+ {
674
+ name: 'set_mobile_data',
675
+ description: 'Enables or disables mobile data on the device via `svc data`.',
676
+ inputSchema: {
677
+ type: 'object',
678
+ properties: {
679
+ device_id: { type: 'string', description: 'ADB device serial.' },
680
+ enable: { type: 'boolean', description: 'true to enable, false to disable.' },
681
+ },
682
+ required: ['enable'],
683
+ },
684
+ },
685
+
686
+ {
687
+ name: 'set_airplane_mode',
688
+ description: 'Enables or disables airplane mode. Note: on Android 8+ this may require the device to be rooted or have special permissions.',
689
+ inputSchema: {
690
+ type: 'object',
691
+ properties: {
692
+ device_id: { type: 'string', description: 'ADB device serial.' },
693
+ enable: { type: 'boolean', description: 'true to enable, false to disable.' },
694
+ },
695
+ required: ['enable'],
696
+ },
697
+ },
698
+
699
+ {
700
+ name: 'adb_shell',
701
+ description:
702
+ 'Runs an arbitrary `adb shell` command on the device. ' +
703
+ 'Use this as a last resort when no other tool covers the needed action.',
704
+ inputSchema: {
705
+ type: 'object',
706
+ properties: {
707
+ device_id: { type: 'string', description: 'ADB device serial.' },
708
+ command: { type: 'string', description: 'Shell command to run (e.g. "pm clear com.example.app").' },
709
+ },
710
+ required: ['command'],
711
+ },
712
+ },
713
+ ],
714
+ }));
715
+
716
+ // ── Tool handlers ─────────────────────────────────────────────────────────────
717
+
718
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
719
+ const { name, arguments: args = {} } = request.params;
720
+
721
+ try {
722
+ switch (name) {
723
+
724
+ // ── list_devices ──────────────────────────────────────────────────────
725
+ case 'list_devices': {
726
+ const serials = await adbClient.listDevices(ADB_PATH);
727
+ if (serials.length === 0) {
728
+ return text('No Android devices connected. Make sure USB debugging is enabled and the device is authorized.');
729
+ }
730
+ return text(`Connected devices (${serials.length}):\n${serials.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}`);
731
+ }
732
+
733
+ // ── take_screenshot ───────────────────────────────────────────────────
734
+ case 'take_screenshot': {
735
+ const device_id = args?.device_id ?? null;
736
+ const rawPng = await adbClient.screenshot(ADB_PATH, device_id);
737
+ const result = await processScreenshot(rawPng, {
738
+ maxWidth: args.max_width ?? 1080,
739
+ maxHeight: args.max_height ?? 1920,
740
+ format: args.format ?? 'webp',
741
+ quality: args.quality ?? 75,
742
+ });
743
+ const b64 = result.buffer.toString('base64');
744
+ const saved = Math.round((1 - result.buffer.length / result.originalBytes) * 100);
745
+ return {
746
+ content: [
747
+ {
748
+ type: 'text',
749
+ text:
750
+ `Screenshot from device ${device_id}: ` +
751
+ `${result.width}x${result.height}px, ` +
752
+ `${result.format}, ` +
753
+ `${(result.buffer.length / 1024).toFixed(1)} KB ` +
754
+ `(${saved}% smaller than raw PNG of ${(result.originalBytes / 1024).toFixed(1)} KB).`,
755
+ },
756
+ { type: 'image', data: b64, mimeType: mimeType(result.format) },
757
+ ],
758
+ };
759
+ }
760
+
761
+ // ── get_ui_elements ───────────────────────────────────────────────────
762
+ case 'get_ui_elements': {
763
+ const device_id = args?.device_id ?? null;
764
+ const limit = Math.min(150, Math.max(1, args.limit ?? 30));
765
+ const xml = await adbClient.uiDump(ADB_PATH, device_id);
766
+ const elements = parseUiXml(xml);
767
+ const compact = compactElements(elements, limit);
768
+ const summary =
769
+ `UI elements on device ${device_id} (showing ${compact.length} of ${elements.length} total, prioritised by clickable):\n` +
770
+ JSON.stringify(compact, null, 2);
771
+ return text(summary);
772
+ }
773
+
774
+ // ── tap ───────────────────────────────────────────────────────────────
775
+ case 'tap': {
776
+ const { x, y } = requireArgs(args, ['x', 'y']);
777
+ const device_id = args?.device_id ?? null;
778
+ await adbClient.tapPoint(ADB_PATH, device_id, Number(x), Number(y));
779
+ return text(`Tapped at (${x}, ${y}) on device ${device_id}.`);
780
+ }
781
+
782
+ // ── double_tap ────────────────────────────────────────────────────────
783
+ case 'double_tap': {
784
+ const { x, y } = requireArgs(args, ['x', 'y']);
785
+ const device_id = args?.device_id ?? null;
786
+ await adbClient.doubleTapPoint(ADB_PATH, device_id, Number(x), Number(y));
787
+ return text(`Double-tapped at (${x}, ${y}) on device ${device_id}.`);
788
+ }
789
+
790
+ // ── tap_element ───────────────────────────────────────────────────────
791
+ case 'tap_element': {
792
+ const { bounds } = requireArgs(args, ['bounds']);
793
+ const device_id = args?.device_id ?? null;
794
+ if (!Array.isArray(bounds) || bounds.length !== 4) {
795
+ throw new Error('bounds must be an array of 4 integers [x1, y1, x2, y2].');
796
+ }
797
+ await adbClient.tapBounds(ADB_PATH, device_id, bounds.map(Number));
798
+ const cx = Math.floor((bounds[0] + bounds[2]) / 2);
799
+ const cy = Math.floor((bounds[1] + bounds[3]) / 2);
800
+ return text(`Tapped element center at (${cx}, ${cy}) on device ${device_id}.`);
801
+ }
802
+
803
+ // ── type_text ─────────────────────────────────────────────────────────
804
+ case 'type_text': {
805
+ const { text: inputText } = requireArgs(args, ['text']);
806
+ const device_id = args?.device_id ?? null;
807
+ if (typeof inputText !== 'string' || inputText.length === 0) {
808
+ throw new Error('"text" must be a non-empty string.');
809
+ }
810
+ await adbClient.typeText(ADB_PATH, device_id, inputText);
811
+ return text(`Typed ${inputText.length} character(s) on device ${device_id}.`);
812
+ }
813
+
814
+ // ── swipe ─────────────────────────────────────────────────────────────
815
+ case 'swipe': {
816
+ const device_id = args?.device_id ?? null;
817
+ const durationMs = args.duration_ms ?? 300;
818
+ if (args.direction) {
819
+ const swipeCx = args.cx != null ? Number(args.cx) : null;
820
+ const swipeCy = args.cy != null ? Number(args.cy) : null;
821
+ await adbClient.swipeDirection(ADB_PATH, device_id, args.direction, 1080, 1920, swipeCx, swipeCy);
822
+ const centerNote = swipeCx != null || swipeCy != null
823
+ ? ` (center: ${swipeCx ?? 'default'}, ${swipeCy ?? 'default'})`
824
+ : '';
825
+ return text(`Swiped ${args.direction}${centerNote} on device ${device_id}.`);
826
+ }
827
+ const { x1, y1, x2, y2 } = requireArgs(args, ['x1', 'y1', 'x2', 'y2']);
828
+ await adbClient.swipe(ADB_PATH, device_id, Number(x1), Number(y1), Number(x2), Number(y2), Number(durationMs));
829
+ return text(`Swiped (${x1},${y1}) → (${x2},${y2}) in ${durationMs}ms on device ${device_id}.`);
830
+ }
831
+
832
+ // ── press_key ─────────────────────────────────────────────────────────
833
+ case 'press_key': {
834
+ const { key } = requireArgs(args, ['key']);
835
+ const device_id = args?.device_id ?? null;
836
+ await adbClient.pressKey(ADB_PATH, device_id, String(key));
837
+ return text(`Pressed key "${key}" on device ${device_id}.`);
838
+ }
839
+
840
+ // ── open_app ──────────────────────────────────────────────────────────
841
+ case 'open_app': {
842
+ const { package_name } = requireArgs(args, ['package_name']);
843
+ const device_id = args?.device_id ?? null;
844
+ await adbClient.launchApp(ADB_PATH, device_id, package_name);
845
+ return text(`Launched app "${package_name}" on device ${device_id}.`);
846
+ }
847
+
848
+ // ── open_url ──────────────────────────────────────────────────────────
849
+ case 'open_url': {
850
+ const { url } = requireArgs(args, ['url']);
851
+ const device_id = args?.device_id ?? null;
852
+ await adbClient.openUrl(ADB_PATH, device_id, url);
853
+ return text(`Opened URL "${url}" on device ${device_id}.`);
854
+ }
855
+
856
+ // ── list_installed_apps ───────────────────────────────────────────────
857
+ case 'list_installed_apps': {
858
+ const device_id = args?.device_id ?? null;
859
+ const packages = await adbClient.listInstalledPackages(ADB_PATH, device_id);
860
+ return text(
861
+ `Installed packages on ${device_id} (${packages.length} total):\n${packages.sort().join('\n')}`,
862
+ );
863
+ }
864
+
865
+ // ── get_foreground_app ────────────────────────────────────────────────
866
+ case 'get_foreground_app': {
867
+ const device_id = args?.device_id ?? null;
868
+ const { currentFocus, focusedApp } = await adbClient.getForegroundApp(ADB_PATH, device_id);
869
+ const lines = [
870
+ `Foreground app on device ${device_id}:`,
871
+ ` mCurrentFocus : ${currentFocus ?? '(not found)'}`,
872
+ ` mFocusedApp : ${focusedApp ?? '(not found)'}`,
873
+ ];
874
+ return text(lines.join('\n'));
875
+ }
876
+
877
+ // ── rotate_screen ─────────────────────────────────────────────────────
878
+ case 'rotate_screen': {
879
+ const { rotation } = requireArgs(args, ['rotation']);
880
+ const device_id = args?.device_id ?? null;
881
+ await adbClient.rotate(ADB_PATH, device_id, Number(rotation));
882
+ const labels = ['portrait', 'landscape', 'reverse portrait', 'reverse landscape'];
883
+ return text(`Rotated device ${device_id} to ${labels[rotation] ?? rotation}.`);
884
+ }
885
+
886
+ // ── adb_connect ───────────────────────────────────────────────────────
887
+ case 'adb_connect': {
888
+ const { ip } = requireArgs(args, ['ip']);
889
+ const result = await adbClient.adbConnect(ADB_PATH, String(ip), Number(args.port ?? 5555));
890
+ return text(`ADB connect result: ${result}`);
891
+ }
892
+
893
+ // ── adb_disconnect ────────────────────────────────────────────────────
894
+ case 'adb_disconnect': {
895
+ const result = await adbClient.adbDisconnect(ADB_PATH, args.target);
896
+ return text(`ADB disconnect result: ${result}`);
897
+ }
898
+
899
+ // ── enable_wireless_adb ───────────────────────────────────────────────
900
+ case 'enable_wireless_adb': {
901
+ const device_id = args?.device_id ?? null;
902
+ await adbClient.adbTcpip(ADB_PATH, device_id, Number(args.port ?? 5555));
903
+ return text(
904
+ `TCP/IP mode enabled on device ${device_id} (port ${args.port ?? 5555}). ` +
905
+ `Now call get_device_ip, then disconnect USB and call adb_connect.`
906
+ );
907
+ }
908
+
909
+ // ── get_device_ip ─────────────────────────────────────────────────────
910
+ case 'get_device_ip': {
911
+ const device_id = args?.device_id ?? null;
912
+ const routes = await adbClient.getDeviceIp(ADB_PATH, device_id);
913
+ if (!routes || routes.length === 0) {
914
+ return text(`No network interfaces found on device ${device_id}. Ensure the device has an active network connection.`);
915
+ }
916
+ const lines = [
917
+ `Network interfaces on device ${device_id} (${routes.length} route${routes.length > 1 ? 's' : ''}):`,
918
+ ...routes.map((r) => ` ${r.iface.padEnd(12)} ${r.ip.padEnd(18)} network: ${r.network}`),
919
+ ];
920
+ return text(lines.join('\n'));
921
+ }
922
+
923
+ // ── force_stop_app ────────────────────────────────────────────────────
924
+ case 'force_stop_app': {
925
+ const { package_name } = requireArgs(args, ['package_name']);
926
+ const device_id = args?.device_id ?? null;
927
+ await adbClient.forceStopApp(ADB_PATH, device_id, package_name);
928
+ return text(`Force-stopped "${package_name}" on device ${device_id}.`);
929
+ }
930
+
931
+ // ── is_app_installed ──────────────────────────────────────────────────
932
+ case 'is_app_installed': {
933
+ const { package_name } = requireArgs(args, ['package_name']);
934
+ const device_id = args?.device_id ?? null;
935
+ const installed = await adbClient.isAppInstalled(ADB_PATH, device_id, package_name);
936
+ return text(`"${package_name}" is ${installed ? 'INSTALLED' : 'NOT installed'} on device ${device_id}.`);
937
+ }
938
+
939
+ // ── get_screen_size ───────────────────────────────────────────────────
940
+ case 'get_screen_size': {
941
+ const device_id = args?.device_id ?? null;
942
+ const { width, height } = await adbClient.getScreenSize(ADB_PATH, device_id);
943
+ return text(`Screen size of device ${device_id}: ${width} x ${height} pixels.`);
944
+ }
945
+
946
+ // ── dump_ui_xml ───────────────────────────────────────────────────────
947
+ case 'dump_ui_xml': {
948
+ const device_id = args?.device_id ?? null;
949
+ const xml = await adbClient.getRawUiXml(ADB_PATH, device_id);
950
+ return text(xml);
951
+ }
952
+
953
+ // ── find_element ──────────────────────────────────────────────────────
954
+ case 'find_element': {
955
+ const { selector } = requireArgs(args, ['selector']);
956
+ const device_id = args?.device_id ?? null;
957
+ const el = findElement(parseUiXml(await adbClient.uiDump(ADB_PATH, device_id)), selector);
958
+ if (!el) return text(`No element found matching selector: ${JSON.stringify(selector)}`);
959
+ return text(`Element found:\n${JSON.stringify(el, null, 2)}`);
960
+ }
961
+
962
+ // ── tap_by_selector ───────────────────────────────────────────────────
963
+ case 'tap_by_selector': {
964
+ const { selector } = requireArgs(args, ['selector']);
965
+ const device_id = args?.device_id ?? null;
966
+ const el = findElement(parseUiXml(await adbClient.uiDump(ADB_PATH, device_id)), selector);
967
+ if (!el) throw new Error(`No element found matching selector: ${JSON.stringify(selector)}`);
968
+ await adbClient.tapBounds(ADB_PATH, device_id, el.bounds);
969
+ const cx = Math.floor((el.bounds[0] + el.bounds[2]) / 2);
970
+ const cy = Math.floor((el.bounds[1] + el.bounds[3]) / 2);
971
+ return text(`Tapped "${el.text || el.contentDesc || el.id}" at (${cx}, ${cy}) on device ${device_id}.`);
972
+ }
973
+
974
+ // ── wait_for_element ──────────────────────────────────────────────────
975
+ case 'wait_for_element': {
976
+ const { selector } = requireArgs(args, ['selector']);
977
+ const device_id = args?.device_id ?? null;
978
+ const timeoutSec = Math.min(60, Math.max(1, Number(args.timeout_seconds ?? 10)));
979
+ const intervalMs = 1500;
980
+ const deadline = Date.now() + timeoutSec * 1000;
981
+ let el = null;
982
+ while (Date.now() < deadline) {
983
+ el = findElement(parseUiXml(await adbClient.uiDump(ADB_PATH, device_id)), selector);
984
+ if (el) break;
985
+ const remaining = deadline - Date.now();
986
+ if (remaining <= 0) break;
987
+ await new Promise((r) => setTimeout(r, Math.min(intervalMs, remaining)));
988
+ }
989
+ if (!el) return text(`Element not found within ${timeoutSec}s. Selector: ${JSON.stringify(selector)}`);
990
+ return text(`Element appeared:\n${JSON.stringify(el, null, 2)}`);
991
+ }
992
+
993
+ // ── type_in_element ───────────────────────────────────────────────────
994
+ case 'type_in_element': {
995
+ const { selector, text: inputText } = requireArgs(args, ['selector', 'text']);
996
+ const device_id = args?.device_id ?? null;
997
+ const el = findElement(parseUiXml(await adbClient.uiDump(ADB_PATH, device_id)), selector);
998
+ if (!el) throw new Error(`No element found matching selector: ${JSON.stringify(selector)}`);
999
+ await adbClient.tapBounds(ADB_PATH, device_id, el.bounds);
1000
+ await new Promise((r) => setTimeout(r, 300));
1001
+ await adbClient.clearInputAndType(ADB_PATH, device_id, inputText);
1002
+ return text(`Typed into "${el.text || el.contentDesc || el.id}" on device ${device_id}.`);
1003
+ }
1004
+
1005
+ // ── assert_element_exists ─────────────────────────────────────────────
1006
+ case 'assert_element_exists': {
1007
+ const { selector } = requireArgs(args, ['selector']);
1008
+ const device_id = args?.device_id ?? null;
1009
+ const el = findElement(parseUiXml(await adbClient.uiDump(ADB_PATH, device_id)), selector);
1010
+ if (!el) return text(`FAIL: No element found matching selector: ${JSON.stringify(selector)}`);
1011
+ return text(`PASS: Element exists.\n${JSON.stringify(el, null, 2)}`);
1012
+ }
1013
+
1014
+ // ── go_home ───────────────────────────────────────────────────────────
1015
+ case 'go_home': {
1016
+ const device_id = args?.device_id ?? null;
1017
+ await adbClient.pressKey(ADB_PATH, device_id, 'home');
1018
+ return text(`Pressed Home on device ${device_id}.`);
1019
+ }
1020
+
1021
+ // ── go_back ───────────────────────────────────────────────────────────
1022
+ case 'go_back': {
1023
+ const device_id = args?.device_id ?? null;
1024
+ await adbClient.pressKey(ADB_PATH, device_id, 'back');
1025
+ return text(`Pressed Back on device ${device_id}.`);
1026
+ }
1027
+
1028
+ // ── open_recents ──────────────────────────────────────────────────────
1029
+ case 'open_recents': {
1030
+ const device_id = args?.device_id ?? null;
1031
+ await adbClient.pressKey(ADB_PATH, device_id, 'recent');
1032
+ return text(`Opened recent apps on device ${device_id}.`);
1033
+ }
1034
+
1035
+ // ── get_device_info ────────────────────────────────────────────────────
1036
+ case 'get_device_info': {
1037
+ const device_id = args?.device_id ?? null;
1038
+ const info = await adbClient.getDeviceInfo(ADB_PATH, device_id);
1039
+ // Format human-readable alongside raw JSON
1040
+ const fmt = (bytes) => bytes == null ? 'N/A' : `${(bytes / 1024 / 1024).toFixed(0)} MB`;
1041
+ const lines = [
1042
+ `Device: ${info.device.manufacturer} ${info.device.model} (${info.device.device})`,
1043
+ `Brand: ${info.device.brand} | CPU ABI: ${info.device.cpu_abi} | Platform: ${info.device.board_platform}`,
1044
+ `Serial: ${info.device.serial}`,
1045
+ ``,
1046
+ `Android: ${info.software.android_version} (SDK ${info.software.sdk_level}) — Build: ${info.software.build_id} [${info.software.build_type}]`,
1047
+ ``,
1048
+ `Screen: ${info.screen.width}×${info.screen.height} @ ${info.screen.density_dpi} dpi`,
1049
+ ``,
1050
+ `Battery: ${info.battery.level}% | ${info.battery.status} | Health: ${info.battery.health} | Plugged: ${info.battery.plugged}`,
1051
+ ` Voltage: ${info.battery.voltage_mv} mV | Temp: ${info.battery.temperature_c} °C | Tech: ${info.battery.technology}`,
1052
+ ``,
1053
+ `Memory: Total ${fmt(info.memory.total_bytes)} | Free ${fmt(info.memory.free_bytes)} | Available ${fmt(info.memory.available_bytes)} | Cached ${fmt(info.memory.cached_bytes)}`,
1054
+ ``,
1055
+ `Storage:`,
1056
+ ...info.storage.map((s) => ` ${s.mount}: ${fmt(s.avail_bytes)} free / ${fmt(s.size_bytes)} total (${s.filesystem})`),
1057
+ ``,
1058
+ `Network:`,
1059
+ ...info.network_interfaces.map((n) => ` ${n.iface}: ${n.ip} (${n.network})`),
1060
+ ``,
1061
+ `--- Raw JSON ---`,
1062
+ JSON.stringify(info, null, 2),
1063
+ ];
1064
+ return text(lines.join('\n'));
1065
+ }
1066
+
1067
+ // ── assert_foreground_app ─────────────────────────────────────────────
1068
+ case 'assert_foreground_app': {
1069
+ const { package_name } = requireArgs(args, ['package_name']);
1070
+ const device_id = args?.device_id ?? null;
1071
+ const { currentFocus, focusedApp } = await adbClient.getForegroundApp(ADB_PATH, device_id);
1072
+ const combined = `${currentFocus ?? ''} ${focusedApp ?? ''}`;
1073
+ const pass = combined.includes(package_name);
1074
+ return text(
1075
+ `${pass ? 'PASS' : 'FAIL'}: Expected "${package_name}" in foreground.\n` +
1076
+ ` mCurrentFocus : ${currentFocus ?? '(not found)'}\n` +
1077
+ ` mFocusedApp : ${focusedApp ?? '(not found)'}`,
1078
+ );
1079
+ }
1080
+
1081
+ // ── delay ─────────────────────────────────────────────────────────────
1082
+ case 'delay': {
1083
+ const { ms } = requireArgs(args, ['ms']);
1084
+ const duration = Math.min(10000, Math.max(1, Number(ms)));
1085
+ await new Promise((r) => setTimeout(r, duration));
1086
+ return text(`Waited ${duration} ms.`);
1087
+ }
1088
+
1089
+ case 'post_notification': {
1090
+ const { title, text: body } = requireArgs(args, ['title', 'text']);
1091
+ const device_id = args?.device_id ?? null;
1092
+ await adbClient.postNotification(ADB_PATH, device_id, {
1093
+ title,
1094
+ text: body,
1095
+ tag: args.tag ?? 'aiphone',
1096
+ style: args.style ?? 'bigtext',
1097
+ });
1098
+ return text(`Notification posted on device ${device_id}: "${title}" — ${body}`);
1099
+ }
1100
+
1101
+ case 'dump_notifications': {
1102
+ const device_id = args?.device_id ?? null;
1103
+ const output = await adbClient.dumpNotifications(ADB_PATH, device_id);
1104
+ return text(output);
1105
+ }
1106
+
1107
+ case 'set_wifi': {
1108
+ const { enable } = requireArgs(args, ['enable']);
1109
+ const device_id = args?.device_id ?? null;
1110
+ await adbClient.setWifi(ADB_PATH, device_id, Boolean(enable));
1111
+ return text(`WiFi ${enable ? 'enabled' : 'disabled'} on device ${device_id}.`);
1112
+ }
1113
+
1114
+ case 'set_mobile_data': {
1115
+ const { enable } = requireArgs(args, ['enable']);
1116
+ const device_id = args?.device_id ?? null;
1117
+ await adbClient.setMobileData(ADB_PATH, device_id, Boolean(enable));
1118
+ return text(`Mobile data ${enable ? 'enabled' : 'disabled'} on device ${device_id}.`);
1119
+ }
1120
+
1121
+ case 'set_airplane_mode': {
1122
+ const { enable } = requireArgs(args, ['enable']);
1123
+ const device_id = args?.device_id ?? null;
1124
+ await adbClient.setAirplaneMode(ADB_PATH, device_id, Boolean(enable));
1125
+ return text(`Airplane mode ${enable ? 'enabled' : 'disabled'} on device ${device_id}.`);
1126
+ }
1127
+
1128
+ case 'adb_shell': {
1129
+ const { command } = requireArgs(args, ['command']);
1130
+ const device_id = args?.device_id ?? null;
1131
+ const output = await adbClient.adbShell(ADB_PATH, device_id, command);
1132
+ return text(output || '(no output)');
1133
+ }
1134
+
1135
+ default:
1136
+ return text(`Unknown tool: "${name}"`);
1137
+ }
1138
+ } catch (err) {
1139
+ return {
1140
+ content: [{ type: 'text', text: `Error: ${err.message}` }],
1141
+ isError: true,
1142
+ };
1143
+ }
1144
+ });
1145
+
1146
+ // ── Helpers ───────────────────────────────────────────────────────────────────
1147
+
1148
+ function text(str) {
1149
+ return { content: [{ type: 'text', text: str }] };
1150
+ }
1151
+
1152
+ function requireArgs(args, keys) {
1153
+ const missing = keys.filter((k) => args[k] === undefined || args[k] === null);
1154
+ if (missing.length > 0) {
1155
+ throw new Error(`Missing required argument(s): ${missing.join(', ')}`);
1156
+ }
1157
+ return args;
1158
+ }
1159
+
1160
+ // ── Connect transport ─────────────────────────────────────────────────────────
1161
+
1162
+ const transport = new StdioServerTransport();
1163
+ await server.connect(transport);