YA

yhuangsh/adbmcp

Developer tools
0 stars 0 forks Quality 83 Trend 20MIT

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 device serial and every adb call is scoped with adb -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 adbmcp as a subprocess; --http serves the same tools over /mcp for 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_TOKEN is refused at startup; the error explains the two ways forward (set a token, or bind 127.0.0.1).
  • Token auth is pre-MCP. With a token configured, missing/incorrect credentials get 401 before any MCP session handling; comparison is constant-time (node:crypto timingSafeEqual).
  • DNS-rebinding protection. The SDK transport rejects requests whose Host header is not the bound host or a loopback alias (localhost, 127.0.0.1, ::1) on the actual port. Override with ADBMCP_HTTP_ALLOWED_HOSTS when a reverse proxy forwards a different hostname.
  • One device host, many agents. shell is available to every session; on a shared host prefer ADBMCP_ALLOWED_DEVICES and ADBMCP_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_HOSTS to the forwarded Host value.
  • Only /mcp is served; POST bodies are capped at 1 MiB (413 above that), unsupported methods get 405, and unknown paths get 404.

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; adbmcp enforces its own limits regardless: blocked shell patterns, the device allowlist, remote push roots, and the pull overwrite check.
  • shell is 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 — keep shell disabled or prefix-restricted (ADBMCP_SHELL_ALLOWED_PREFIXES) on shared or CI machines. The default blocklist covers reboot/power-state changes, su, mkfs, wipe, rm of / (including split/reordered flags and the /* glob), and pipelines into sh/bash such as curl … | sh or … | 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 like echo "use curl | sh to install" is refused — precision always yields to safety here. maintainer can replace the patterns with ADBMCP_SHELL_BLOCKED_PATTERNS.
  • uninstall_package deletes app data unless keep_data: true. clear_app_data deletes app data permanently and is refused unless the package is listed in ADBMCP_CLEAR_ALLOWED_PACKAGES (unset/empty refuses every clear). grant_permission / revoke_permission change an app’s granted permissions. All four are marked destructiveHint: true.
  • Device scoping is explicit. With multiple devices connected, calls without device fail with MULTIPLE_DEVICES and the candidate serials instead of guessing.
  • Filesystem writes are bounded. pull_file never overwrites silently; push_file refuses 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. adbmcp never runs adb root/su itself and never retries a permission failure with elevation.
  • The adb server is shared host state. adbmcp never kills or restarts it implicitly; adb_server_restart exists 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).

View this README on GitHub

Install

npx -y adbmcp

Configuration

{ "mcpServers": { "adb": { "command": "npx", "args": ["-y", "adbmcp"], "env": { "ADB_PATH": "/Users/me/Library/Android/sdk/platform-tools/adb" } } } }