
Security News
Re-Enabled GitHub Actions Expose Thousands of Repositories to Mini Shai-Hulud
Two compromised GitHub Actions were re-enabled with malicious tags intact, exposing thousands of downstream repositories to Mini Shai-Hulud.
@metamask/device-mcp
Advanced tools
MCP server for mobile device interaction — iOS (IDB), Android (ADB), and Appium/BrowserStack
MCP server for mobile device interaction — iOS (simctl + IDB), Android (ADB), and remote devices (Appium/BrowserStack).
Provides device interaction tools for LLM agents to inspect UI state, interact with elements, capture evidence, and control app lifecycle. Works standalone for debugging or as part of the self-healing test infrastructure for MetaMask Mobile.
^20 || ^22 || >=24xcrun simctl) + IDB for UI interaction. Install the unified client (brew tap facebook/fb && brew install idb), which bundles the CLI and simulator companion. On iOS 17+ simulators, element extraction requires this modern idb (it uses idb ui describe-all --api axbridge); the legacy python fb-idb client does not support --api and will not read the UI hierarchy on newer runtimes.$ANDROID_HOME, $ANDROID_SDK_ROOT, or ~/Library/Android/sdkWebSocket API. Node 22+ works out of the box; Node 20 requires launching with NODE_OPTIONS="--experimental-websocket".yarn add @metamask/device-mcp
Or run directly:
npx @metamask/device-mcp
The server communicates over stdio using the Model Context Protocol. It starts immediately and defers device connection to the first tool call — so the MCP handshake completes even when no device is available yet.
# Auto-detect connected device
device-mcp
# Target a specific device
DEVICE_ID=<udid-or-serial> device-mcp
# Target a specific platform (useful in CI with one device per platform)
DEVICE_PLATFORM=ios device-mcp
DEVICE_PLATFORM=android device-mcp
The server selects a backend in this order:
.device-session file — if present in the working directory, connects via Appium (local or BrowserStack)DEVICE_ID + DEVICE_PLATFORM — direct connect, no auto-detectionDEVICE_ID only — platform inferred from format (UUID = iOS, serial/emulator-* = Android)DEVICE_PLATFORM only — auto-detect first device of that platformdevice_select_deviceWhen multiple devices are connected and no DEVICE_ID is set, the server enters an "awaiting selection" state. Any tool call returns the list of available devices. Use device_list_devices to enumerate them and device_select_device to choose one.
xcrun simctl list devices booted --json — no IDB needed for discoveryadb devices — the server probes $ANDROID_HOME/platform-tools/adb, $ANDROID_SDK_ROOT/platform-tools/adb, and ~/Library/Android/sdk/platform-tools/adb when adb is not on $PATH$PATH, /usr/local/bin, /opt/homebrew/bin, and ~/Library/Python/*/bin (pip user installs)For remote devices or cloud testing, create a .device-session file in the working directory.
Attach to an existing Appium session (local):
{
"appiumUrl": "http://localhost:4723",
"sessionId": "abc123-def456",
"platform": "ios"
}
Attach to a BrowserStack session:
{
"appiumUrl": "https://hub-cloud.browserstack.com/wd/hub",
"sessionId": "abc123-def456",
"platform": "android",
"auth": {
"user": "YOUR_USERNAME",
"key": "YOUR_ACCESS_KEY"
}
}
Create a new BrowserStack session:
{
"appiumUrl": "https://hub-cloud.browserstack.com/wd/hub",
"platform": "ios",
"capabilities": {
"platformName": "iOS",
"appium:deviceName": "iPhone 15",
"appium:app": "bs://app-hash",
"bstack:options": { "userName": "...", "accessKey": "..." }
},
"auth": {
"user": "YOUR_USERNAME",
"key": "YOUR_ACCESS_KEY"
}
}
The .device-session file is typically written by the test runner when it creates an Appium session, and read by the MCP server when healing or agent interaction is needed.
| Tool | Description |
|---|---|
device_list_devices | List all connected devices and simulators/emulators. |
device_select_device | Select a device for this session. Use after device_list_devices. |
| Tool | Description |
|---|---|
device_snapshot | Capture the UI accessibility hierarchy. Call before interacting. |
device_screenshot | Capture a screenshot as base64 PNG. Optionally save to file. |
device_info | Get device platform, name, OS version, and device ID. |
device_app_state | Check if an app is running, installed, or absent. |
device_logs | Capture recent device logs (syslog/logcat) with optional filter. |
| Tool | Description |
|---|---|
device_tap_element | Find an element by label/identifier/text/type and tap its center. |
device_tap_coordinates | Tap at exact screen coordinates. Last resort when queries fail. |
device_type | Type text into the currently focused input field. |
device_swipe | Swipe in a direction with optional start coordinates and distance. |
device_long_press | Long press an element for context menus or drag initiation. |
device_wait_for | Poll until an element matching a query appears. |
device_press_button | Press a device button (home/back/enter/lock). |
| Tool | Description |
|---|---|
device_open_app | Launch or foreground an app by bundle ID. |
device_close_app | Force-stop an app by bundle ID. |
device_dismiss_keyboard | Hide the on-screen keyboard after typing. |
device_dismiss_alert | Accept or dismiss a system alert or permission dialog. |
| Tool | Description |
|---|---|
hermes_cdp | Speak raw Chrome DevTools Protocol to the React Native Hermes JS runtime (no DOM). |
hermes_targets | List and diagnose the debuggable Hermes targets exposed by Metro. |
webview_cdp | Speak raw CDP to a debuggable in-app Android WebView — the DOM of the app's in-app browser. |
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".
| Tool | iOS (IDB) | Android (ADB) | Appium (W3C WebDriver) |
|---|---|---|---|
device_snapshot | idb ui describe-all | uiautomator dump + instrumentation helper | mobile: source |
device_screenshot | idb screenshot | screencap + pull | mobile: getScreenshot |
device_info | idb describe | getprop | session capabilities |
device_tap_element | find + idb ui tap | find + input tap | find + W3C Actions |
device_tap_coordinates | idb ui tap x y | input tap x y | W3C Actions |
device_type | idb ui text | input text | findElement + sendKeys |
device_swipe | idb ui swipe | input swipe | W3C Actions |
device_long_press | idb ui tap --duration | input swipe (hold) | W3C Actions (pause) |
device_wait_for | poll snapshot | poll snapshot | poll snapshot |
device_list_devices | xcrun simctl list | adb devices | N/A |
device_select_device | select by UDID | select by serial | N/A |
device_app_state | idb list-apps / simctl listapps | dumpsys activity | mobile: queryAppState |
device_open_app | idb launch / simctl launch | monkey -p | mobile: activateApp |
device_close_app | idb terminate / simctl terminate | am force-stop | mobile: terminateApp |
device_press_button | idb ui key | input keyevent | mobile: pressButton/Key |
device_dismiss_keyboard | idb ui key RETURN | input keyevent 111 | mobile: hideKeyboard |
device_dismiss_alert | find button + tap | find button + tap | mobile: accept/dismissAlert |
device_logs | idb log | logcat | mobile: getLog |
uiautomator dump calls UiAutomation.waitForIdle internally, which never
returns on a screen that emits a continuous accessibility-event stream (for
example a React Native screen with polling or an animating skeleton loader). On
those screens the stock dump fails with ERROR: could not get idle state.
To handle this, the ADB backend ships a small self-instrumenting helper APK
(dist/android/device-mcp-android-snapshot-helper-*.apk, built during
prepack). The helper captures the accessibility hierarchy without waiting
for idle: it skips waitForIdle and instead retries a cheap capture until the
foreground window's root is present, then streams the hierarchy back over
am instrument as chunked base64. It is installed on demand the first time it
is needed and reused across snapshots in the session.
The strategy is controlled by DEVICE_MCP_ADB_SNAPSHOT:
auto (default) — a single fast uiautomator dump first (wins instantly
on idle screens), then the instrumentation helper if that dump does not
produce a hierarchy, then the remaining dump retries as a last resort.instrument — use the instrumentation helper only.dump — use stock uiautomator dump only (the original behavior; no APK
is installed).The helper is a testOnly APK installed with adb install -t, so it only
installs on developer emulators/attached devices, never on locked-down or
managed profiles.
Package names and version codes are attacker-controlled metadata, so before the
backend runs am instrument it verifies that the installed helper is actually
ours. It pulls the installed APK(s) and cryptographically verifies their APK
Signature Scheme v2/v3 signature (a pure-JS check — no Android SDK required at
runtime), then compares the signer certificate SHA-256 against the value pinned
in the bundled build manifest. If a different app is squatting the helper's
package name, the signer will not match: the snapshot fails closed with a
trust error and does not silently fall back to uiautomator dump. Generic,
non-trust failures (a churny screen, an am hiccup) still fall back to dump in
auto mode. Use DEVICE_MCP_ADB_SNAPSHOT=instrument for a fully fail-closed
mode, or DEVICE_MCP_ADB_SNAPSHOT=dump to skip the helper entirely.
The helper APK is normally built for you by prepack and (for releases) in CI.
You only need this if you are developing the helper or want the helper-based
snapshot path to work against a local build.
Requirements: JDK 17, Android SDK with platforms/android-36 and
build-tools;36.0.0 (auto-discovered from $ANDROID_HOME / $ANDROID_SDK_ROOT
/ ~/Library/Android/sdk).
The helper is signed with a shared key so the on-device trust check accepts your local build. Obtain the keystore from the team vault, place it outside the repo, point the build at it, and build:
export DEVICE_MCP_HELPER_KEYSTORE=~/.device-mcp/helper.keystore
export DEVICE_MCP_HELPER_KEYSTORE_PASSWORD=<from vault>
export DEVICE_MCP_HELPER_KEY_ALIAS=device-mcp-helper
yarn build:android-helper
The APK and its .manifest.json land in dist/android/. The MCP server
installs the APK on demand and verifies its signing certificate before use.
Don't have the keystore? You can still develop everything except the
helper-signed path: the build script auto-generates a throwaway key when
DEVICE_MCP_HELPER_KEYSTORE is unset. A throwaway-signed APK will not match
the pinned signer, so the helper path rejects it by design — run snapshots via
DEVICE_MCP_ADB_SNAPSHOT=dump.
Never commit a keystore.
*.keystore,*.jks,*.p12, and~/.device-mcp/are gitignored.
keytool -genkeypair -v -keystore device-mcp-helper.keystore \
-alias device-mcp-helper -keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=Device MCP Snapshot Helper, OU=device-mcp, O=MetaMask, C=US"
Store the keystore in the team vault for developers. For CI, add repository
secrets ANDROID_HELPER_KEYSTORE_B64 (base64 -i device-mcp-helper.keystore),
ANDROID_HELPER_KEYSTORE_PASSWORD, and ANDROID_HELPER_KEY_ALIAS. Then pin the
certificate SHA-256 that the build emits (signerSha256 in the manifest) as the
runtime trust anchor.
Beyond native UI automation (idb/adb), the server can speak Chrome DevTools Protocol (CDP) directly to the React Native Hermes JS runtime of a running app via Metro's inspector proxy. This lets an agent evaluate JavaScript, inspect runtime state, and diagnose the app's JS layer — complementing the native tools. Works on both iOS and Android (the transport is identical HTTP/WebSocket to Metro; only the default appId differs).
This is React Native Hermes CDP via Metro's inspector proxy — the app's Hermes runtime connects out to Metro; Metro exposes debuggable targets over http://localhost:<metroPort>/json and a per-target webSocketDebuggerUrl, and the server speaks real CDP over that WebSocket. It targets the React Native JS engine (no DOM). To drive the DOM of a web page inside an in-app WebView, use the separate webview_cdp tool instead — and note this is not the iOS WebKit Inspector Protocol.
/json returns []).WebSocket API: Node 22+ works out of the box; Node 20 requires launching with NODE_OPTIONS="--experimental-websocket" (see Requirements).| Tool | Description |
|---|---|
hermes_cdp | Speak raw Chrome DevTools Protocol to the React Native Hermes JS runtime. |
hermes_targets | List and diagnose the debuggable Hermes targets exposed by Metro. |
Example hermes_cdp call: method Runtime.evaluate with params {"expression":"1+1","returnByValue":true} — the raw response nests the value at result.result.value.
A single Metro can have multiple apps/simulators registered, so executing CDP against the wrong target could run code in the wrong workspace. The server applies five fail-closed checks before executing:
appId match — only targets whose appId equals the expected id (no substring, no targets[0] fallback).HermesInternal identity probe — evaluates HermesInternal.getRuntimeProperties() before the user's method; non-Hermes targets fail closed.reactNative.logicalDeviceId on first success; later calls are filtered to the pin.ws:, hostname must be loopback, and port must equal the resolved Metro port.The synthetic legacy page (React Native Experimental (Improved Chrome Reloads)) is filtered out, and destructive methods Runtime.terminateExecution and Inspector.detached are blocked.
io.metamask.MetaMask (iOS) / io.metamask (Android). Override globally via the HERMES_APP_ID env var or per-call via the tool's appId param. Android users not on the default must pass appId or set HERMES_APP_ID; hermes_targets with all: true aids discovery of the real appId.8081. Override via the HERMES_METRO_PORT env var or per-call via the tool's metroPort param.Separately from Hermes, the server can speak CDP to a debuggable in-app Android WebView — the Chromium web page inside an app's in-app browser. Where hermes_cdp reaches the React Native JS engine (no DOM), webview_cdp reaches the page's DOM and exposes the full Chrome surface: Runtime, DOM, Page, Network, Input.
Use it to click buttons, fill inputs, or read values on a web page rendered inside the app. For the app's native UI (e.g. a MetaMask signature confirmation sheet), use the native device_* tools — a typical dapp flow alternates between the two.
WebView.setWebContentsDebuggingEnabled(true) (debug builds usually do). The socket appears as webview_devtools_remote_<pid>.WebSocket API: Node 22+ works out of the box; Node 20 requires NODE_OPTIONS="--experimental-websocket".| Tool | Description |
|---|---|
webview_cdp | Speak raw CDP to a debuggable in-app Android WebView — the DOM of the app's in-app browser. |
Example: method Runtime.evaluate with params {"expression":"document.querySelector('#submit').click()","returnByValue":true}. Pass urlFilter to select a specific page when several WebViews are open.
The server enumerates the WebView's abstract debug socket from /proc/net/unix, forwards it to an ephemeral local TCP port with adb forward, discovers the page target via http://localhost:<port>/json/list, speaks CDP over the page's webSocketDebuggerUrl, and removes the forward automatically after the call. When a debuggable WebView is open, device_context also lists a WEBVIEW context.
ws:, hostname must be loopback, port must equal the forwarded local port.Browser.close, Target.closeTarget, Target.disposeBrowserContext, Browser.crashGpuProcess.device_screenshot and device_screen_recording write image/video files to disk. These artifacts can contain sensitive on-screen content (seed phrases, private keys, balances), so writes are hardened:
outputPath — the file is written into a private, per-process temporary directory (0700) with an unpredictable name, rather than a predictable /tmp/device-mcp-*-<timestamp> path (which is exposed to symlink and information-disclosure attacks on shared hosts).outputPath — the path is resolved to an absolute path and written owner-only (0600); a symlink already present at the destination is never followed.DEVICE_MCP_OUTPUT_DIR — set this to confine every caller-supplied outputPath to a single directory. Any path resolving outside it is rejected. Leave it unset to allow writing to any path the server process can access.Add to ~/.config/opencode/opencode.json:
{
"mcp": {
"device": {
"type": "local",
"command": ["npx", "-y", "@metamask/device-mcp"]
}
}
}
IDB and ADB are auto-discovered from standard install locations. No PATH override needed unless tools are installed in custom directories.
Add to .cursor/mcp.json in your project root:
{
"mcpServers": {
"device": {
"command": "npx",
"args": ["-y", "@metamask/device-mcp"]
}
}
}
Add to .claude/settings.json in your project root:
{
"mcpServers": {
"device": {
"command": "npx",
"args": ["-y", "@metamask/device-mcp"]
}
}
}
Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"device": {
"command": "npx",
"args": ["-y", "@metamask/device-mcp"],
"env": {
"DEVICE_ID": "<optional-device-id>"
}
}
}
}
@metamask/device-mcp
├── src/
│ ├── index.ts # Entry point — lazy backend, stdio MCP server
│ ├── server.ts # MCP server — registers 29 tools
│ ├── backends/
│ │ ├── types.ts # DeviceBackend interface
│ │ ├── idb-backend.ts # iOS local — IDB commands + simctl fallback
│ │ ├── adb-backend.ts # Android local — ADB commands + XML parser
│ │ ├── appium-backend.ts # Remote — Appium/BrowserStack via W3C WebDriver
│ │ ├── webdriver-client.ts # Minimal W3C WebDriver HTTP client (fetch)
│ │ ├── session-file.ts # .device-session file reader
│ │ └── index.ts # createBackend() + createLazyBackend() factory
│ ├── tools/ # One file per MCP tool (28 tools)
│ │ ├── list-devices.ts # device_list_devices — enumerate connected devices
│ │ ├── select-device.ts # device_select_device — choose device for session
│ │ └── ... # snapshot, tap, type, swipe, etc.
│ └── utils/
│ ├── exec.ts # Shell execution wrapper
│ ├── platform.ts # Device discovery (simctl, adb), path resolution
│ └── element.ts # Element search, matching, formatting
yarn build # Compile TypeScript
yarn test # Run tests
yarn lint # Lint everything (ESLint + Prettier + changelog)
yarn lint:fix # Auto-fix lint issues
yarn dev # Watch mode compilation
(MIT OR Apache-2.0)
FAQs
MCP server for mobile device interaction — iOS (IDB), Android (ADB), and Appium/BrowserStack
The npm package @metamask/device-mcp receives a total of 15,658 weekly downloads. As such, @metamask/device-mcp popularity was classified as popular.
We found that @metamask/device-mcp demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 6 open source maintainers collaborating on the project.

Security News
Two compromised GitHub Actions were re-enabled with malicious tags intact, exposing thousands of downstream repositories to Mini Shai-Hulud.

Research
/Security News
A malicious Firefox extension fetches its payload after installation to evade detection, steal Google session cookies, and automate account takeover.

Research
/Security News
The compromise affects MemTensor's MemOS, an open source memory framework for large language models (LLMs) and AI agents. Both npm package @memtensor/memos-cloud-openclaw-plugin and the PyPI package MemoryOS are compromised. They drop cross-platform Go binaries that exfiltrate developer secrets.