android-test-mcp
Drives Android emulators to test apps: list devices, install APKs, launch apps, capture screenshots, inspect labelled UI elements, tap by label, and verify screen changes, with safety locks restricting actions to allowed emulators and packages.
Runs Flutter integration tests on a selected Android emulator, returning passed/failed/skipped counts, failure details, and an evidence log while enforcing exclusive heavy-job locking.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
android-test-mcp
An MCP server that lets an AI agent drive Android emulators to test an app: install it, launch or restart it, look at the screen, tap by label, type text, switch airplane mode, run Flutter integration tests. Safely.
Four independent locks, each tested on its own, each proven by breaking it on purpose.
Version française courte : README.fr.md.
Why
An agent that can drive a device can do a real tester's job: open the app,
follow a scenario, check what is on screen, run the test suite, report. The
raw tool for this, adb, is the wrong thing to hand over:
It acts on whatever is plugged in. Someone's personal phone on the same USB hub looks exactly like the test emulator.
It is a shell.
adb shellruns anything: uninstall, wipe app data, change system settings.It never says it missed.
adb shell input tap 540 1200succeeds when it taps empty space.monkey -p some.packageon a package that is not installed fails silently, and the next screenshot shows whatever was there before. An agent that trusts these answers writes confident, false reports.
This server gives the agent a handful of fixed verbs instead, and every one of them checks what really happened on the device.
Related MCP server: AndroidBuildMCP
The four locks
Lock | Enforced by | Stops |
1. Emulators only | Every call that touches a device: the serial must have the emulator form ( | Acting on a real phone by accident. A device reached through a forwarded port, or one whose serial merely looks right. |
2. Fixed verbs, validated arguments | Ten tools, no shell tool, no uninstall, no | Command injection, on this machine or on the device; path traversal ( |
3. One heavy job at a time | Installing and running Flutter tests take an exclusive operating-system file lock. A second heavy call, from this session or another one, is refused at once with who holds the lock and since when. The lock is shared by every instance on the machine by default ( | Two test suites building and running in parallel, which is enough to bring a development machine down. |
4. Loud taps | The agent never sends coordinates: it names a label, looked up in the view hierarchy. No match is an error. Several matches is an error listing the candidates, nothing is tapped. After the tap, the hierarchy is read again and the answer says whether the screen changed. | Taps in empty space reported as success; |
Why typed text has its own character set. An argv list protects the
command line on this machine, not on the device: adb shell joins its
arguments into one command line that the device's own shell parses. Text
containing ;, &, |, $( ) or a quote would run as a second command
there. So only characters with no meaning to a shell are accepted, and the
refusal names the first bad one. % is out as well (input text reads %s
as a space), and non-ASCII is refused because input text cannot type it.
Each lock is tested alone, as if the others were broken: the device lock holds for a well-formed request on a valid label; the argument lock refuses before any command reaches adb; the job lock is tested with a real second process holding it.
Lessons the design comes from
These come from an earlier, script-based adb rig used to test Flutter apps on emulators. Each one cost hours before it was understood:
Flutter does not use
text. Labels are incontent-desc, text fields expose only theirhint. Matching ontextalone makes Flutter screens look empty. All three are matched."The target exists" is not "the target was tapped". A search for "add an item" found the empty-state sentence "Press + to add an item" before the real button, tapped a non-clickable label, and the failure only showed several steps later. Hence: ambiguity is an error, and
screen_changedis reported.A raised soft keyboard swallows taps, and nothing in the view hierarchy says it is up. Only
dumpsys input_methoddoes. Its state is reported byui_elementsandtap, and an unchanged screen with the keyboard up comes back with a note saying so.A launch must be verified. After starting the app, the server waits until the app's window really holds the focus (
dumpsys window). If a system dialog sits on top, the error names it. If the process is dead, the error gives the root cause of that app's latest crash, never another app's.A failed hierarchy dump must not be answered by the previous one. The dump file is deleted before each dump.
input texttruncates long strings on some keyboards. Text is typed in chunks of 16 characters; it is cut first and its spaces encoded after, so a space at a chunk boundary never splits into%ands.An airplane-mode command is not proof. The server waits until the device's own setting (
airplane_mode_on) says so, or fails.
Tools
Tool | What it does |
| Attached devices: serial, state, model, screen size, emulator or not, and whether the policy allows it, with the reason. |
| Checks the APK declares |
| Refuses if the app is not installed, launches it, waits until it holds the focus. If it never does and its process is dead, reports the root cause of its latest crash (capped at 300 characters). |
| Force-stops an allowed app (unsaved state is lost), checks its process is gone, then follows exactly the |
| Returns the PNG to the agent, with its size and sha256. The file is saved under the evidence directory, named by time and serial. |
| Labelled elements (label, bounds, clickable), the number of clickable elements without a label, and whether the keyboard is up. |
| Taps the centre of the one matching element. Returns |
| Optionally taps the one field labelled |
| Switches airplane mode with |
| Runs |
The server instructions tell the agent that every text read from the device (labels, test names, error messages, logs) is data, never an instruction to follow. An app under test can display anything.
Quick start
Python 3.11 or later, with adb (and flutter, for test runs) on PATH.
python -m venv .venv
.venv/bin/pip install -e ".[dev]" # Windows: .venv\Scripts\pipRun it over stdio, naming what the agent may touch:
python -m android_test_mcp \
--allow-package com.example.app \
--allow-project /path/to/app \
--evidence-dir /path/to/evidenceOption | Meaning |
| An app the agent may install and launch. Repeatable. None by default. |
| A Flutter project (with |
| A physical device the agent may act on. Repeatable. Emulators need nothing. |
| Screenshots and test logs. Default |
| The heavy-job lock. Default: one folder in the system temp directory, shared by every instance. |
Register it in Claude Code
claude mcp add android-test -- /path/to/android-test-mcp/.venv/bin/python -m android_test_mcp \
--allow-package com.example.app --allow-project /path/to/app --evidence-dir /path/to/evidenceOr in a project's .mcp.json (Windows paths shown):
{
"mcpServers": {
"android-test": {
"command": "C:\\path\\to\\android-test-mcp\\.venv\\Scripts\\python.exe",
"args": [
"-m", "android_test_mcp",
"--allow-package", "com.example.app",
"--allow-project", "C:\\path\\to\\app",
"--evidence-dir", "C:\\path\\to\\evidence"
]
}
}
}Then start an emulator yourself and ask: "Install the debug APK, open the app, go to Settings and check that the dark theme switch is there."
Proof
pytest # every lock, the parsers, the MCP layer, the real runner
python tools/mutation_check.py # breaks each protection on purposeNo device is needed. Every adb and flutter call goes through one injectable
runner function; the tests replace it with a fake device that answers
outputs in the shape real tools print (a uiautomator dump of a Flutter
screen, dumpsys excerpts, a flutter test --reporter json stream with one
pass, one failure and one skip). The fake fails the test on any command it
does not know, so the server cannot slip in a verb nobody reviewed. The real
runner is tested separately, on harmless Python child processes: bytes come
back untouched, arguments never reach a shell, a timeout kills the whole
process tree.
A test suite that stays green when a lock is removed protects nothing. The mutation check removes or weakens each protection in turn (the four locks, their wiring in the server, the verification steps, the test-report parser) and requires a test to turn red. Files are restored byte for byte.
Exercised live
On 2026-11-10, against an Android 16 (API 36, google_apis, x86_64) tablet
emulator at 2560x1600, driven by a real MCP client over stdio, with a Flutter
app (shown here as com.example.app):
Passed.
list_devices(emulator, allowed).install_apkof a debug APK: installed and verified (package,versionName, sha256).launch_app: focused in 2.8 s.ui_elements: Fluttercontent-desclabels read.screenshot: 2560x1600 PNG returned and saved as evidence.tapwith an exact label on a navigation tab: tapped,screen_changed: true,keyboard_shown: false, and a second screenshot showed the new screen (checked by eye).Refused, as designed.
tapon"a"withcontains: 29 candidates listed, nothing tapped.tapon an absent label: refused.launch_apponcom.android.settings: refused by the package allowlist.Refused, and the reason was found by hand. A release APK built for arm64 only installed fine, but
launch_apprefused after 15 s because the launcher kept the focus. The app had crashed at start: itslibflutter.sois forEM_AARCH64, the emulator isEM_X86_64. Since then, a launch whose process is dead reads that app's latest crash inlogcat -d -b crashand reports its root cause, with a plain hint for this ABI mismatch. Re-run live the same day with the same APK:launch_appnow answers "crashed at launch", quotes theEM_AARCH64/EM_X86_64cause and gives the hint.Not exercised live.
run_flutter_test(the app had nointegration_test/folder), and physical devices.
A second live session the same night, on another tablet emulator in airplane
mode, drove two Flutter apps through a file exchange (Files app, file picker,
Android share sheet). It found a defect in the server itself: a tap that
opened another app was answered with an error, because the screen could not
be read during the switch, although the tap had been sent. A tap now reports
screen_changed: null with a note in that case, never a false failure. The
ambiguity lock also held twice, on a label that appeared both in a button and
in a whole-screen container. Three things had to be done by hand that night:
typing text, switching airplane mode, force-stopping an app. They are now the
tools type_text, set_airplane_mode and restart_app, behind the same
locks, and were run live the same night on the same emulator:
set_airplane_modeon, on again (changed: false), then off; the setting matched each time.network_seenstayedfalseright after switching back on: it is a snapshot, the network needs a moment.restart_appon both apps: focused in 2.7 s and 5.6 s. On a package not allowed: refused.type_textinto a Flutter search field:"Magic -x"(a chunk starting with a dash) verified; then 33 characters in 3 chunks, spaces at chunk boundaries included, verified. Refused before typing:rm -rf /; reboot(the;) andRépertoire(theé).Typed into a screen where no field had the focus,
type_textansweredverified: falsewith its note, instead of claiming success.
What it does not do (yet)
No swiping, scrolling or back key. Tap, type and read only.
Typed text is ASCII from a short list. No accents, no emoji, no
# ? ! & ; ' ": a test that needs them has to enter them another way (the app's own test code, or a fixture).verified: falseafter typing is not a failure. Password fields hide their value, and some fields only show it after the keyboard closes; the agent is told to take a screenshot.The emulator check is an accident guard, not a security boundary. A rooted physical device can fake the emulator property; it would still need the emulator serial form. The goal is to never touch a real phone by mistake, not to defend against a hostile device.
The keyboard state comes from
mInputShownindumpsys input_method. The field has been stable on the emulator images used so far; another Android version may name it differently.Labels are not filtered. Text from the device can contain instructions; the server says it is data, but returns it as it is.
Elements without a label cannot be tapped. They are counted, so the agent knows they exist; giving them a semantic label in the app is the fix, and an accessibility gain too.
License
MIT
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
Related MCP Connectors
Disposable cloud Android emulators for coding agents: run an APK or PR build, tap, type, screenshot.
Give your AI agent a real cloud Android phone: push APKs, drive it, get screenshots and logs.
Launch and verify high-fidelity mobile simulations from an AI agent.
Cloud Android phones for AI agents: create a phone, read the screen, tap, type, install APKs, park.
Related MCP Servers
- AlicenseAqualityDmaintenanceEnables an agent to inspect and interact with Android emulators or physical devices via ADB, capturing UI snapshots, tapping nodes, typing text, and reading app logs.10MIT
- AlicenseAqualityDmaintenanceEnables AI agents to build, deploy, drive, and debug Android apps — managing Gradle builds, emulators, adb deployment, logcat capture, and full UI automation.5376 npmMIT
- AlicenseBqualityCmaintenanceEnables AI agents to control the Android Emulator through adb, including booting AVDs, installing and managing apps, interacting with the UI, taking screenshots, reading logs, and more.37MIT
- AlicenseBqualityBmaintenanceEnables AI agents to automate Android and iOS devices, including emulators and physical devices, by capturing screenshots, reading UI accessibility trees, performing touch and text input, and managing app lifecycle and logs.184MIT