Skip to content

Commit 538ea2e

Browse files
Merge pull request #3 from MetaMask/add-tools
test: add additional tools
2 parents cd87b20 + 623f4cc commit 538ea2e

23 files changed

Lines changed: 1209 additions & 63 deletions

CHANGELOG.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
### Added
1313

1414
- Initial release of `@metamask/device-mcp`
15-
- MCP server with stdio transport for mobile device interaction
16-
- iOS backend via IDB (`idb ui describe-all`, `idb ui tap`, `idb ui text`, `idb ui swipe`)
17-
- Android backend via ADB (`uiautomator dump`, `input tap`, `input text`, `input swipe`)
18-
- Auto-detection of platform from connected device or `DEVICE_ID` env var
19-
- 6 tools: `device_snapshot`, `device_tap_element`, `device_type`, `device_swipe`, `device_wait_for`, `device_app_state`
15+
- MCP server with stdio transport and lazy backend initialization
16+
- Three backends: iOS (IDB), Android (ADB), Appium/BrowserStack (W3C WebDriver)
17+
- `.device-session` file for attaching to existing Appium sessions or creating new ones
18+
- 16 MCP tools: `device_snapshot`, `device_screenshot`, `device_info`, `device_tap_element`, `device_tap_coordinates`, `device_type`, `device_swipe`, `device_long_press`, `device_wait_for`, `device_app_state`, `device_open_app`, `device_close_app`, `device_press_button`, `device_dismiss_keyboard`, `device_dismiss_alert`, `device_logs`
19+
- Fuzzy element matching (case-insensitive, partial text)
20+
- Android tree-structured UI hierarchy parser (nested, not flat)
21+
- iOS `snapshotMaxDepth` and `mobile: source` fallback for deep hierarchies
22+
- Auto-detection of platform from `.device-session`, `DEVICE_ID`, or booted simulator/emulator
2023
- Runtime health check with actionable error messages for missing IDB/ADB
24+
- SKILL.md agent reference with core loop, common patterns, and platform differences
25+
- MetaMask module template compliance (ts-bridge, dual CJS/ESM, yarn constraints, CI workflows)
2126

2227
[Unreleased]: https://github.com/MetaMask/device-mcp/compare/v0.1.0...HEAD
2328
[0.1.0]: https://github.com/MetaMask/device-mcp/releases/tag/v0.1.0

src/backends/adb-backend.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('parseAndroidHierarchy', () => {
2828
expect(frame.children![2].value).toBe('$0.00');
2929
});
3030

