MCP server exposing the Android Debug Bridge (adb) as tools, resources and prompts for AI assistants
Overview
adbmcp is a Model Context Protocol server that exposes the Android Debug Bridge (adb) to AI agents: list devices, run shell commands, install apps, read logcat, take screenshots, drive the UI (tap/swipe/type/key events), query system services with dumpsys, transfer files, forward ports, manage app permissions and pair/connect to devices over Wi-Fi. It is a thin, predictable wrapper around the official adb binary — no re-implemented adb protocol, no test framework, no extra state. - — every device-scoped tool takes an optional device serial and every adb call is scoped with adb -s ; ambiguity is an error listing the candidates. - — adb stderr/exit codes are mapped to typed causes (NO_DEVICE, MULTIPLE_DEVICES, DEVICE_UNAUTHORIZED, DEVICE_OFFLINE, ADB_SERVER_CONFLICT, TIMEOUT, COMMAND_FAILED) with a hint for the next step. - — full MCP annotations on every tool plus server-side guardrails that are enforced even if a host ignores annotations.
README
adbmcp — Android Debug Bridge, as MCP tools
adbmcp is a Model Context Protocol server that exposes the
Android Debug Bridge (adb) to AI agents: list devices, run shell commands, install apps, read
logcat, take screenshots, drive the UI (tap/swipe/type/key events), query system services with
dumpsys, transfer files, forward ports, manage app permissions and pair/connect to devices over
Wi-Fi. It is a thin, predictable wrapper around the official
adb binary — no re-implemented adb protocol, no test framework, no extra state.
- Multi-device first — every device-scoped tool takes an optional
deviceserial and every adb call is scoped withadb -s; ambiguity is an error listing the candidates. - Failures an agent can act on — adb stderr/exit codes are mapped to typed causes
(
NO_DEVICE,MULTIPLE_DEVICES,DEVICE_UNAUTHORIZED,DEVICE_OFFLINE,ADB_SERVER_CONFLICT,TIMEOUT,COMMAND_FAILED) with a hint for the next step. - Safety by construction — full MCP annotations on every tool plus server-side guardrails that are enforced even if a host ignores annotations.
- stdio by default, Streamable HTTP optional — the client launches
adbmcpas a subprocess;--httpserves the same tools over/mcpfor shared-device-host deployments.
Implements the MVP plus v2.0 items 1–7 in docs/design/product-design-v0.md.
Install
Requires Node.js >= 20 and Android platform-tools (adb) on PATH — or ADB_PATH pointing at the
binary.
# from npm
npx -y @yhuangsh/adbmcp
# from a checkout
git clone https://github.com/yhuangsh/adbmcp.git && cd adbmcp
npm install
npm run build
node dist/index.js # speaks MCP on stdin/stdout
Claude Desktop (claude_desktop_config.json)
{
"mcpServers": {
"adb": {
"command": "npx",
"args": ["-y", "adbmcp"],
"env": {
"ADB_PATH": "/Users/me/Library/Android/sdk/platform-tools/adb"
}
}
}
}
Cursor (.cursor/mcp.json)
{
"mcpServers": {
"adb": {
"command": "node",
"args": ["/absolute/path/to/adbmcp/dist/index.js"],
"env": {
"ADBMCP_ALLOWED_DEVICES": "emulator-5554"
}
}
}
}
Other stdio clients work the same way: a command plus args, with ADB_PATH / ADBMCP_*
environment variables passed through. In stdio mode, stdout carries MCP protocol traffic only;
diagnostics go to stderr.
Streamable HTTP (shared device host)
Use HTTP when several agents or MCP clients should share one machine’s adb server, devices and
allowlist — an emulator farm, a lab phone, or a remote desktop — instead of each client launching its
own subprocess. stdio stays the default: with no flags, adbmcp speaks MCP on stdin/stdout.
node dist/index.js --http # http://127.0.0.1:3331/mcp
ADBMCP_HTTP_TOKEN=secret node dist/index.js --http --host 0.0.0.0 --port 8080
| Flag / variable | Default | Meaning |
|---|---|---|
--http |
off | Enable the Streamable HTTP transport. Without it the server is stdio-only. |
--port, ADBMCP_HTTP_PORT |
3331 |
Listen port (--port wins over the env var). 0 asks the OS for an ephemeral port; the real URL is logged to stderr. |
--host, ADBMCP_HTTP_HOST |
127.0.0.1 |
Bind address (--host wins over the env var). |
ADBMCP_HTTP_TOKEN |
unset | When set, every /mcp request must send Authorization: Bearer . Unset is allowed only on a loopback host. |
ADBMCP_HTTP_ALLOWED_HOSTS |
derived | Comma-separated exact Host header values (host[:port]) for DNS-rebinding protection. Overrides the derived loopback/bound-host list. |
The endpoint is stateful: initialize creates a session, Mcp-Session-Id identifies it, and
DELETE /mcp closes it. All sessions share one device host, one ADBMCP_ALLOWED_DEVICES allowlist
and one logcat stream registry — streams started in one session can be read from another. Sessions
are the same trust domain, not isolation between agents.
Client config (Claude Desktop, Cursor, or any Streamable HTTP client):
{
"mcpServers": {
"adb": {
"type": "http",
"url": "http://localhost:3331/mcp"
}
}
}
When ADBMCP_HTTP_TOKEN is set, add "headers": { "Authorization": "Bearer " } to the
server entry (client support for header config varies).
Security notes:
- Loopback by default. Binding a non-loopback host without
ADBMCP_HTTP_TOKENis refused at startup; the error explains the two ways forward (set a token, or bind127.0.0.1). - Token auth is pre-MCP. With a token configured, missing/incorrect credentials get
401before any MCP session handling; comparison is constant-time (node:cryptotimingSafeEqual). - DNS-rebinding protection. The SDK transport rejects requests whose
Hostheader is not the bound host or a loopback alias (localhost,127.0.0.1,::1) on the actual port. Override withADBMCP_HTTP_ALLOWED_HOSTSwhen a reverse proxy forwards a different hostname. - One device host, many agents.
shellis available to every session; on a shared host preferADBMCP_ALLOWED_DEVICESandADBMCP_SHELL_ALLOWED_PREFIXES, and put the server behind the token. - No in-process TLS. The server speaks plain HTTP; terminate TLS in a reverse proxy for
non-loopback deployments and set
ADBMCP_HTTP_ALLOWED_HOSTSto the forwardedHostvalue. - Only
/mcpis served; POST bodies are capped at 1 MiB (413above that), unsupported methods get405, and unknown paths get404.
Deploying over a VPN mesh (Tailscale example)
A common topology: your agent runs on a public server, and one or more device hosts (machines with USB-attached phones) are joined to a private mesh such as a Tailscale tailnet. Nothing needs to be exposed to the public internet — the agent reaches device hosts through the mesh.
Shape A — bind the mesh interface directly. On each device host, set the token, bind to the
mesh IP (or 0.0.0.0), and allow the hostname the agent will dial:
ADBMCP_HTTP_TOKEN= ADBMCP_HTTP_ALLOWED_HOSTS='phonehost.tailnet-name.ts.net:3331,127.0.0.1:3331,localhost:3331' \
adbmcp --http --host 0.0.0.0 --port 3331
WireGuard encrypts mesh traffic; the bearer token stays as the application-level gate so a
misconfigured ACL or a compromised mesh member still cannot touch your devices. ALLOWED_HOSTS must
contain the exact Host header the agent sends — MagicDNS name and port. Note the override
replaces the derived loopback defaults, so loopback entries are re-added for local health checks.
Shape B — loopback + tailscale serve (nice for always-on hosts). Keep adbmcp bound to
127.0.0.1 (optionally with a token still set), then let Tailscale expose it:
adbmcp --http --host 127.0.0.1 --port 3331 &
tailscale serve --bg --https=443 http://127.0.0.1:3331 # --https=443 is the default mode
Keep the token set even in this shape — tailscale serve terminates TLS and limits reachability to
the tailnet, but adbmcp should still authenticate its clients. Point the agent at
https://phonehost.tailnet-name.ts.net/mcp (tailscale serve passes the original Host header
through, so the portless ADBMCP_HTTP_ALLOWED_HOSTS entry is correct here).
Caveats that apply to both shapes: one adbmcp node per device host (the agent configures one MCP server entry per host — there is no cross-host device registry); sessions live until the client deletes them or the process restarts, so a device host that suspends or redeploys drops its logcat streams and agents must re-initialize; and provision the token to the agent out-of-band (secret manager, not chat).
Tools
device is optional on every device-scoped tool and required when more than one device is connected.
Annotation columns: RO = readOnlyHint, D = destructiveHint, I = idempotentHint,
O = openWorldHint.
| Tool | What it does | RO | D | I | O |
|---|---|---|---|---|---|
list_devices |
adb devices -l: serial, state, model, allowlist status |
✅ | — | ✅ | — |
get_device_info |
Curated build properties (model, Android version, SDK, ABI) + screen size/density | ✅ | — | ✅ | — |
dumpsys_query |
Curated dumpsys dump — battery/activity/window/connectivity, capped at 64 KB |
✅ | — | ✅ | — |
shell |
Arbitrary adb shell command — the power-user escape hatch, guardrailed |
— | — | — | ✅ |
list_packages |
pm list packages, optional substring filter |
✅ | — | ✅ | — |
install_apk |
adb install a host APK, replace for -r |
— | — | — | — |
uninstall_package |
adb uninstall — destructive, keep_data for -k |
— | ✅ | ✅ | — |
grant_permission |
pm grant — destructive: grants a runtime permission |
— | ✅ | ✅ | — |
revoke_permission |
pm revoke — destructive: the app loses the permission |
— | ✅ | ✅ | — |
clear_app_data |
pm clear — destructive, refused unless the package is in ADBMCP_CLEAR_ALLOWED_PACKAGES |
— | ✅ | ✅ | — |
logcat_snapshot |
Bounded adb logcat -d, optionally filtered |
✅ | — | ✅ | — |
start_logcat_stream |
Start a long-lived adb logcat, returns a stream_id |
✅ | — | — | — |
read_logcat_stream |
Drain buffered output since the last read | ✅ | — | — | — |
stop_logcat_stream |
Stop the stream, return unread output | ✅ | — | ✅ | — |
screenshot |
exec-out screencap -p, returned as PNG image content |
✅ | — | ✅ | — |
pull_file |
adb pull to the host; refuses to overwrite unless overwrite: true |
— | — | — | — |
push_file |
adb push; remote path restricted to allowed roots |
— | — | — | ✅ |
forward_port |
adb forward (e.g. tcp:8080 → tcp:8080) |
— | — | ✅ | — |
remove_forward |
adb forward --remove |
— | — | ✅ | — |
reverse_port |
adb reverse — the device listens on remote and forwards to host local |
— | — | ✅ | — |
remove_reverse |
adb reverse --remove (takes the remote spec) |
— | — | ✅ | — |
list_forwards |
adb forward --list as JSON {serial, local, remote} |
✅ | — | ✅ | — |
list_reverses |
adb reverse --list as JSON {serial, remote, local} |
✅ | — | ✅ | — |
connect |
adb connect host:port — TCP/IP wireless without pairing |
— | — | ✅ | ✅ |
disconnect |
adb disconnect [host:port]; no address disconnects every TCP/IP device |
— | — | ✅ | ✅ |
wireless_pair |
adb pair host:pair_port code — Android 11+ pairing, step one of connect |
— | — | ✅ | ✅ |
start_activity |
am start by component or action, wait for -W |
— | — | — | — |
force_stop_app |
am force-stop |
— | — | ✅ | — |
get_ui_hierarchy |
uiautomator dump XML with bounds/ids/text |
✅ | — | ✅ | — |
tap |
input tap x y — tap a screen coordinate (device pixels) |
— | — | — | — |
swipe |
input swipe x1 y1 x2 y2 [duration_ms] — scroll or drag |
— | — | — | — |
type_text |
input text with %s space encoding and device-shell quoting |
— | — | — | — |
key_event |
input keyevent — named key (HOME, BACK, …) or keycode 0–300 |
— | — | — | — |
adb_server_restart |
kill-server + start-server recovery for version conflicts |
— | — | — | — |
Also shipped:
- Resource
adb://devices— live device list as JSON (serial,state,model,allowed). State is never cached; re-read the resource to refresh. - Prompts —
debug_crash,install_and_launch,capture_bug_report: step-by-step orchestrations of the tools above.
uninstall_package, grant_permission, revoke_permission and clear_app_data are the tools with
destructiveHint: true; shell, push_file, connect, disconnect and wireless_pair are the
openWorldHint: true tools. They are never merged with read-only tools, so hosts can auto-approve
reads and confirm writes.
Configuration
| Variable | Default | Meaning |
|---|---|---|
ADB_PATH |
adb (from PATH) |
Path to the adb binary. Must match the version of any running adb server. |
ADBMCP_ALLOWED_DEVICES |
unset (all allowed) | Comma-separated serial allowlist. Operations on other serials are refused. |
ADBMCP_SHELL_BLOCKED_PATTERNS |
reboot, su, mkfs, wipe, rm of / (split/reordered flags and /* included), pipelines into sh/bash |
Comma-separated JavaScript regex sources tested against the whole shell command. Overriding replaces the defaults. |
ADBMCP_SHELL_ALLOWED_PREFIXES |
unset (no prefix restriction) | When set, a shell command must start with one of these comma-separated prefixes. |
ADBMCP_PUSH_ALLOWED_ROOTS |
/sdcard, /storage/emulated/0, /data/local/tmp |
Remote roots push_file may write to. |
ADBMCP_CLEAR_ALLOWED_PACKAGES |
unset (refuse every clear) | Comma-separated package allowlist for clear_app_data. There is no allow-all mode: unset or empty refuses every clear. |
ADBMCP_TIMEOUT_MS |
30000 |
Default subprocess timeout (timeout_ms per call overrides it). |
ADBMCP_MAX_OUTPUT_BYTES |
65536 |
Head+tail byte cap per call; the dropped middle is reported inline. |
ADBMCP_LOGCAT_BUFFER_BYTES |
1048576 |
Ring-buffer cap per logcat stream; overflow is reported as dropped_bytes. |
ADBMCP_HTTP_PORT |
3331 |
HTTP mode listen port (equivalent to --port; the flag wins). |
ADBMCP_HTTP_HOST |
127.0.0.1 |
HTTP mode bind address (equivalent to --host; the flag wins). |
ADBMCP_HTTP_TOKEN |
unset | Bearer token required on every /mcp request when set. Non-loopback binds without it are refused. |
ADBMCP_HTTP_ALLOWED_HOSTS |
derived | Comma-separated exact Host header values accepted by DNS-rebinding protection. |
Safety notes
- Annotations are advisory, guardrails are authoritative. The host decides what to auto-approve;
adbmcpenforces its own limits regardless: blockedshellpatterns, the device allowlist, remote push roots, and the pull overwrite check. shellis arbitrary code execution on the device. It is annotated as not read-only and open-world, blocklisted by default, and documented as the escape hatch — keepshelldisabled or prefix-restricted (ADBMCP_SHELL_ALLOWED_PREFIXES) on shared or CI machines. The default blocklist covers reboot/power-state changes,su,mkfs,wipe,rmof/(including split/reordered flags and the/*glob), and pipelines intosh/bashsuch ascurl … | shor… | sudo bash. This is a guardrail, not a sandbox: a deliberately obfuscated command can still get through. A pattern-based guardrail: matching runs on the raw command text, including inside quotes, so even prose likeecho "use curl | sh to install"is refused — precision always yields to safety here. maintainer can replace the patterns withADBMCP_SHELL_BLOCKED_PATTERNS.uninstall_packagedeletes app data unlesskeep_data: true.clear_app_datadeletes app data permanently and is refused unless the package is listed inADBMCP_CLEAR_ALLOWED_PACKAGES(unset/empty refuses every clear).grant_permission/revoke_permissionchange an app’s granted permissions. All four are markeddestructiveHint: true.- Device scoping is explicit. With multiple devices connected, calls without
devicefail withMULTIPLE_DEVICESand the candidate serials instead of guessing. - Filesystem writes are bounded.
pull_filenever overwrites silently;push_filerefuses writes outside the allowed remote roots and rejects..traversal and relative paths. Root containment is lexical: it does not resolve on-device symlinks, so a link pre-created inside an allowed root (e.g./data/local/tmp/x -> /data/system) can still redirect a push outside it. - No privilege escalation.
adbmcpnever runsadb root/suitself and never retries a permission failure with elevation. - The adb server is shared host state.
adbmcpnever kills or restarts it implicitly;adb_server_restartexists for the version-conflict case and says so in its description. - Logcat streams are cleaned up. Streams are killed on
stop_logcat_stream, and all remaining streams are terminated on SIGINT/SIGTERM (server shutdown).
Development
npm install
npm run build # tsc -> dist/
npm test # vitest, no adb binary or device required
npm run typecheck
Tests drive a fake adb runner and a fake stream spawner, so the whole suite runs without platform-tools, a device or an emulator. Layout:
src/
index.ts entrypoint: stdio by default, --http for Streamable HTTP
http.ts HTTP sessions, bearer auth, loopback guard, DNS-rebinding allowlist
server.ts assembles config + runner + tools/resources/prompts
config.ts env config, shell/device/push/clear guardrails
devices.ts device parsing, resolution rule, list_devices tool
adb/runner.ts subprocess per call: -s scoping, timeouts, head+tail caps
adb/errors.ts typed causes mapped from adb stderr/exit codes
streams/registry.ts long-running logcat handles + ring buffer
tools/ one module per tool group (34 tools, MVP + v2.0 items 1–6)
resources/devices.ts, prompts/index.ts
test/ vitest suites (fakes in test/helpers/)
Status
Implements the MVP plus v2.0 items 1–7 from the design (§4.2): reverse port forwards and structured
forward/reverse listings, pm grant/revoke plus allowlist-gated clear_app_data, wireless
TCP/IP connect/disconnect/wireless_pair, first-class input injection (tap, swipe,
type_text, key_event — typed schemas that fix the input text quoting footgun, no DSL), the
curated dumpsys_query, and the pulled-forward Streamable HTTP transport. v0.4.0 was a
hardening/polish release (no new tools): shell guardrails now also block pipe-to-shell payloads and
split/globbed root-deleting rm, address-targeted disconnect failures are reported as typed
errors, and reverse_port rejects forward-only device-side specs pre-spawn. v0.5.0 adds Streamable
HTTP (/mcp, stateful sessions, one shared device/stream context, optional bearer auth, loopback
binding guard, DNS-rebinding protection). Not implemented: screenrecord, bugreport, iOS,
emulator lifecycle management. Windows is best-effort (POSIX-first, like most of the ecosystem).
Install
npx -y adbmcpConfiguration
{
"mcpServers": {
"adb": {
"command": "npx",
"args": ["-y", "adbmcp"],
"env": {
"ADB_PATH": "/Users/me/Library/Android/sdk/platform-tools/adb"
}
}
}
}