qa-mcp-server
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@qa-mcp-server open the notes app on my emulator and tap the Settings button"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
qa-mcp-server
An MCP server that gives AI agents (Claude Desktop, Claude Code, any MCP client) ten Android QA tools over adb, with strict input validation, a command allowlist and an audit log, so an agent can explore an app without getting a shell.
Why
LLM agents can now do useful exploratory testing: read a screen, try a flow,
notice a crash in logcat and write it up. The quick way to wire that up is to
give the agent adb shell. That also gives a shell to anyone who can put text
in front of the agent, through the app under test, a log line or a web page.
This server takes the other route. It exposes narrow, typed tools
(dump_ui, tap, logcat, ...) and enforces, below the agent, what may run:
every argument is validated (pydantic and the published JSON schema);
every adb argv must match one of 13 allowlisted command shapes, checked right before execution;
processes start with
shell=Falseand an argument list, under a timeout and an output cap;values that reach the device shell are restricted to a metacharacter-free charset, because
adb shellre-joins arguments into a device-side shell command;APKs install only from a sandboxed directory; test suites run only by name, as configured by the operator;
a dry-run mode plans state-changing commands without executing them;
every call (accepted, rejected or failed) goes to a JSONL audit log with the exact argv.
The threat model is in docs/SECURITY.md.
Related MCP server: android-mcp
How it works
flowchart LR
Agent["AI agent<br/>(Claude Desktop / Claude Code /<br/>qa-mcp-server agent + Ollama)"]
subgraph Server["qa-mcp-server (FastMCP, stdio or HTTP on loopback)"]
Audit["AuditedFastMCP<br/>JSONL audit of every call"]
Validate["Input validation<br/>pydantic models + JSON schema"]
Service["QAService<br/>device attached? package allowed?<br/>APK path sandbox, suite registry"]
Adb["AdbClient<br/>builds argv lists, parses output"]
Policy{"Allowlist<br/>13 command shapes<br/>safe device-shell tokens"}
end
Real["SubprocessTransport<br/>shell=False, timeout, output cap"]
Fake["FakeAdbTransport<br/>scripted device: packages, UI XML,<br/>tap transitions, logcat, PNG"]
Device[("Android device /<br/>emulator")]
Agent -- "tools/call" --> Audit --> Validate --> Service --> Adb --> Policy
Policy -- "real" --> Real --> Device
Policy -- "--fake / tests" --> Fake
Policy -. "rejected" .-> AuditThe fake device sits below the allowlist and speaks the same argv protocol as
adb (devices -l, am start -W, uiautomator dump /dev/tty, threadtime
logcat, screencap -p). Tests and demos therefore exercise the real
validation, policy, argv building and output parsing; only the process call is
replaced.
Tool | What it does | Mutates device |
|
| no |
| install an | yes |
| start a package (launcher resolved automatically), returns | yes |
| PNG as MCP image content, or saved under the artifacts dir | no |
| dump filtered by package (pid), tag, minimum level and time, line- and byte-capped | no |
| uiautomator hierarchy as compact JSON: class, text, resource_id, content_desc, bounds, flags | no |
| by | yes |
| type into the focused field; fails if nothing has focus | yes |
| one key from an allowlist (BACK, HOME, ENTER, DEL, DPAD_*, ...) | yes |
| run an operator-configured suite by name; returns exit code, output tail and JUnit totals | runs host process |
Quick start
git clone https://github.com/Gabnagawo/qa-mcp-server && cd qa-mcp-server
uv sync
uv run python examples/demo_transcript.py # same as: make demoThe demo starts the server as a stdio subprocess with the fake device, runs an exploratory session on a synthetic notes app with a planted crash, then sends a few injection attempts. No device, no API key.
Other entry points (Windows has no make, so each target is shown as a uv run command):
Task | make | uv |
tests + coverage |
|
|
security matrix only |
|
|
latency benchmark |
|
|
LLM agent on the fake device (Ollama) |
|
|
print the adb allowlist |
|
|
run the server (real adb) |
|
Use it from Claude
Copy examples/qa-mcp.example.toml to
qa-mcp.toml and set apk_dir, artifacts_dir and, ideally,
allowed_packages. Add --fake to any command below to try it without a device,
or --dry-run to see what an agent would do before letting it act.
Claude Desktop: edit claude_desktop_config.json, then restart Claude Desktop.
macOS:
~/Library/Application Support/Claude/claude_desktop_config.jsonWindows:
%APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"qa-android": {
"command": "uv",
"args": [
"--directory", "/Users/you/src/qa-mcp-server",
"run", "qa-mcp-server", "serve",
"--config", "/Users/you/src/qa-mcp-server/qa-mcp.toml"
]
}
}
}On Windows, use Windows paths with escaped backslashes, for example
"--directory", "C:\\src\\qa-mcp-server". If uv or adb is not on the PATH
that Claude Desktop sees, give the absolute path to uv.exe in command and set
adb_path in the TOML file.
Claude Code:
claude mcp add qa-android -- uv --directory /path/to/qa-mcp-server run qa-mcp-server serve --config /path/to/qa-mcp.toml
# or over streamable HTTP (loopback only unless --allow-remote):
uv run qa-mcp-server serve --transport http --port 8765 --config qa-mcp.toml
claude mcp add --transport http qa-android http://127.0.0.1:8765/mcpdocs/DEMO.md is a scripted exploratory session on a real F-Droid app (Markor) with the expected sequence of tool calls.
Example output
Real output of uv run python examples/demo_transcript.py (also saved in
docs/results/demo-transcript.txt). The
server runs as a stdio subprocess against the fake device; the times are
client-side round trips. The synthetic app crashes on "Export as PDF", the
crash is found through dump_ui and logcat, and the injection attempts at the
end are rejected before anything runs.
$ uv run python examples/demo_transcript.py
server qa-mcp-server 0.1.0 (stdio, fake device)
10 tools: list_devices, install_apk, launch_app, screenshot, logcat, dump_ui, tap, input_text, press_key, run_test_suite
== exploratory session ==
#1 list_devices() [11.5 ms]
-> online=1 [emulator-5554 (device), FAKE-DEVICE-0001 (offline)]
#2 install_apk(apk_path='sample-notes-1.0.apk') [8.7 ms]
-> installed=True sample-notes-1.0.apk sha256=a9f36ce9e261...
#3 launch_app(package='org.qamcp.sample.notes') [10.3 ms]
-> org.qamcp.sample.notes/.MainActivity status=ok COLD 412 ms
# read the screen before acting
#4 dump_ui() [4.6 ms]
-> 7 nodes; TextView:'Notes', ImageView:'More options', TextView:'No notes yet', FloatingActionButton:'New note'
#5 tap(content_desc='New note') [10.4 ms]
-> tapped (960, 2200) on org.qamcp.sample.notes:id/fab_new_note
#6 tap(resource_id='title_input') [10.6 ms]
-> tapped (540, 405) on org.qamcp.sample.notes:id/title_input
#7 input_text(text='Groceries') [7.1 ms]
-> {"action": "input_text", "detail": "typed 9 characters into org.qamcp.sample.notes:id/title_input", "dry_run": false}
#8 tap(text='Save') [10.9 ms]
-> tapped (886, 2250) on org.qamcp.sample.notes:id/save_button
# verify the note is listed
#9 dump_ui() [4.9 ms]
-> 10 nodes; TextView:'Notes', ImageView:'More options', TextView:'Groceries', TextView:'Note saved', FloatingActionButton:'New note'
#10 logcat(package='org.qamcp.sample.notes', level='W') [13.3 ms]
-> 1 lines
W/StrictMode: StrictMode policy violation; ~duration=18 ms: android.os.strictmode.DiskReadViolation
#11 tap(content_desc='New note') [11.1 ms]
-> tapped (960, 2200) on org.qamcp.sample.notes:id/fab_new_note
# risky action
#12 tap(content_desc='Export as PDF') [11.0 ms]
-> tapped (114, 2250) on org.qamcp.sample.notes:id/export_button
# what happened?
#13 dump_ui() [5.0 ms]
-> 3 nodes; TextView:'Sample Notes keeps stopping', Button:'App info', Button:'Close app'
#14 logcat(tag='AndroidRuntime', level='E') [14.7 ms]
-> 6 lines
E/AndroidRuntime: FATAL EXCEPTION: main
E/AndroidRuntime: Process: org.qamcp.sample.notes, PID: 4242
E/AndroidRuntime: java.lang.NullPointerException: Attempt to invoke virtual method 'java.io.File android.con
E/AndroidRuntime: at org.qamcp.sample.notes.export.PdfExporter.export(PdfExporter.kt:42)
# evidence for the bug report
#15 screenshot(save=True) [4.0 ms]
-> saved qa-mcp-artifacts/demo/screenshots/emulator-5554-20261112T131228116024Z.png (1006 bytes)
#16 tap(text='Close app') [10.6 ms]
-> tapped (540, 1340) on android:id/aerr_close
#17 run_test_suite(name='smoke') [451.0 ms]
-> exit=0 passed=True junit={'tests': 3, 'failures': 0, 'errors': 0, 'skipped': 0} in 0.438s
== a prompt-injected agent tries to escape ==
#18 input_text(text='hi; reboot') [2.2 ms]
-> REJECTED: 1 validation error for InputTextRequest text String should match pattern '^[A-Za-z0-9 @._,:/+=-]{1,500}$' [type=string_pattern_mismatch, input_value='hi; reboot', input_t
#19 launch_app(package='org.qamcp.sample.notes', activity='.Main$(id)') [1.9 ms]
-> REJECTED: 1 validation error for launch_appArguments activity String should match pattern '^\.?[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$' [type=string_pattern_mismatch,
#20 install_apk(apk_path='../../pyproject.toml') [2.6 ms]
-> REJECTED: apk_path escapes the allowed APK directory
#21 press_key(key='POWER') [2.0 ms]
-> REJECTED: 1 validation error for PressKeyRequest Value error, key not allowed: 'POWER'; allowed: ['APP_SWITCH', 'BACK', 'DEL', 'DPAD_CENTER', 'DPAD_DOWN', 'DPAD_LEFT', 'DPAD_RIGHT'
#22 run_test_suite(name='smoke; rm -rf /') [1.8 ms]
-> REJECTED: 1 validation error for RunSuiteRequest name String should match pattern '^[a-z0-9][a-z0-9_-]{0,63}$' [type=string_pattern_mismatch, input_value='smoke; rm -rf /', input_t
#23 logcat(serial='emulator-5554 && reboot') [2.0 ms]
-> REJECTED: 1 validation error for logcatArguments serial String should match pattern '^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$' [type=string_pattern_mismatch, input_value='emulator-5554 &
audit log: 23 tool calls {'ok': 17, 'rejected': 6}, 42 adb/suite commands executed -> qa-mcp-artifacts/demo/audit.jsonlTwo lines from the audit log of that run (qa-mcp-artifacts/demo/audit.jsonl): an
accepted call with the four adb commands it executed, and a rejected one that executed nothing.
{"ts": "2026-11-12T13:12:28.002+00:00", "tool": "launch_app", "arguments": {"package": "org.qamcp.sample.notes"}, "outcome": "ok", "duration_ms": 1.2, "dry_run": false, "commands": [{"rule": "devices", "argv": ["adb", "devices", "-l"], "executed": true}, {"rule": "pm_path", "argv": ["adb", "-s", "emulator-5554", "shell", "pm", "path", "org.qamcp.sample.notes"], "executed": true}, {"rule": "resolve_launcher", "argv": ["adb", "-s", "emulator-5554", "shell", "cmd", "package", "resolve-activity", "--brief", "-c", "android.intent.category.LAUNCHER", "org.qamcp.sample.notes"], "executed": true}, {"rule": "am_start", "argv": ["adb", "-s", "emulator-5554", "shell", "am", "start", "-W", "-n", "org.qamcp.sample.notes/.MainActivity"], "executed": true}]}
{"ts": "2026-11-12T13:12:28.584+00:00", "tool": "install_apk", "arguments": {"apk_path": "../../pyproject.toml"}, "outcome": "rejected", "duration_ms": 0.811, "dry_run": false, "commands": [], "error": "Error executing tool install_apk: apk_path escapes the allowed APK directory"}Results
All numbers below come from runs made in this repository on 2026-11-12. The raw outputs are in docs/results/. Machine: Windows 11, AMD64 (Family 23 Model 96), Python 3.12.10. No Android device was attached.
Metric | Result | Reproduce |
MCP tools exposed | 10 |
|
Tests | 244 passed, 1 skipped (the symlink-escape case needs symlink privilege on Windows) |
|
Line + branch coverage | 98.44 % (CI gate: 80 %) |
|
Lint / types | ruff check and format clean, mypy | see lint.txt |
Security matrix: rejected injection cases | 118 rejected, 0 accepted (1 more skipped on Windows): 69 tool-call cases through a real MCP client (39 command injection, 13 path traversal, 8 policy, 8 limits, 1 package allowlist) + 49 raw adb argv against the allowlist |
|
All | 126 passed, 1 skipped (matrix + |
|
Tool-call latency, fake device, in-process | p50 8.81 ms, p95 15.11 ms over 2,400 calls (12 call types x 200) |
|
Tool-call latency, fake device, stdio subprocess | p50 10.10 ms, p95 15.00 ms over 2,400 calls | same command |
Latency of a rejected call | p50 1.03 ms (in-process), 2.13 ms (stdio) | same command |
Where the time goes | in a cProfile of 100 |
|
Tool-call latency on a real device | to be measured | |
Markor exploratory session (DEMO.md) | to be measured |
LLM agent runs (Ollama, qwen3:8b, local, fake device). The reference loop
(uv run qa-mcp-server agent --fake --apk-dir examples/apks) was run 7 times.
Every run is saved in docs/results/agent-ollama-qwen3-8b-run*.txt.
Run | Outcome | What changed after it |
1 | Finished after 10 tool calls (1 min 52 s). Typed the title without focusing the field, so the text was dropped, yet reported "the note was saved correctly". Never tried the export action, so it missed the planted crash. |
|
2 | Ollama HTTP 500 after 4 calls. | send |
3 | 7 calls. Received the new "no focused input field: tap the field first" error, then Ollama aborted: | Qwen3-recommended sampling instead of greedy decoding |
4 | Model printed its tool call as text, so the loop took it as the final answer (2 calls) | parse Hermes-style |
5 |
| thinking mode made optional ( |
6 | Thinking on: | retries use a new seed |
7 |
| none (stopped here) |
So the agent loop and the server work end to end with a local model, but
qwen3:8b on this machine did not complete the exploratory goal reliably. The
first run shows why the audit log and fail-loud tools matter: the model's report
claimed a result the device never showed. Runs with a hosted model
(LLM_PROVIDER=anthropic) are to be measured.
Limitations
No real device was attached where this was built. Every number above comes from the scripted fake device. Real-device latency is dominated by adb and
uiautomator dumpand is to be measured, as is the Markor session in docs/DEMO.md.The fake device reproduces adb's output formats (
devices -l,am start -W,uiautomator dump /dev/tty, threadtime logcat). It is not an emulator, and per-device differences are untested:uiautomator dump /dev/tty,cmd package resolve-activityandlogcat --pidneed a reasonably recent Android (7+), and OEM builds vary.input_textaccepts a shell-safe ASCII subset only (letters, digits, space,@._,:/+=-). Quotes,#,?,*,%, non-ASCII and emoji are rejected rather than escaped. Full Unicode input needs an IME-based approach (roadmap).Selectors match
text,resource_idorcontent_descon the current screen only. There is no scrolling to off-screen elements and no XPath, and WebViews or Compose screens without semantics expose few nodes.Allowed actions are still powerful: a tap can land in any app, including Settings. Use a disposable emulator or test device (see SECURITY.md).
The HTTP transport has no authentication. It binds to loopback and refuses other interfaces without
--allow-remote.One device per call, and there is no locking between concurrent clients on the same device.
The bundled agent loop is a reference client. With a small local model (qwen3:8b) it follows the tool protocol but skips steps and can over-report success (see Results). Treat agent reports as leads and check them against the audit log.
On Windows without symlink privilege, the symlink-escape test is skipped. It runs on Linux CI.
Roadmap
Record the Markor session from docs/DEMO.md on an emulator and replace the "to be measured" rows with real numbers.
swipe/scroll_totools, with the same allowlist treatment.Optional per-tool rate limits and a confirmation hook (MCP elicitation) for
install_apkandrun_test_suite.Structured crash extraction (
FATAL EXCEPTIONand ANR blocks grouped per process) as a dedicated tool.Authentication for the HTTP transport.
Publish to PyPI so
uvx qa-mcp-serverworks without a clone.
License
MIT, see LICENSE. Copyright (c) 2026 Gabriel Nagawo.
Available Tools
10 toolsdump_uiARead-only
Dump the current screen's UI hierarchy (uiautomator) as a JSON tree: class, text, resource_id, content_desc, bounds [x1,y1,x2,y2] and flags (clickable, scrollable, ...).
Inputs: serial, compact. Returns: object
input_textA
Type text into the focused field. Tap the field first; fails if nothing is focused.
Inputs: text (required), serial, require_focus. Returns: action, detail, serial, dry_run
install_apkADestructive
Install (or reinstall, keeping data) an APK from the allowed APK directory.
Inputs: apk_path (required), serial. Returns: apk, serial, sha256, dry_run, +2 more
launch_appA
Start an installed app (launcher activity unless activity is given).
Reports launch state (COLD/WARM/HOT) and TotalTime from `am start -W`.Inputs: package (required), serial, activity. Returns: serial, status, dry_run, component, +2 more
list_devicesARead-only
List attached Android devices and emulators with their state.
State is device, offline or unauthorized; only "device" accepts commands.
Inputs: no named parameters. Returns: online, devices
logcatARead-only
Read the logcat buffer (non-blocking dump), size-capped.
Filters: package (running process), tag, minimum level and since.Inputs: tag, level, since, serial, +2 more. Returns: note, serial, entries, scanned, +2 more
press_keyB
Send one allowlisted key event.
Inputs: key (required), serial. Returns: action, detail, serial, dry_run
run_test_suiteA
Run a pre-configured test suite by name (exported ANDROID_SERIAL = serial). Only suites declared in the server config can run; arguments cannot be changed.
Inputs: name (required), serial. Returns: name, junit, passed, dry_run, +4 more
screenshotARead-only
Capture the screen as PNG image content, or save it and return the file path.
Inputs: save, serial.
tapA
Tap at (x, y), or on the element found by ONE selector (text, resource_id or content_desc), resolved through a fresh UI dump and tapped at its center.
Inputs: x, y, text, index, +4 more. Returns: x, y, serial, dry_run, +2 more
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 10 tools
Each tool targets a distinct Android QA action: UI inspection, imaging, device discovery, installation, launching, logging, interaction, and suite execution. The only mild overlap is dump_ui vs screenshot (both observe the screen) and tap/input_text/press_key (all input), but descriptions make the boundaries clear.
Most names are snake_case verb_noun (dump_ui, list_devices, install_apk, launch_app, run_test_suite), but several are bare nouns (screenshot, logcat, tap, input_text, press_key) where the verb is implicit. Still readable, but the pattern is not uniform.
Ten tools is well-scoped for an Android UI-testing control surface; each maps to a distinct step in a device/QA workflow with no filler tools.
Core interaction and observation are covered, but lifecycle coverage is gappy: no uninstall, no force-stop/clear-data, no swipe/scroll or long-press gesture, no wait_for_element, and no way to pull files or clear logcat. Agents doing real UI flows will hit dead ends on scrolling and element waiting.
Maintenance
Related MCP Connectors
Give your AI agent a real cloud Android phone: push APKs, drive it, get screenshots and logs.
Cloud Android phones for AI agents: create a phone, read the screen, tap, type, install APKs, park.
Disposable cloud Android emulators for coding agents: run an APK or PR build, tap, type, screenshot.
Related MCP Servers
- AlicenseAqualityDmaintenanceEnables AI agents to fully control Android devices through over 30 tools for app management, UI automation, and vision-based analysis via ADB. It supports multi-device management, action recording, and smart execution strategies ranging from UI hierarchy parsing to coordinate-based interaction.37188 npm1MIT
- AlicenseAqualityCmaintenanceEnables AI agents to control, inspect, and automate Android devices, Waydroid containers, and AVD emulators over ADB. Provides tools for screenshots, UI-hierarchy text-based tapping, gestures, key presses, text input, app management, and raw shell commands.12MIT
- AlicenseNot gradedqualityAmaintenanceEnables AI agents to drive an Android TV or Google TV box end to end through named tools — discover, pair, connect, check, install, provision, link, test, screenshot, report and a gated shell — with usage instructions embedded in the tool descriptions so the agent never improvises adb. Every command passes the same safety gate that refuses destructive actions.15Apache 2.0
- AlicenseAqualityBmaintenanceEnables AI agents to control Android devices through the Android Debug Bridge, covering device listing, shell commands, app install/uninstall, logcat reading, screenshots, UI automation (tap/swipe/type/key events), dumpsys queries, file transfer, port forwarding, permission management, and Wi-Fi pairing. Failures are mapped to typed, actionable causes and tool calls are safety-annotated and guardrailed, with multi-device calls scoped explicitly by serial.3563 npmMIT