Skip to content

Commit a5b010b

Browse files
test: add additional tools
1 parent 04b2ec3 commit a5b010b

21 files changed

Lines changed: 784 additions & 88 deletions

README.md

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -108,18 +108,21 @@ The `.device-session` file is typically written by the test runner when it creat
108108
| ------------------- | ---------------------------------------------------------------- |
109109
| `device_snapshot` | Capture the UI accessibility hierarchy. Call before interacting. |
110110
| `device_screenshot` | Capture a screenshot as base64 PNG. Optionally save to file. |
111+
| `device_info` | Get device platform, name, OS version, and device ID. |
111112
| `device_app_state` | Check if an app is running, installed, or absent. |
112113
| `device_logs` | Capture recent device logs (syslog/logcat) with optional filter. |
113114

114115
### Interaction
115116

116-
| Tool | Description |
117-
| --------------------- | ------------------------------------------------------------------ |
118-
| `device_tap_element` | Find an element by label/identifier/text/type and tap its center. |
119-
| `device_type` | Type text into the currently focused input field. |
120-
| `device_swipe` | Swipe in a direction with optional start coordinates and distance. |
121-
| `device_wait_for` | Poll until an element matching a query appears. |
122-
| `device_press_button` | Press a device button (home/back/enter/lock). |
117+
| Tool | Description |
118+
| ------------------------ | ------------------------------------------------------------------ |
119+
| `device_tap_element` | Find an element by label/identifier/text/type and tap its center. |
120+
| `device_tap_coordinates` | Tap at exact screen coordinates. Last resort when queries fail. |
121+
| `device_type` | Type text into the currently focused input field. |
122+
| `device_swipe` | Swipe in a direction with optional start coordinates and distance. |
123+
| `device_long_press` | Long press an element for context menus or drag initiation. |
124+
| `device_wait_for` | Poll until an element matching a query appears. |
125+
| `device_press_button` | Press a device button (home/back/enter/lock). |
123126

124127
### App & Device Control
125128

@@ -132,28 +135,31 @@ The `.device-session` file is typically written by the test runner when it creat
132135

133136
### Element Identification
134137

135-
Elements are identified by accessibility attributes — not internal refs:
138+
Elements are identified by accessibility attributes — not internal refs. Matching is **fuzzy**: partial text and case-insensitive matches work. For example, querying `{ label: "Confirm" }` matches an element with label `"Confirm Transaction"`.
136139

137140
- **iOS**: accessibility label, accessibility identifier
138141
- **Android**: content-description, resource-id, text
139142

140143
### Backend Implementation
141144

142-
| Tool | iOS (IDB) | Android (ADB) | Appium (W3C WebDriver) |
143-
| ------------------------- | --------------------- | -------------------- | ----------------------------- |
144-
| `device_snapshot` | `idb ui describe-all` | `uiautomator dump` | `mobile: source` |
145-
| `device_screenshot` | `idb screenshot` | `screencap` + `pull` | `mobile: getScreenshot` |
146-
| `device_tap_element` | find + `idb ui tap` | find + `input tap` | find + W3C Actions |
147-
| `device_type` | `idb ui text` | `input text` | `findElement` + `sendKeys` |
148-
| `device_swipe` | `idb ui swipe` | `input swipe` | W3C Actions |
149-
| `device_wait_for` | poll snapshot | poll snapshot | poll snapshot |
150-
| `device_app_state` | `idb list-apps` | `dumpsys activity` | `mobile: queryAppState` |
151-
| `device_open_app` | `idb launch` | `monkey -p` | `mobile: activateApp` |
152-
| `device_close_app` | `idb terminate` | `am force-stop` | `mobile: terminateApp` |
153-
| `device_press_button` | `idb ui key` | `input keyevent` | `mobile: pressButton/Key` |
154-
| `device_dismiss_keyboard` | `idb ui key RETURN` | `input keyevent 111` | `mobile: hideKeyboard` |
155-
| `device_dismiss_alert` | find button + tap | find button + tap | `mobile: accept/dismissAlert` |
156-
| `device_logs` | `idb log` | `logcat` | `mobile: getLog` |
145+
| Tool | iOS (IDB) | Android (ADB) | Appium (W3C WebDriver) |
146+
| ------------------------- | ----------------------- | -------------------- | ----------------------------- |
147+
| `device_snapshot` | `idb ui describe-all` | `uiautomator dump` | `mobile: source` |
148+
| `device_screenshot` | `idb screenshot` | `screencap` + `pull` | `mobile: getScreenshot` |
149+
| `device_info` | `idb describe` | `getprop` | session capabilities |
150+
| `device_tap_element` | find + `idb ui tap` | find + `input tap` | find + W3C Actions |
151+
| `device_tap_coordinates` | `idb ui tap x y` | `input tap x y` | W3C Actions |
152+
| `device_type` | `idb ui text` | `input text` | `findElement` + `sendKeys` |
153+
| `device_swipe` | `idb ui swipe` | `input swipe` | W3C Actions |
154+
| `device_long_press` | `idb ui tap --duration` | `input swipe` (hold) | W3C Actions (pause) |
155+
| `device_wait_for` | poll snapshot | poll snapshot | poll snapshot |
156+
| `device_app_state` | `idb list-apps` | `dumpsys activity` | `mobile: queryAppState` |
157+
| `device_open_app` | `idb launch` | `monkey -p` | `mobile: activateApp` |
158+
| `device_close_app` | `idb terminate` | `am force-stop` | `mobile: terminateApp` |
159+
| `device_press_button` | `idb ui key` | `input keyevent` | `mobile: pressButton/Key` |
160+
| `device_dismiss_keyboard` | `idb ui key RETURN` | `input keyevent 111` | `mobile: hideKeyboard` |
161+
| `device_dismiss_alert` | find button + tap | find button + tap | `mobile: accept/dismissAlert` |
162+
| `device_logs` | `idb log` | `logcat` | `mobile: getLog` |
157163