31-
it('self-closing nodes have no children', () => {
31+
it('self-closing nodes have no children array', () => {
3232
const elements = parseAndroidHierarchy(SAMPLE_UIAUTOMATOR_XML);
3333
const title = elements[0].children![0];
3434
expect(title.children).toBeUndefined();

src/backends/adb-backend.ts

Lines changed: 182 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { spawn } from 'node:child_process';
2+
import type { ChildProcess } from 'node:child_process';
3+
14
import type {
25
DeviceBackend,
36
DeviceButton,
@@ -9,7 +12,9 @@ import type {
912
AppStateResult,
1013
ElementQuery,
1114
UIElement,
15+
WindowSize,
1216
} from './types.js';
17+
import { ACCEPT_LABELS, DENY_LABELS } from '../utils/alert-labels.js';
1318
import {
1419
findElement,
1520
describeElement,
@@ -22,6 +27,12 @@ export class AdbBackend implements DeviceBackend {
2227

2328
readonly #serial: string;
2429

30+
#recordingProcess: ChildProcess | null = null;
31+
32+
#recordingPath: string | null = null;
33+
34+
readonly #recordingRemotePath = '/sdcard/device-mcp-recording.mp4';
35+
2536
constructor(serial: string) {
2637
this.#serial = serial;
2738
}
@@ -97,6 +108,17 @@ export class AdbBackend implements DeviceBackend {
97108
}
98109

99110
async typeText(text: string): Promise<void> {
111+
// Android keyevent 29+shift = Ctrl+A (select all), 67 = DEL
112+
await this.#adb(['shell', 'input', 'keyevent', 'KEYCODE_MOVE_HOME']);
113+
await this.#adb([
114+
'shell',
115+
'input',
116+
'keyevent',
117+
'--longpress',
118+
'KEYCODE_SHIFT_LEFT',
119+
'KEYCODE_MOVE_END',
120+
]);
121+
await this.#adb(['shell', 'input', 'keyevent', '67']);
100122
// Android input text requires escaping spaces and special chars
101123
const escaped = text.replace(/ /gu, '%s').replace(/[&|;<>]/gu, '\\$&');
102124
await this.#adb(['shell', 'input', 'text', escaped]);
@@ -230,23 +252,20 @@ export class AdbBackend implements DeviceBackend {
230252
}
231253

232254
async dismissAlert(accept: boolean): Promise<void> {
233-
const snapshot = await this.snapshot();
234-
const buttonQuery = accept ? { text: 'Allow' } : { text: 'Deny' };
235-
const fallbackQuery = accept ? { text: 'OK' } : { text: 'Cancel' };
236-
237-
const element =
238-
findElement(snapshot.hierarchy, buttonQuery) ||
239-
findElement(snapshot.hierarchy, fallbackQuery);
240-
241-
if (element) {
242-
const cx = Math.round(element.frame.x + element.frame.width / 2);
243-
const cy = Math.round(element.frame.y + element.frame.height / 2);
244-
await this.tapCoordinates(cx, cy);
245-
} else {
246-
throw new Error(
247-
`No alert button found. Looked for "${buttonQuery.text}" and "${fallbackQuery.text}"`,
248-
);
255+
const snap = await this.snapshot();
256+
const candidates = accept ? ACCEPT_LABELS : DENY_LABELS;
257+
258+
for (const label of candidates) {
259+
const element = findElement(snap.hierarchy, { text: label });
260+
if (element) {
261+
const cx = Math.round(element.frame.x + element.frame.width / 2);
262+
const cy = Math.round(element.frame.y + element.frame.height / 2);
263+
await this.tapCoordinates(cx, cy);
264+
return;
265+
}
249266
}
267+
268+
throw new Error(`No alert button found. Tried: ${candidates.join(', ')}`);
250269
}
251270

252271
async getLogs(durationSeconds = 30, filter?: string): Promise<LogsResult> {
@@ -303,6 +322,152 @@ export class AdbBackend implements DeviceBackend {
303322
targetDescription: describeElement(element),
304323
};
305324
}
325+
326+
async scrollToElement(
327+
query: ElementQuery,
328+
direction: 'up' | 'down' = 'down',
329+
maxAttempts = 10,
330+
): Promise<UIElement> {
331+
let previousRaw = '';
332+
333+
for (let i = 0; i < maxAttempts; i++) {
334+
const snap = await this.snapshot();
335+
const element = findElement(snap.hierarchy, query);
336+
if (element) {
337+
return element;
338+
}
339+
if (snap.raw === previousRaw) {
340+
break;
341+
}
342+
previousRaw = snap.raw;
343+
await this.swipe(direction);
344+
}
345+
throw new Error(
346+
`Element not found after scrolling: ${JSON.stringify(query)}\n` +
347+
'Use device_snapshot to inspect the current UI hierarchy.',
348+
);
349+
}
350+
351+
async getAlertText(): Promise<string> {
352+
const snap = await this.snapshot();
353+
const texts = collectAndroidAlertTexts(snap.hierarchy);
354+
if (texts.length === 0) {
355+
throw new Error(
356+
'No alert is currently displayed.\n' +
357+
'Use device_snapshot to inspect the current UI hierarchy.',
358+
);
359+
}
360+
return texts.join('\n');
361+
}
362+
363+
async getWindowSize(): Promise<WindowSize> {
364+
const raw = await this.#adb(['shell', 'wm', 'size']);
365+
const match = raw.match(/(\d+)x(\d+)/u);
366+
if (match) {
367+
return {
368+
width: parseInt(match[1], 10),
369+
height: parseInt(match[2], 10),
370+
};
371+
}
372+
throw new Error(`Unable to parse window size from: ${raw.trim()}`);
373+
}
374+
375+
async getContexts(): Promise<string[]> {
376+
return ['NATIVE_APP'];
377+
}
378+
379+
async setContext(context: string): Promise<void> {
380+
if (context === 'NATIVE_APP') {
381+
return;
382+
}
383+
throw new Error(
384+
'Context switching requires Appium backend. ADB only supports NATIVE_APP.',
385+
);
386+
}
387+
388+
async getClipboard(): Promise<string> {
389+
throw new Error(
390+
'Clipboard access via ADB is not supported on modern Android. ' +
391+
'Use the Appium backend for clipboard operations.',
392+
);
393+
}
394+
395+
async setClipboard(_text: string): Promise<void> {
396+
throw new Error(
397+
'Clipboard access via ADB is not supported on modern Android. ' +
398+
'Use the Appium backend for clipboard operations.',
399+
);
400+
}
401+
402+
async startScreenRecording(outputPath?: string): Promise<void> {
403+
if (this.#recordingProcess) {
404+
throw new Error('Screen recording is already in progress');
405+
}
406+
this.#recordingPath =
407+
outputPath ?? `/tmp/device-mcp-recording-${Date.now()}.mp4`;
408+
this.#recordingProcess = spawn('adb', [
409+
'-s',
410+
this.#serial,
411+
'shell',
412+
'screenrecord',
413+
this.#recordingRemotePath,
414+
]);
415+
this.#recordingProcess.on('error', () => {
416+
this.#recordingProcess = null;
417+
this.#recordingPath = null;
418+
});
419+
}
420+
421+
async stopScreenRecording(): Promise<string> {
422+
if (!this.#recordingProcess || !this.#recordingPath) {
423+
throw new Error('No screen recording in progress');
424+
}
425+
const localPath = this.#recordingPath;
426+
this.#recordingProcess.kill('SIGINT');
427+
await new Promise((resolve) => setTimeout(resolve, 2000));
428+
this.#recordingProcess = null;
429+
this.#recordingPath = null;
430+
431+
await execStrict('adb', [
432+
'-s',
433+
this.#serial,
434+
'pull',
435+
this.#recordingRemotePath,
436+
localPath,
437+
]);
438+
await this.#adb(['shell', 'rm', '-f', this.#recordingRemotePath]);
439+
return localPath;
440+
}
441+
}
442+
443+
function collectAndroidAlertTexts(elements: UIElement[]): string[] {
444+
const texts: string[] = [];
445+
for (const el of elements) {
446+
const isDialog =
447+
el.type.includes('Dialog') || el.type.includes('AlertDialog');
448+
if (isDialog) {
449+
collectTextValues(el.children ?? [], texts);
450+
return texts;
451+
}
452+
if (el.children) {
453+
const found = collectAndroidAlertTexts(el.children);
454+
if (found.length > 0) {
455+
return found;
456+
}
457+
}
458+
}
459+
return texts;
460+
}
461+
462+
function collectTextValues(elements: UIElement[], texts: string[]): void {
463+
for (const el of elements) {
464+
if (el.type.includes('TextView') && el.value) {
465+
texts.push(el.value);
466+
}
467+
if (el.children) {
468+
collectTextValues(el.children, texts);
469+
}
470+
}
306471
}
307472

308473
export function parseAndroidHierarchy(xml: string): UIElement[] {
@@ -319,14 +484,7 @@ export function parseAndroidHierarchy(xml: string): UIElement[] {
319484

320485
if (isClosing) {
321486
if (stack.length > 1) {
322-
const children = stack.pop();
323-
if (children?.length === 0) {
324-
const parent = stack[stack.length - 1];
325-
const lastEl = parent[parent.length - 1];
326-
if (lastEl) {
327-
delete lastEl.children;
328-
}
329-
}
487+
stack.pop();
330488
}
331489
} else if (isSelfClosing && attrs) {
332490
const element = parseNodeAttributes(attrs);

0 commit comments

Comments
 (0)