@sensaiorg/adapter-android 0.1.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.
Files changed (89) hide show
  1. package/dist/android-adapter.d.ts.map +1 -0
  2. package/dist/android-adapter.js +89 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +12 -0
  5. package/dist/tools/accessibility.d.ts.map +1 -0
  6. package/dist/tools/accessibility.js +85 -0
  7. package/dist/tools/adb.d.ts.map +1 -0
  8. package/dist/tools/adb.js +66 -0
  9. package/dist/tools/app-state.d.ts.map +1 -0
  10. package/dist/tools/app-state.js +173 -0
  11. package/dist/tools/diagnose.d.ts.map +1 -0
  12. package/dist/tools/diagnose.js +128 -0
  13. package/dist/tools/hot-reload.d.ts.map +1 -0
  14. package/dist/tools/hot-reload.js +97 -0
  15. package/dist/tools/index.d.ts.map +1 -0
  16. package/dist/tools/index.js +66 -0
  17. package/dist/tools/interaction.d.ts.map +1 -0
  18. package/dist/tools/interaction.js +395 -0
  19. package/dist/tools/logcat.d.ts.map +1 -0
  20. package/dist/tools/logcat.js +216 -0
  21. package/dist/tools/network.d.ts.map +1 -0
  22. package/dist/tools/network.js +123 -0
  23. package/dist/tools/performance.d.ts.map +1 -0
  24. package/dist/tools/performance.js +143 -0
  25. package/dist/tools/recording.d.ts.map +1 -0
  26. package/dist/tools/recording.js +102 -0
  27. package/dist/tools/rn-tools.d.ts.map +1 -0
  28. package/dist/tools/rn-tools.js +120 -0
  29. package/dist/tools/smart-actions.d.ts.map +1 -0
  30. package/dist/tools/smart-actions.js +506 -0
  31. package/dist/tools/ui-tree.d.ts.map +1 -0
  32. package/dist/tools/ui-tree.js +226 -0
  33. package/dist/transport/adb-client.d.ts.map +1 -0
  34. package/dist/transport/adb-client.js +124 -0
  35. package/dist/transport/adb-client.test.d.ts.map +1 -0
  36. package/dist/transport/adb-client.test.js +153 -0
  37. package/dist/transport/agent-client.d.ts.map +1 -0
  38. package/dist/transport/agent-client.js +157 -0
  39. package/dist/transport/agent-client.test.d.ts.map +1 -0
  40. package/dist/transport/agent-client.test.js +199 -0
  41. package/dist/transport/connection-manager.d.ts.map +1 -0
  42. package/dist/transport/connection-manager.js +119 -0
  43. package/dist/util/logcat-parser.d.ts.map +1 -0
  44. package/dist/util/logcat-parser.js +79 -0
  45. package/dist/util/safety.d.ts.map +1 -0
  46. package/dist/util/safety.js +132 -0
  47. package/dist/util/safety.test.d.ts.map +1 -0
  48. package/dist/util/safety.test.js +205 -0
  49. package/dist/util/text-extractor.d.ts.map +1 -0
  50. package/dist/util/text-extractor.js +71 -0
  51. package/dist/util/ui-tree-cache.d.ts.map +1 -0
  52. package/dist/util/ui-tree-cache.js +46 -0
  53. package/dist/util/ui-tree-cache.test.d.ts.map +1 -0
  54. package/dist/util/ui-tree-cache.test.js +84 -0
  55. package/dist/util/ui-tree-parser.d.ts.map +1 -0
  56. package/dist/util/ui-tree-parser.js +123 -0
  57. package/dist/util/ui-tree-parser.test.d.ts.map +1 -0
  58. package/dist/util/ui-tree-parser.test.js +167 -0
  59. package/package.json +22 -0
  60. package/src/android-adapter.ts +124 -0
  61. package/src/index.ts +8 -0
  62. package/src/tools/accessibility.ts +94 -0
  63. package/src/tools/adb.ts +75 -0
  64. package/src/tools/app-state.ts +193 -0
  65. package/src/tools/diagnose.ts +146 -0
  66. package/src/tools/hot-reload.ts +103 -0
  67. package/src/tools/index.ts +66 -0
  68. package/src/tools/interaction.ts +448 -0
  69. package/src/tools/logcat.ts +252 -0
  70. package/src/tools/network.ts +145 -0
  71. package/src/tools/performance.ts +169 -0
  72. package/src/tools/recording.ts +123 -0
  73. package/src/tools/rn-tools.ts +143 -0
  74. package/src/tools/smart-actions.ts +593 -0
  75. package/src/tools/ui-tree.ts +258 -0
  76. package/src/transport/adb-client.test.ts +228 -0
  77. package/src/transport/adb-client.ts +139 -0
  78. package/src/transport/agent-client.test.ts +267 -0
  79. package/src/transport/agent-client.ts +188 -0
  80. package/src/transport/connection-manager.ts +140 -0
  81. package/src/util/logcat-parser.ts +94 -0
  82. package/src/util/safety.test.ts +251 -0
  83. package/src/util/safety.ts +143 -0
  84. package/src/util/text-extractor.ts +87 -0
  85. package/src/util/ui-tree-cache.test.ts +105 -0
  86. package/src/util/ui-tree-cache.ts +54 -0
  87. package/src/util/ui-tree-parser.test.ts +182 -0
  88. package/src/util/ui-tree-parser.ts +169 -0
  89. package/tsconfig.json +11 -0