158164
## MCP Client Configuration
159165

@@ -229,16 +235,16 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
229235
@metamask/device-mcp
230236
├── src/
231237
│ ├── index.ts # Entry point — lazy backend, stdio MCP server
232-
│ ├── server.ts # MCP server — registers 13 tools
238+
│ ├── server.ts # MCP server — registers 16 tools
233239
│ ├── backends/
234-
│ │ ├── types.ts # DeviceBackend interface (13 operations)
240+
│ │ ├── types.ts # DeviceBackend interface (16 operations)
235241
│ │ ├── idb-backend.ts # iOS local — IDB commands + JSON parser
236242
│ │ ├── adb-backend.ts # Android local — ADB commands + XML parser
237243
│ │ ├── appium-backend.ts # Remote — Appium/BrowserStack via W3C WebDriver
238244
│ │ ├── webdriver-client.ts # Minimal W3C WebDriver HTTP client (fetch)
239245
│ │ ├── session-file.ts # .device-session file reader
240246
│ │ └── index.ts # createBackend() + createLazyBackend() factory
241-
│ ├── tools/ # One file per MCP tool (13 tools)
247+
│ ├── tools/ # One file per MCP tool (16 tools)
242248
│ └── utils/
243249
│ ├── exec.ts # Shell execution wrapper
244250
│ ├── platform.ts # Platform auto-detection
@@ -249,7 +255,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
249255

250256
```bash
251257
yarn build # Compile TypeScript
252-
yarn test # Run tests (49 tests)
258+
yarn test # Run tests (69 tests)
253259
yarn lint # Lint everything (ESLint + Prettier + changelog)
254260
yarn lint:fix # Auto-fix lint issues
255261
yarn dev # Watch mode compilation

SKILL.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @metamask/device-mcp — Agent Reference
22

3-
You interact with a mobile device (iOS simulator or Android emulator) through the `device-mcp` MCP server. It exposes 6 tools for inspecting and controlling the device UI.
3+
You interact with a mobile device (iOS simulator, Android emulator, or BrowserStack remote device) through the `device-mcp` MCP server. It exposes 16 tools for inspecting UI state, interacting with elements, capturing evidence, and controlling app lifecycle.
44

55
## When to Use This
66

@@ -23,6 +23,9 @@ device_snapshot # 3. See the result — NEVER assume screen state
2323
- **ALWAYS call `device_snapshot` before interacting.** You cannot guess what's on screen.
2424
- **NEVER call `device_snapshot` twice without acting in between.** If the screen hasn't changed, the hierarchy won't either.
2525
- **ALWAYS call `device_snapshot` after interacting.** Confirm the action had the expected effect.
26+
- **Call `device_dismiss_keyboard` after typing.** The keyboard obscures elements below the input.
27+
- **Call `device_dismiss_alert` when a system dialog appears.** Permission prompts block all other interaction.
28+
- **Use `device_screenshot` for visual evidence.** Snapshots show accessibility data; screenshots show what the user sees.
2629

2730
## Tools
2831

@@ -56,6 +59,8 @@ Find an element by query and tap its center. Specify at least one of: `label`, `
5659
3. `text` — visible text content (matches both `value` and `label`)
5760
4. `type` — element class (least specific, combine with other fields)
5861

