ADB MCP Server — Architecture¶
This describes the system as it currently stands: what exists, how a process boots, and how the pieces work together.
Last updated: 2026-08-26
1. Overview¶
An MCP server that exposes Android Debug Bridge (ADB) capabilities to MCP clients (Claude, other agents/IDEs) as typed, documented tools (actions) and resources (readable state).
Goals:
- Every behavior is verifiable without a physical device or emulator.
- New command domains can be added as plugins, by third parties, without forking.
- Operable like a real open-source project: semantic versioning, automated releases, documented architecture, a PR workflow contributors can actually follow.
Non-goals (v1):
- A pure-Python re-implementation of the ADB wire protocol.
- Multi-transport MCP support (HTTP/SSE) — ships
stdioonly. - A generic "run arbitrary shell command" tool in core.
- Per-user or per-identity RBAC. The policy layer governs which tools exist at all on a given server instance, not who is calling it.
Current implementation status (2026-08-26): six modules exist under
src/adb_automation_mcp/modules/: diagnostics (check_adb_available), device_info
(list_connected_devices), connection (restart_adb_server,
connect_device, disconnect_device, restart_adbd_as_root — the last
restarts the device-side adbd daemon as root, adb -s <serial> root,
distinct from restart_adb_server's host-side adb server restart; judged on
adb's message text rather than exit code, mirroring connect_device's own
exit-code-is-useless handling, and categorized destructive since it's a
genuine privilege escalation rather than a data-mutating write — the only
opt-in-gated category this project's policy model currently has for that),
user (get_current_user, dump_user, user_info, list_users,
switch_user, create_user, remove_user, get_user_capabilities — the
last aggregates six separate pm/cmd user capability queries behind one
read-only tool, treating the old pm ones as always-must-succeed and the
newer cmd user ones as independently gracefully-degradable to None on
Android versions that don't recognize them, without swallowing a genuine
device-not-found failure on any of the six), logger (read_logs,
clear_logs, get_log_buffer_size, read_package_logs, start_log_session,
stop_log_session — see the Tool Reference site for exactly what each adb
shell command is and its verified quirks), and system_properties
(get_property, list_properties, get_property_metadata,
set_property — rejects the ctl.*/sys.powerctl control-property
namespaces up front rather than tunneling lifecycle/power operations through
a plain property write; get_property_metadata's SELinux-context/declared-type
fields are best-effort and degrade to None on devices/Android versions that
don't support the underlying getprop -Z capability). stop_log_session is
the first tool that writes to the host filesystem; it's gated by a call-time
local_root check (ADB_AUTOMATION_LOCAL_ROOT) — no default, refuses to run until
an operator sets it.
It passes mypy --strict, ruff,
pytest, and has been verified
end-to-end through a real fastmcp Client call, including against a real
adb install for every module's failure paths except system_properties and
the newer parts of connection/user (restart_adbd_as_root,
get_user_capabilities), whose fixtures are shaped on documented adb/toybox
behavior rather than a live-device capture (no device was available when
they were added) — worth a real-device check before relying on
get_property_metadata, restart_adbd_as_root's exact wording, or
get_user_capabilities's optional fields in production. CI
(.github/workflows/ci.yml) runs lint, type-check, and tests on every push/PR to
main, plus a strict mkdocs build and (on merge to main) a GitHub Pages deploy.
Everything else described below — additional modules, release automation,
emulator integration — is the target shape, not yet built.
Package install/uninstall (2026-08-26): the packages module (previously
list_packages only) gained install_apk, uninstall_package, and
install_existing_for_user. Semantic MCP options (replace_existing,
allow_downgrade, grant_runtime_permissions, allow_test_packages,
force_sdk, user_id, keep_data, version_code) map to adb/pm's own
supported install/uninstall flags — no raw flag string is ever accepted from
a client. install_apk reuses AdbBackend.install's existing
(serial, apk_path, flags: list[str]) contract unchanged; uninstall_package
and install_existing_for_user go through AdbBackend.shell (the same route
list_packages/grant_permission/clear_app_data already use for pm
subcommands) rather than extending AdbBackend.uninstall, since that
primitive's (serial, package, keep_data) signature has no way to express
--user/--versionCode — AdbBackend itself was not changed. Not verified
live (no device was available when these were added) — shaped on documented
adb install/PackageManagerShellCommand output conventions, same caveat as
system_properties/get_user_capabilities above; worth a real-device check
before relying on exact Failure [REASON]/missing-user wording in
production.
2. Component Architecture¶
graph TB
Client["MCP Client<br/>(Claude, IDE, agent)"] -->|stdio / MCP protocol| Server["MCP Server<br/>(FastMCP app)"]
Server --> Registry["Registry<br/>(entry_points discovery)"]
Registry <--> Policy["PolicyEngine<br/>(category rules from config)"]
Registry --> ModDiag["diagnostics module"]
Registry --> ModDevices["device_info module"]
Registry --> ModConn["connection module"]
Registry --> ModUser["user module"]
Registry --> ModLogger["logger module"]
Registry -.-> ModExt["3rd-party module<br/>(separate PyPI package)"]
ModDiag --> Backend["AdbBackend<br/>(Protocol)"]
ModDevices --> Backend
ModConn --> Backend
ModUser --> Backend
ModLogger --> Backend
ModExt -.-> Backend
Backend --> Sub["SubprocessBackend<br/>(production)"]
Backend --> Fake["FakeBackend<br/>(deterministic, tests only)"]
Sub -->|asyncio subprocess exec| ADB["adb binary"]
ADB --> Device["Physical device /<br/>emulator"]
style Fake fill:#2d5,stroke:#333,stroke-dasharray: 4 3
style ModExt fill:#569,stroke:#333,stroke-dasharray: 4 3
style Policy fill:#c73,stroke:#333,stroke-dasharray: 4 3
Nothing above the AdbBackend line knows whether it's talking to a real device or a
fixture — that single seam is what makes "deterministic data for testing, real adb for
production" possible without special-casing tests inside module code.
The Registry ↔ PolicyEngine link is the other seam worth noting: the registry
consults policy before handing a module's tool/resource to FastMCP for
registration, not after. A denied tool is never exposed to the MCP client at all.
3. How a Process Boots¶
sequenceDiagram
participant Py as Python import
participant EP as entry_points
participant Reg as Registry
participant Life as app_lifespan
participant BE as Backend
participant MCP as FastMCP instance
Py->>EP: discover_modules() — entry_points(group="adb_automation_mcp.modules")
EP-->>Py: [ModuleManifest, ...]
Py->>Reg: Registry(policy=PolicyEngine(config))
Py->>MCP: FastMCP("adb-automation-mcp", lifespan=app_lifespan)
Py->>Reg: register_tools(mcp, manifests)
loop for each manifest, each tool fn
Reg->>Reg: policy.is_allowed(module, tool, category)?
alt allowed
Reg->>MCP: mcp.tool()(wrap_with_envelope(fn))
else denied
Reg->>Reg: skip — never registered, never visible to a client
end
end
Py->>MCP: main() calls mcp.run()
MCP->>Life: enter app_lifespan(server)
Life->>BE: construct backend (env var selects Subprocess or Fake)
Life->>Reg: build_services(backend, manifests)
Reg-->>Life: {module_name: service_instance, ...}
Life-->>MCP: yield {"backend": ..., "services": {...}}
Note over MCP: server is now ready — reads MCP requests from stdin,<br/>dispatches to registered tools, writes responses to stdout
Two phases matter here, and they happen in a specific order for a reason:
Phase 1 — import time, static, no backend involved. discover_modules() walks
Python entry_points for group adb_automation_mcp.modules and loads every registered
ModuleManifest. A PolicyEngine is built from config/env. A Registry is
constructed with that policy, and register_tools runs immediately — every tool
function gets a policy check and, if allowed, gets wrapped and handed to the
FastMCP instance. None of this needs a backend; it's pure wiring.
Phase 2 — process start, per-run, backend-dependent. mcp.run() starts the stdio
transport loop. Before the first request is served, app_lifespan runs once: it picks
a backend implementation (SubprocessBackend by default, FakeBackend if
ADB_AUTOMATION_BACKEND=fake), then asks the registry to build one service instance per
module, passing every service the same shared backend instance. That
{"backend": ..., "services": {...}} dict is what every tool call accesses for the
life of the process, via Context.lifespan_context.
The split matters: which tools exist is decided once, statically, independent of
which backend is running — a test harness that swaps in FakeBackend sees exactly
the same registered tool set a production run does.
4. Core Concepts¶
Backend¶
AdbBackend is a typing.Protocol — structural typing, no forced inheritance —
defining the primitive operations every module is allowed to depend on. Modules never
call subprocess or know the string "adb" exists; they only see the Protocol.
classDiagram
class AdbBackend {
<<Protocol>>
+list_devices() list~DeviceInfo~
+shell(serial, command) CommandResult
+exec_out(serial, command) ExecOutResult
+install(serial, apk_path, options) CommandResult
+uninstall(serial, package, keep_data) CommandResult
+push(serial, local_path, remote_path) CommandResult
+pull(serial, remote_path, local_path) CommandResult
}
class SubprocessBackend {
-adb_path: str
-timeout_s: float
+... implements via asyncio subprocess
}
class FakeBackend {
-devices: list~DeviceInfo~
+... implements via in-memory state
}
AdbBackend <|.. SubprocessBackend
AdbBackend <|.. FakeBackend
CommandResult (stdout, stderr, exit_code, duration_ms) is returned uniformly
by both implementations, so module code and tests assert against the same shape
regardless of which backend is running underneath. exec_out is the one exception:
it returns ExecOutResult, identical but with stdout as raw bytes (not decoded),
for binary output such as screencap -p — see ADR-020.
Service¶
Each module's domain logic lives in a service class — e.g. DiagnosticsService —
constructed once at server startup with the shared backend instance
(service_factory(backend), called by the registry). It owns command construction,
output parsing, and domain-specific exceptions; it's the only layer that knows what
adb's output actually means, as opposed to how to mechanically invoke adb.
Tool¶
A thin, typed, documented, module-level async function — never a closure. It takes
a Context (for reaching services) plus whatever typed parameters the tool needs,
delegates essentially immediately to the service, and is tagged with
@category("read" | "write" | "destructive").
Registry¶
The central wiring component (registry.py). It:
- discovers module manifests via
entry_points, - asks the
PolicyEnginewhether each declared tool is allowed, at registration time, - wraps allowed tools with the response-envelope translator,
- registers the wrapped function with the
FastMCPinstance, - and, once per process (in the lifespan), builds one service instance per module from the shared backend.
Policy Engine¶
Decides, once per server instance at registration time, which declared tools actually
get exposed to the client — based on a category default posture (read/write
allowed, destructive denied, unless configured otherwise) plus explicit
allow/deny lists by tool name. A denied tool simply doesn't exist as far as the client
(or the LLM driving it) can tell — it isn't listed, so there's no "permission denied"
round trip to reason about.
Response Envelope¶
ToolResponse[T] / ToolError — Pydantic models. Every tool call returns the same
four-key shape:
{
"status": "success | error",
"message": "one-line, human/agent-readable summary — always present",
"data": "tool-specific payload on success, else null",
"error": "present only when status is \"error\""
}
Backend and module code never construct this directly — they return data or raise a
typed AdbError subclass, and a single wrapper at the registry boundary
(wrap_with_envelope) converts whichever happened into the envelope. No tool
deviates from this — take_screenshot saves a PNG to local_root and returns its
path in the ordinary envelope (ADR-022; an @image_content carve-out briefly
existed under ADR-020 and was reverted).
5. How They Work Together — A Tool Call¶
sequenceDiagram
participant Client as MCP Client (agent)
participant MCP as FastMCP
participant Wrap as Registry wrapper
participant Fn as Tool function
participant Svc as Service
participant BE as AdbBackend
Client->>MCP: call_tool("check_adb_available", {})
MCP->>Wrap: invoke wrapped function
Wrap->>Fn: fn(ctx)
Fn->>Svc: services["diagnostics"].check_adb_available()
Svc->>BE: backend.list_devices()
alt success
BE-->>Svc: list[DeviceInfo]
Svc-->>Fn: AdbAvailability(...)
Fn-->>Wrap: raw data
Wrap-->>MCP: ToolResponse(status="success", data=..., message=...)
else domain failure (AdbError raised)
BE--xSvc: raise AdbUnavailableError(...)
Svc--xWrap: propagates (or is caught and turned into data — service's choice)
Wrap-->>MCP: ToolResponse(status="error", error={code, details, remediation, retryable})
else unexpected exception
Fn--xWrap: raise (uncaught)
Wrap->>Wrap: log full traceback server-side
Wrap-->>MCP: ToolResponse(status="error", error.code="INTERNAL_ERROR", generic message)
end
MCP-->>Client: CallToolResult (isError: false in every branch above)
Every branch produces a normal, isError: false MCP result — the client always gets
the envelope. Whether a service catches a backend exception and turns it into data
(as DiagnosticsService.check_adb_available does — "adb unreachable" is a legitimate
available: false answer) or lets it propagate up to become an error response (the
default — most failures genuinely are failures) is a per-service, per-method choice.
6. Module Internal Layering¶
Every module is structured in three layers, each with exactly one reason to change:
graph LR
Tool["Tool/Resource function<br/>(thin, typed, documented, module-level)"] --> Svc["Domain service class<br/>(command construction, output parsing, domain exceptions)"]
Svc --> BE["AdbBackend<br/>(mechanical execution, transport-level errors only)"]
# modules/diagnostics/service.py
class DiagnosticsService:
def __init__(self, backend: AdbBackend) -> None:
self._backend = backend
async def check_adb_available(self) -> AdbAvailability:
...
# modules/diagnostics/tools.py
@category("read")
async def check_adb_available(ctx: Context) -> AdbAvailability:
"""..."""
services = cast("dict[str, object]", ctx.lifespan_context["services"])
diagnostics = cast(DiagnosticsService, services["diagnostics"])
return await diagnostics.check_adb_available()
# modules/diagnostics/manifest.py
MODULE = ModuleManifest(
name="diagnostics",
service_factory=DiagnosticsService,
tools=[check_adb_available],
resources=[],
)
The tool function's body is essentially a one-line delegate call — almost redundant
with its own docstring. All the actual logic lives in the service class, which is
directly unit-testable with zero MCP/registry machinery involved:
DiagnosticsService(FakeBackend(...)).check_adb_available() in a plain pytest test.
@category(...) is a transparent marker decorator — it sets fn.__adb_category__
and returns the identical function object, nothing wrapped. The registry's own
envelope-wrapping (functools.wraps) preserves that marker automatically when it
wraps the function for registration.
7. Tools vs. Resources¶
Heuristic applied consistently across modules:
Tool — if it takes meaningful parameters, has side effects, or is not safely re-invocable at will. Resource — if it's an idempotent, addressable, "GET-like" piece of device state a client might want to read/re-read/cache.
Every resource is implicitly category read. Tools are tagged individually — the
category is what the policy layer filters on. Registry.register_resources wires
manifest resources into FastMCP via mcp.resource(uri), wrapped by
wrap_resource (a lighter-weight, envelope-free error handling path) rather
than wrap_with_envelope — implemented and exercised (tests/unit/test_registry.py,
tests/e2e/'s register_resources call), but no module currently registers a
resource through it.
adb://devices was originally planned as a resource; it shipped instead as
the list_connected_devices tool in the device_info module (kept out of
diagnostics, which only reports on adb-connection health and never mutates
anything — introspecting individual devices, restarting the server, or connecting
to one over TCP each belong to a different module).
Reason: not every MCP client surfaces resources to the model as readily as it
surfaces tools — confirmed directly, adb://devices worked when read from Claude
Code but Claude Desktop couldn't read it at all. The tool form is the safe default
until a client-compatible way to offer both is worth the duplication; the resource
machinery stays in place for a future read where re-read/cache semantics
genuinely matter more than universal client support.
8. Repository Layout¶
adb-automation-mcp/
├── pyproject.toml
├── uv.lock
├── mkdocs.yml # docs site config — nav, theme, mkdocstrings
├── README.md # short pitch + link to the docs site; not the manual
├── docs/ # docs_dir for mkdocs — served at the Pages URL
│ ├── index.md # docs site home page (the old README content lives here)
│ ├── ARCHITECTURE.md # this file — current-state description
│ ├── reference/ # one page per module, mkdocstrings-generated from tools.py
│ └── integrations/ # per-MCP-client setup guides
├── LICENSE
├── .github/
│ └── workflows/
│ └── ci.yml # lint, type-check, tests, docs build/deploy — on every push/PR
├── src/
│ └── adb_automation_mcp/
│ ├── __main__.py
│ ├── server.py # builds FastMCP app, lifespan, runs registry
│ ├── registry.py # entry_points discovery, policy-filtered registration, envelope wrapping
│ ├── policy.py # PolicyEngine
│ ├── errors.py # AdbError hierarchy
│ ├── responses.py # ToolResponse / ToolError
│ ├── backend/
│ │ ├── protocol.py # AdbBackend Protocol, CommandResult, DeviceInfo
│ │ ├── subprocess_backend.py
│ │ └── testing.py # FakeBackend
│ └── modules/
│ ├── diagnostics/ # service.py, tools.py, manifest.py
│ ├── device_info/ # service.py, tools.py, manifest.py
│ ├── connection/ # service.py, tools.py, manifest.py
│ ├── user/ # service.py, tools.py, manifest.py
│ ├── logger/ # service.py, tools.py, manifest.py
│ └── system_properties/ # service.py, tools.py, manifest.py
└── tests/
├── meta/ # Layer 0 — registry contract (typing + docstrings)
├── unit/ # Layer 1, per module
└── e2e/ # Layer 3 — protocol-level, real fastmcp.Client
Single distribution (not a uv workspace of many packages) — built-in modules are
subpackages of adb_automation_mcp, registered through the same entry_points mechanism a
third-party plugin would use.
9. Testing Layers¶
graph TB
subgraph Pyramid[" "]
direction TB
A["E2E / Integration (CI, emulator-gated)<br/>real device via SubprocessBackend<br/>few, slow"]
B["Protocol-level E2E<br/>real MCP client ↔ FastMCP server, FakeBackend"]
C["Contract tests<br/>same suite run against FakeBackend AND SubprocessBackend"]
D["Unit / behavior tests<br/>per-module service class, FakeBackend<br/>many, fast"]
E["Registry contract (meta-tests)<br/>every registered tool: fully typed + documented<br/>fastest, runs first"]
end
E --> D --> C --> B --> A
Layer 0 — Registry contract. Introspects every tool actually registered and
asserts full typing and a complete docstring, including a worked example. Needs no
backend, no event loop — just the live registry. Implemented (tests/meta/).
Layer 1 — Unit/behavior tests. Target a module's domain service class directly
against FakeBackend — no MCP registration or event-loop server startup involved.
Implemented (tests/unit/).
Layer 2 — Contract tests. A shared test suite written once against the
AdbBackend Protocol, parametrized over [FakeBackend, SubprocessBackend], verifying
transport-level exception parity between them. Not yet implemented.
Layer 3 — Protocol-level E2E. Tests that speak actual MCP protocol to a running
FastMCP server instance, backed by FakeBackend, catching registration/schema/
serialization bugs unit tests can't see — a real bug this layer caught on its first
day: the adb://devices resource function returned list[ConnectedDevice] (pydantic
models) directly, which Layer 0/1 tests never exercised through fastmcp's actual
resource-read/serialization path, so they passed while every real read of the
resource raised TypeError: Object of type ConnectedDevice is not JSON
serializable. Implemented (tests/e2e/), using fastmcp.Client(mcp) in-memory
against a Registry-wired server backed by FakeBackend.
Layer 4 — CI emulator integration. Runs the Layer 2 SubprocessBackend contract
tests plus a smoke subset of Layer 3 against a real Android emulator in CI. Not yet
implemented.
10. CI/CD¶
Current status: .github/workflows/ci.yml has two jobs: test (ruff check →
mypy --strict → pytest, Layer 0 + Layer 1 + Layer 3, one Python version) and
docs (mkdocs build --strict on every push/PR; mkdocs gh-deploy additionally, only
on push to main). That's the entire pipeline that exists right now — folded into
ci.yml rather than a separate docs.yml, since one extra job isn't yet enough to
justify a second workflow file.
Target shape:
flowchart LR
subgraph PR["Pull Request"]
P0["registry meta-tests"] --> P1["ruff"] --> P2["mypy --strict"] --> P3["unit + contract(fake) + E2E tests"] --> P4["emulator smoke subset"] --> P5["mkdocs build --strict"]
end
subgraph Main["Merge to main"]
M1["full test suite<br/>(full Python matrix)"] --> M2["coverage report"] --> M3["mkdocs gh-deploy"]
end
subgraph Nightly["Nightly (scheduled)"]
N1["full matrix"] --> N2["full emulator integration"]
end
subgraph Release["Release (on main, via Conventional Commits)"]
R0["full matrix + full emulator gate"] --> R1["python-semantic-release"] --> R2["uv build"] --> R3["PyPI publish"] --> R4["GitHub Release + CHANGELOG.md"]
end
PR --> Main
Main --> Release
Nightly -.->|failure pages, doesn't block merges| Main
nightly.yml and release.yml don't exist yet — each gets added once the
infrastructure it depends on (contract/E2E/emulator tests, semantic-release config)
actually exists, not ahead of it. The mkdocs build/deploy steps this diagram shows as
P5/M3 are implemented, currently as a docs job inside ci.yml rather than a
standalone docs.yml.
Versioning: SemVer, driven by Conventional Commits (fix:, feat:,
feat!:/BREAKING CHANGE:) once python-semantic-release is wired up.
Publishing (planned): PyPI trusted publishing (OIDC from GitHub Actions).
Living documentation — sections are updated in place as the system changes, not appended to.