@@ -0,0 +1,593 @@
1
+ /**
2
+ * Smart Actions - Higher-level compound tools that reduce round-trips.
3
+ *
4
+ * Provides:
5
+ * - wait_for_text: Poll until text appears on screen (navigation waits)
6
+ * - scroll_to_text: Scroll until an element with matching text is visible
7
+ * - fill_form: Batch-fill multiple form fields in one call
8
+ * - tap_and_wait: Tap element then wait for expected text
9
+ * - assert_screen: Quick screen assertions (contains / notContains)
10
+ * - open_deep_link: Open app via deep link URL scheme
11
+ * - take_screenshot: Capture screen as base64 PNG image
12
+ */
13
+
14
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { z } from "zod";
16
+ import type { ConnectionManager } from "../transport/connection-manager.js";
17
+ import { flattenTree, type UiNode } from "../util/ui-tree-parser.js";
18
+ import { extractTextStrings } from "../util/text-extractor.js";
19
+ import { getCachedTree } from "./ui-tree.js";
20
+ import { readFile, unlink } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ /** Sleep helper */
25
+ function sleep(ms: number): Promise<void> {
26
+ return new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
28
+
29
+ /**
30
+ * Find an element by text/resourceId/contentDescription in the tree.
31
+ */
32
+ function findElement(
33
+ flat: UiNode[],
34
+ selector: { text?: string; resourceId?: string; contentDescription?: string },
35
+ ): UiNode | null {
36
+ for (const node of flat) {
37
+ if (selector.text && node.text.toLowerCase().includes(selector.text.toLowerCase())) {
38
+ return node;
39
+ }
40
+ if (selector.resourceId && node.resourceId.includes(selector.resourceId)) {
41
+ return node;
42
+ }
43
+ if (
44
+ selector.contentDescription &&
45
+ node.contentDescription.toLowerCase().includes(selector.contentDescription.toLowerCase())
46
+ ) {
47
+ return node;
48
+ }
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export function registerSmartActionTools(server: McpServer, cm: ConnectionManager): void {
54
+ /**
55
+ * wait_for_text - Poll until specific text appears on screen.
56
+ */
57
+ server.tool(
58
+ "wait_for_text",
59
+ "Wait until specific text appears on the screen. Polls the UI tree at intervals until the text is found or timeout is reached. Essential after navigation, screen transitions, or async operations.",
60
+ {
61
+ text: z.string().describe("Text to wait for (case-insensitive substring match)"),
62
+ timeoutMs: z.number().optional().describe("Maximum wait time in ms (default: 10000)"),
63
+ pollIntervalMs: z.number().optional().describe("Polling interval in ms (default: 1000)"),
64
+ },
65
+ async (params) => {
66
+ const timeout = params.timeoutMs ?? 10_000;
67
+ const interval = params.pollIntervalMs ?? 1_000;
68
+ const startTime = Date.now();
69
+ const searchLower = params.text.toLowerCase();
70
+ let attempts = 0;
71
+
72
+ while (Date.now() - startTime < timeout) {
73
+ attempts++;
74
+ // Force fresh dump each poll
75
+ cm.uiCache.invalidate();
76
+
77
+ try {
78
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
79
+ const texts = extractTextStrings(tree);
80
+ const found = texts.some((t) => t.toLowerCase().includes(searchLower));
81
+
82
+ if (found) {
83
+ return {
84
+ content: [
85
+ {
86
+ type: "text" as const,
87
+ text: JSON.stringify({
88
+ success: true,
89
+ found: true,
90
+ text: params.text,
91
+ elapsedMs: Date.now() - startTime,
92
+ attempts,
93
+ }),
94
+ },
95
+ ],
96
+ };
97
+ }
98
+ } catch {
99
+ // UI dump failed, retry
100
+ }
101
+
102
+ if (Date.now() - startTime + interval < timeout) {
103
+ await sleep(interval);
104
+ }
105
+ }
106
+
107
+ return {
108
+ content: [
109
+ {
110
+ type: "text" as const,
111
+ text: JSON.stringify({
112
+ success: false,
113
+ found: false,
114
+ text: params.text,
115
+ elapsedMs: Date.now() - startTime,
116
+ attempts,
117
+ hint: "Text did not appear within timeout. Check if the expected screen transition occurred.",
118
+ }),
119
+ },
120
+ ],
121
+ };
122
+ },
123
+ );
124
+
125
+ /**
126
+ * scroll_to_text - Scroll until element with text is visible.
127
+ */
128
+ server.tool(
129
+ "scroll_to_text",
130
+ "Scroll the screen until an element containing the specified text becomes visible. Useful for finding elements in long scrollable lists or forms.",
131
+ {
132
+ text: z.string().describe("Text to scroll to (case-insensitive substring match)"),
133
+ direction: z.enum(["up", "down"]).optional().describe("Scroll direction (default: down)"),
134
+ maxScrolls: z.number().optional().describe("Maximum scroll attempts (default: 10)"),
135
+ },
136
+ async (params) => {
137
+ const direction = params.direction ?? "down";
138
+ const maxScrolls = params.maxScrolls ?? 10;
139
+ const searchLower = params.text.toLowerCase();
140
+
141
+ // Screen dimensions for scroll coordinates (assume 1080x2340 default, adjust from first tree)
142
+ let screenWidth = 1080;
143
+ let screenHeight = 2340;
144
+
145
+ for (let i = 0; i < maxScrolls; i++) {
146
+ cm.uiCache.invalidate();
147
+
148
+ try {
149
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
150
+ const flat = flattenTree(tree);
151
+
152
+ // Detect screen size from root node bounds
153
+ if (flat.length > 0 && flat[0].bounds) {
154
+ screenWidth = flat[0].bounds.right;
155
+ screenHeight = flat[0].bounds.bottom;
156
+ }
157
+
158
+ // Check if target text is visible
159
+ const match = flat.find(
160
+ (n) =>
161
+ n.text.toLowerCase().includes(searchLower) ||
162
+ n.contentDescription.toLowerCase().includes(searchLower),
163
+ );
164
+
165
+ if (match && match.bounds) {
166
+ return {
167
+ content: [
168
+ {
169
+ type: "text" as const,
170
+ text: JSON.stringify({
171
+ success: true,
172
+ found: true,
173
+ text: match.text || match.contentDescription,
174
+ center: {
175
+ x: Math.round((match.bounds.left + match.bounds.right) / 2),
176
+ y: Math.round((match.bounds.top + match.bounds.bottom) / 2),
177
+ },
178
+ scrollsNeeded: i,
179
+ }),
180
+ },
181
+ ],
182
+ };
183
+ }
184
+ } catch {
185
+ // Continue scrolling
186
+ }
187
+
188
+ // Perform scroll
189
+ const centerX = Math.round(screenWidth / 2);
190
+ const startY = direction === "down"
191
+ ? Math.round(screenHeight * 0.7)
192
+ : Math.round(screenHeight * 0.3);
193
+ const endY = direction === "down"
194
+ ? Math.round(screenHeight * 0.3)
195
+ : Math.round(screenHeight * 0.7);
196
+
197
+ await cm.adb.shell(`input swipe ${centerX} ${startY} ${centerX} ${endY} 300`);
198
+ // Brief pause for scroll animation to settle
199
+ await sleep(500);
200
+ }
201
+
202
+ return {
203
+ content: [
204
+ {
205
+ type: "text" as const,
206
+ text: JSON.stringify({
207
+ success: false,
208
+ found: false,
209
+ text: params.text,
210
+ scrollAttempts: maxScrolls,
211
+ hint: `Text "${params.text}" not found after ${maxScrolls} scroll attempts.`,
212
+ }),
213
+ },
214
+ ],
215
+ };
216
+ },
217
+ );
218
+
219
+ /**
220
+ * fill_form - Fill multiple form fields in one call.
221
+ */
222
+ server.tool(
223
+ "fill_form",
224
+ "Fill multiple form fields in a single call. Each field is identified by text, resourceId, or contentDescription. Taps the field, clears it, types the value. Much more efficient than separate tap+type calls for each field.",
225
+ {
226
+ fields: z
227
+ .array(
228
+ z.object({
229
+ selector: z.object({
230
+ text: z.string().optional().describe("Find field by visible text/label"),
231
+ resourceId: z.string().optional().describe("Find field by resource ID"),
232
+ contentDescription: z.string().optional().describe("Find field by content description"),
233
+ }).describe("How to find the field"),
234
+ value: z.string().describe("Text to enter in the field"),
235
+ clearFirst: z.boolean().optional().describe("Clear existing text before typing (default: true)"),
236
+ }),
237
+ )
238
+ .describe("Array of field selectors and values to fill"),
239
+ },
240
+ async (params) => {
241
+ const results: Array<{ field: string; success: boolean; error?: string }> = [];
242
+
243
+ for (const field of params.fields) {
244
+ const clearFirst = field.clearFirst ?? true;
245
+ const fieldId = field.selector.text || field.selector.resourceId || field.selector.contentDescription || "unknown";
246
+
247
+ try {
248
+ // Force fresh tree for each field (screen changes after typing)
249
+ cm.uiCache.invalidate();
250
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
251
+ const flat = flattenTree(tree);
252
+ const node = findElement(flat, field.selector);
253
+
254
+ if (!node || !node.bounds) {
255
+ results.push({ field: fieldId, success: false, error: "Element not found" });
256
+ continue;
257
+ }
258
+
259
+ const x = Math.round((node.bounds.left + node.bounds.right) / 2);
260
+ const y = Math.round((node.bounds.top + node.bounds.bottom) / 2);
261
+
262
+ // Tap the field
263
+ await cm.adb.shell(`input tap ${x} ${y}`);
264
+ await sleep(300); // Wait for field to focus
265
+
266
+ // Clear if needed
267
+ if (clearFirst) {
268
+ await cm.adb.shell("input keyevent 123"); // MOVE_END
269
+ await cm.adb.shell("input keyevent " + Array(50).fill("67").join(" "));
270
+ }
271
+
272
+ // Type the value
273
+ const escaped = field.value
274
+ .replace(/\\/g, "\\\\")
275
+ .replace(/ /g, "%s")
276
+ .replace(/'/g, "\\'")
277
+ .replace(/"/g, '\\"')
278
+ .replace(/&/g, "\\&")
279
+ .replace(/</g, "\\<")
280
+ .replace(/>/g, "\\>")
281
+ .replace(/\|/g, "\\|")
282
+ .replace(/;/g, "\\;")
283
+ .replace(/\(/g, "\\(")
284
+ .replace(/\)/g, "\\)")
285
+ .replace(/\$/g, "\\$")
286
+ .replace(/`/g, "\\`");
287
+
288
+ await cm.adb.shell(`input text ${escaped}`);
289
+
290
+ // Dismiss keyboard with ESCAPE to prevent focus issues with next field
291
+ await cm.adb.shell("input keyevent 111"); // ESCAPE
292
+
293
+ results.push({ field: fieldId, success: true });
294
+ } catch (err) {
295
+ results.push({
296
+ field: fieldId,
297
+ success: false,
298
+ error: err instanceof Error ? err.message : String(err),
299
+ });
300
+ }
301
+ }
302
+
303
+ const allSuccess = results.every((r) => r.success);
304
+
305
+ return {
306
+ content: [
307
+ {
308
+ type: "text" as const,
309
+ text: JSON.stringify({
310
+ success: allSuccess,
311
+ filledCount: results.filter((r) => r.success).length,
312
+ totalFields: params.fields.length,
313
+ results,
314
+ }),
315
+ },
316
+ ],
317
+ };
318
+ },
319
+ );
320
+
321
+ /**
322
+ * tap_and_wait - Tap an element then wait for expected text to appear.
323
+ */
324
+ server.tool(
325
+ "tap_and_wait",
326
+ "Tap an element and wait for expected text to appear on screen. Combines tap + wait_for_text in a single call to reduce round-trips.",
327
+ {
328
+ text: z.string().optional().describe("Visible text to tap"),
329
+ resourceId: z.string().optional().describe("Android resource ID to tap"),
330
+ contentDescription: z.string().optional().describe("Content description to tap"),
331
+ x: z.number().optional().describe("X coordinate to tap (fallback)"),
332
+ y: z.number().optional().describe("Y coordinate to tap (fallback)"),
333
+ waitForText: z.string().describe("Text to wait for after tap"),
334
+ timeoutMs: z.number().optional().describe("Max wait time in ms (default: 10000)"),
335
+ },
336
+ async (params) => {
337
+ try {
338
+ // Step 1: Find and tap the element
339
+ cm.uiCache.invalidate();
340
+ let tapX: number | undefined;
341
+ let tapY: number | undefined;
342
+ let matchedBy = "";
343
+
344
+ if (params.text || params.resourceId || params.contentDescription) {
345
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
346
+ const flat = flattenTree(tree);
347
+ const match = findElement(flat, {
348
+ text: params.text,
349
+ resourceId: params.resourceId,
350
+ contentDescription: params.contentDescription,
351
+ });
352
+
353
+ if (match?.bounds) {
354
+ tapX = Math.round((match.bounds.left + match.bounds.right) / 2);
355
+ tapY = Math.round((match.bounds.top + match.bounds.bottom) / 2);
356
+ matchedBy = params.text
357
+ ? `text="${match.text}"`
358
+ : params.resourceId
359
+ ? `resourceId="${match.resourceId}"`
360
+ : `contentDescription="${match.contentDescription}"`;
361
+ }
362
+ }
363
+
364
+ if (tapX === undefined || tapY === undefined) {
365
+ if (params.x !== undefined && params.y !== undefined) {
366
+ tapX = params.x;
367
+ tapY = params.y;
368
+ matchedBy = `coordinates (${tapX}, ${tapY})`;
369
+ } else {
370
+ return {
371
+ content: [
372
+ {
373
+ type: "text" as const,
374
+ text: JSON.stringify({
375
+ success: false,
376
+ error: "Element not found",
377
+ hint: "Use get_screen_text to see available elements.",
378
+ }),
379
+ },
380
+ ],
381
+ };
382
+ }
383
+ }
384
+
385
+ cm.uiCache.invalidate();
386
+ await cm.adb.shell(`input tap ${tapX} ${tapY}`);
387
+
388
+ // Step 2: Wait for text to appear
389
+ const timeout = params.timeoutMs ?? 10_000;
390
+ const interval = 500;
391
+ const startTime = Date.now();
392
+ const searchLower = params.waitForText.toLowerCase();
393
+
394
+ while (Date.now() - startTime < timeout) {
395
+ await sleep(interval);
396
+ cm.uiCache.invalidate();
397
+
398
+ try {
399
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
400
+ const texts = extractTextStrings(tree);
401
+ const found = texts.some((t) => t.toLowerCase().includes(searchLower));
402
+
403
+ if (found) {
404
+ return {
405
+ content: [
406
+ {
407
+ type: "text" as const,
408
+ text: JSON.stringify({
409
+ success: true,
410
+ tappedBy: matchedBy,
411
+ tapped: { x: tapX, y: tapY },
412
+ waitedFor: params.waitForText,
413
+ elapsedMs: Date.now() - startTime,
414
+ }),
415
+ },
416
+ ],
417
+ };
418
+ }
419
+ } catch {
420
+ // UI dump failed, retry
421
+ }
422
+ }
423
+
424
+ return {
425
+ content: [
426
+ {
427
+ type: "text" as const,
428
+ text: JSON.stringify({
429
+ success: false,
430
+ tappedBy: matchedBy,
431
+ tapped: { x: tapX, y: tapY },
432
+ waitedFor: params.waitForText,
433
+ timedOut: true,
434
+ elapsedMs: Date.now() - startTime,
435
+ hint: "Tap succeeded but expected text did not appear within timeout.",
436
+ }),
437
+ },
438
+ ],
439
+ };
440
+ } catch (err) {
441
+ return {
442
+ content: [
443
+ {
444
+ type: "text" as const,
445
+ text: `tap_and_wait failed: ${err instanceof Error ? err.message : String(err)}`,
446
+ },
447
+ ],
448
+ isError: true,
449
+ };
450
+ }
451
+ },
452
+ );
453
+
454
+ /**
455
+ * assert_screen - Quick screen assertions without returning the full tree.
456
+ */
457
+ server.tool(
458
+ "assert_screen",
459
+ "Quickly verify screen contains (or doesn't contain) expected text. Returns pass/fail without the full UI tree, saving tokens.",
460
+ {
461
+ contains: z.array(z.string()).optional().describe("Text strings that MUST be on screen"),
462
+ notContains: z.array(z.string()).optional().describe("Text strings that must NOT be on screen"),
463
+ },
464
+ async (params) => {
465
+ try {
466
+ cm.uiCache.invalidate();
467
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
468
+ const allText = extractTextStrings(tree).map((t) => t.toLowerCase());
469
+
470
+ const missing: string[] = [];
471
+ const unexpected: string[] = [];
472
+
473
+ for (const expected of params.contains ?? []) {
474
+ if (!allText.some((t) => t.includes(expected.toLowerCase()))) {
475
+ missing.push(expected);
476
+ }
477
+ }
478
+
479
+ for (const banned of params.notContains ?? []) {
480
+ if (allText.some((t) => t.includes(banned.toLowerCase()))) {
481
+ unexpected.push(banned);
482
+ }
483
+ }
484
+
485
+ const pass = missing.length === 0 && unexpected.length === 0;
486
+ return {
487
+ content: [
488
+ {
489
+ type: "text" as const,
490
+ text: JSON.stringify({
491
+ pass,
492
+ ...(missing.length > 0 ? { missing } : {}),
493
+ ...(unexpected.length > 0 ? { unexpected } : {}),
494
+ }),
495
+ },
496
+ ],
497
+ };
498
+ } catch (err) {
499
+ return {
500
+ content: [
501
+ {
502
+ type: "text" as const,
503
+ text: `assert_screen failed: ${err instanceof Error ? err.message : String(err)}`,
504
+ },
505
+ ],
506
+ isError: true,
507
+ };
508
+ }
509
+ },
510
+ );
511
+
512
+ /**
513
+ * open_deep_link - Open app via deep link URL scheme.
514
+ */
515
+ server.tool(
516
+ "open_deep_link",
517
+ "Open the app via a deep link URL scheme. Navigates directly to a specific screen without tapping through the UI.",
518
+ {
519
+ url: z.string().describe("Deep link URL (e.g., myapp://screen/detail?id=123 or https://example.com/path)"),
520
+ },
521
+ async (params) => {
522
+ try {
523
+ cm.uiCache.invalidate();
524
+ await cm.adb.shell(`am start -a android.intent.action.VIEW -d "${params.url}"`);
525
+ return {
526
+ content: [
527
+ {
528
+ type: "text" as const,
529
+ text: JSON.stringify({ success: true, url: params.url }),
530
+ },
531
+ ],
532
+ };
533
+ } catch (err) {
534
+ return {
535
+ content: [
536
+ {
537
+ type: "text" as const,
538
+ text: `open_deep_link failed: ${err instanceof Error ? err.message : String(err)}`,
539
+ },
540
+ ],
541
+ isError: true,
542
+ };
543
+ }
544
+ },
545
+ );
546
+
547
+ /**
548
+ * take_screenshot - Capture the screen as a base64 PNG image.
549
+ */
550
+ server.tool(
551
+ "take_screenshot",
552
+ "Capture a screenshot of the current screen and return it as a base64-encoded PNG image. Useful for visual verification of the app state.",
553
+ {},
554
+ async () => {
555
+ try {
556
+ const localPath = join(tmpdir(), `emudebug-screenshot-${Date.now()}.png`);
557
+
558
+ // Capture screenshot on device and pull to local
559
+ await cm.adb.shell("screencap -p /sdcard/emudebug_screenshot.png");
560
+ await cm.adb.exec(["pull", "/sdcard/emudebug_screenshot.png", localPath]);
561
+ // Clean up device file (fire and forget)
562
+ cm.adb.shell("rm -f /sdcard/emudebug_screenshot.png").catch(() => {});
563
+
564
+ // Read the local file as base64
565
+ const imageBuffer = await readFile(localPath);
566
+ const base64 = imageBuffer.toString("base64");
567
+
568
+ // Clean up local file
569
+ await unlink(localPath).catch(() => {});
570
+
571
+ return {
572
+ content: [
573
+ {
574
+ type: "image" as const,
575
+ data: base64,
576
+ mimeType: "image/png",
577
+ },
578
+ ],
579
+ };
580
+ } catch (err) {
581
+ return {
582
+ content: [
583
+ {
584
+ type: "text" as const,
585
+ text: `Error taking screenshot: ${err instanceof Error ? err.message : String(err)}`,
586
+ },
587
+ ],
588
+ isError: true,
589
+ };
590
+ }
591
+ },
592
+ );
593
+ }