62+
**Matching is fuzzy:** partial text and case-insensitive matches work. `{ label: "Confirm" }` matches `"Confirm Transaction"`.
63+
5964
**Examples:**
6065

6166
```json
@@ -64,6 +69,22 @@ Find an element by query and tap its center. Specify at least one of: `label`, `
6469
{ "text": "Send", "type": "Button" }
6570
```
6671

72+
### device_tap_coordinates
73+
74+
Tap at exact screen coordinates. Use as a **last resort** when element queries fail. Get coordinates from `device_snapshot` frame data.
75+
76+
```json
77+
{ "x": 195, "y": 722 }
78+
```
79+
80+
### device_long_press
81+
82+
Long press an element. Used for context menus, drag initiation, and other long-press interactions.
83+
84+
```json
85+
{ "label": "Transaction", "durationMs": 2000 }
86+
```
87+
6788
### device_type
6889

6990
Type text into the currently focused input. **Tap an input field first** to focus it, then call this.
@@ -104,6 +125,12 @@ Check if an app is running, installed, or absent.
104125
{ "bundleId": "io.metamask" }
105126
```
106127

128+
### device_info (read-only)
129+
130+
Get device platform, name, OS version, and device ID.
131+
132+
**When:** Understanding which device/platform you're connected to. Including platform-specific instructions in your workflow.
133+
107134
### device_screenshot (read-only)
108135

109136
Capture a screenshot of the current screen. Returns base64 PNG image data.
@@ -192,8 +219,10 @@ device_snapshot # verify we're there
192219
device_snapshot # see available inputs
193220
device_tap_element { "identifier": "email-input" } # focus the field
194221
device_type { "text": "user@example.com" } # type into it
222+
device_dismiss_keyboard # hide keyboard
195223
device_tap_element { "identifier": "password-input" } # focus next field
196224
device_type { "text": "secret123" } # type password
225+
device_dismiss_keyboard # hide keyboard
197226
device_tap_element { "identifier": "submit-btn" } # submit
198227
device_snapshot # verify result
199228
```
@@ -211,10 +240,27 @@ device_snapshot # check again
211240

212241
```
213242
device_snapshot # see dialog
214-
device_tap_element { "label": "Allow" } # accept permission
243+
device_dismiss_alert { "accept": true } # accept permission
215244
device_snapshot # verify dismissed
216245
```
217246

247+
### Launch app, debug, collect evidence
248+
249+
```
250+
device_open_app { "bundleId": "io.metamask" } # launch app
251+
device_snapshot # see initial screen
252+
device_screenshot { "outputPath": "./debug/home.png" } # visual evidence
253+
device_logs { "filter": "MetaMask", "durationSeconds": 60 } # check for errors
254+
device_close_app { "bundleId": "io.metamask" } # stop app
255+
```
256+
257+
### Navigate with device buttons
258+
259+
```
260+
device_press_button { "button": "home" } # go to home screen
261+
device_press_button { "button": "back" } # go back (Android)
262+
```
263+
218264
## Error Handling
219265

220266
All tools return `isError: true` on failure with a descriptive message. Common errors:

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const config = createConfig([
6060
'@typescript-eslint/unbound-method': 'off',
6161
'@typescript-eslint/no-non-null-assertion': 'off',
6262
'no-restricted-globals': 'off',
63+
'n/no-sync': 'off',
6364
},
6465
},
6566
]);

src/backends/adb-backend.test.ts

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22

33
import { parseAndroidHierarchy, parseNodeAttributes } from './adb-backend.js';
4+
import { findElement } from '../utils/element.js';
45

56
const SAMPLE_UIAUTOMATOR_XML = `<?xml version="1.0" encoding="UTF-8"?>
67
<hierarchy rotation="0">
@@ -12,71 +13,70 @@ const SAMPLE_UIAUTOMATOR_XML = `<?xml version="1.0" encoding="UTF-8"?>
1213
</hierarchy>`;
1314

1415
describe('parseAndroidHierarchy', () => {
15-
it('parses uiautomator XML into flat element list', () => {
16+
it('builds a tree with parent-child relationships', () => {
1617
const elements = parseAndroidHierarchy(SAMPLE_UIAUTOMATOR_XML);
17-
expect(elements).toHaveLength(4);
18+
expect(elements).toHaveLength(1);
19+
expect(elements[0].type).toBe('android.widget.FrameLayout');
20+
expect(elements[0].children).toHaveLength(3);
1821
});
1922

20-
it('extracts text content', () => {
23+
it('nests children under the correct parent', () => {
2124
const elements = parseAndroidHierarchy(SAMPLE_UIAUTOMATOR_XML);
22-
const title = elements.find((element) => element.value === 'MetaMask');
23-
expect(title).toBeDefined();
24-
expect(title!.identifier).toBe('io.metamask:id/title');
25-
expect(title!.type).toBe('android.widget.TextView');
25+
const frame = elements[0];
26+
expect(frame.children![0].value).toBe('MetaMask');
27+
expect(frame.children![1].label).toBe('Account avatar');
28+
expect(frame.children![2].value).toBe('$0.00');
2629
});
2730

28-
it('extracts content-desc as label', () => {
31+
it('self-closing nodes have no children', () => {
2932
const elements = parseAndroidHierarchy(SAMPLE_UIAUTOMATOR_XML);
30-
const avatar = elements.find(
31-
(element) => element.label === 'Account avatar',
32-
);
33-
expect(avatar).toBeDefined();
34-
expect(avatar!.identifier).toBe('io.metamask:id/identicon');
33+
const title = elements[0].children![0];
34+
expect(title.children).toBeUndefined();
3535
});
3636

37-
it('parses bounds into frame coordinates', () => {
37+
it('findElement still finds nested elements', () => {
3838
const elements = parseAndroidHierarchy(SAMPLE_UIAUTOMATOR_XML);
39-
const title = elements.find((element) => element.value === 'MetaMask');
40-
expect(title!.frame).toStrictEqual({
41-
x: 100,
42-
y: 200,
43-
width: 400,
44-
height: 60,
45-
});
39+
const title = findElement(elements, { identifier: 'io.metamask:id/title' });
40+
expect(title).toBeDefined();
41+
expect(title!.value).toBe('MetaMask');
4642
});
4743

48-
it('detects disabled elements', () => {
49-
const elements = parseAndroidHierarchy(SAMPLE_UIAUTOMATOR_XML);
50-
const balance = elements.find((element) => element.value === '$0.00');
51-
expect(balance).toBeDefined();
52-
expect(balance!.enabled).toBe(false);
44+
it('handles empty XML gracefully', () => {
45+
expect(parseAndroidHierarchy('')).toStrictEqual([]);
5346
});
5447

55-
it('handles empty XML gracefully', () => {
56-
const elements = parseAndroidHierarchy('');
57-
expect(elements).toStrictEqual([]);
48+
it('handles deeply nested XML', () => {
49+
const deepXml = `<hierarchy>
50+
<node class="Root" bounds="[0,0][100,100]">
51+
<node class="Mid" bounds="[10,10][90,90]">
52+
<node class="Leaf" text="deep" bounds="[20,20][80,80]" />
53+
</node>
54+
</node>
55+
</hierarchy>`;
56+
const elements = parseAndroidHierarchy(deepXml);
57+
expect(elements).toHaveLength(1);
58+
expect(elements[0].children).toHaveLength(1);
59+
expect(elements[0].children![0].children).toHaveLength(1);
60+
expect(elements[0].children![0].children![0].value).toBe('deep');
5861
});
5962
});
6063

6164
describe('parseNodeAttributes', () => {
6265
it('returns null for missing bounds', () => {
63-
const result = parseNodeAttributes('text="hello" class="View"');
64-
expect(result).toBeNull();
66+
expect(parseNodeAttributes('text="hello" class="View"')).toBeNull();
6567
});
6668

6769
it('returns null for malformed bounds', () => {
68-
const result = parseNodeAttributes(
69-
'text="hello" class="View" bounds="invalid"',
70-
);
71-
expect(result).toBeNull();
70+
expect(
71+
parseNodeAttributes('text="hello" class="View" bounds="invalid"'),
72+
).toBeNull();
7273
});
7374

7475
it('parses a complete attribute string', () => {
7576
const attrs =
7677
'text="Send" resource-id="btn_send" class="Button" ' +
7778
'content-desc="Send button" enabled="true" bounds="[10,20][110,70]"';
78-
const result = parseNodeAttributes(attrs);
79-
expect(result).toStrictEqual({
79+
expect(parseNodeAttributes(attrs)).toStrictEqual({
8080
type: 'Button',
8181
label: 'Send button',
8282
value: 'Send',

0 commit comments

Comments
 (0)