Skip to main content
Glama

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 shell runs anything: uninstall, wipe app data, change system settings.

  • It never says it missed. adb shell input tap 540 1200 succeeds when it taps empty space. monkey -p some.package on 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 (emulator-5554) and the device must report itself as an emulator (ro.kernel.qemu / ro.boot.qemu). A physical device is allowed only when a human names it at start with --allow-device SERIAL.

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 pm clear. Commands are argv lists, never shell strings. Serials and package names are validated by pattern; packages must be in --allow-package (install, launch, restart); projects in --allow-project; a test path is resolved and must stay inside its project; an APK's own manifest must declare the package it is installed as. Typed text is limited to ASCII letters, digits, space and . , - _ @ : / + =, 200 characters at most (see below).

Command injection, on this machine or on the device; path traversal (../); installing an app nobody allowed under an allowed name.

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 (--lock-dir).

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; "Back" landing on "Backups"; a tap swallowed by the soft keyboard going unnoticed.

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 in content-desc, text fields expose only their hint. Matching on text alone 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_changed is reported.

  • A raised soft keyboard swallows taps, and nothing in the view hierarchy says it is up. Only dumpsys input_method does. Its state is reported by ui_elements and tap, 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 text truncates 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 % and s.

  • 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

list_devices

Attached devices: serial, state, model, screen size, emulator or not, and whether the policy allows it, with the reason.

install_apk(serial, apk_path, package)

Checks the APK declares package, installs it (install -r, data kept), then verifies with pm path and reads versionName / versionCode.

launch_app(serial, package)

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).

restart_app(serial, package)

Force-stops an allowed app (unsaved state is lost), checks its process is gone, then follows exactly the launch_app path: focus verified, crash diagnosed.

screenshot(serial)

Returns the PNG to the agent, with its size and sha256. The file is saved under the evidence directory, named by time and serial.

ui_elements(serial)

Labelled elements (label, bounds, clickable), the number of clickable elements without a label, and whether the keyboard is up.

tap(serial, label, match="exact"|"contains")

Taps the centre of the one matching element. Returns tapped, at, clickable, screen_changed, keyboard_shown and a note. screen_changed is null when the tap was sent but the screen could not be read afterwards (an app switch): the agent must look before going on.

type_text(serial, text, into=None, match="exact"|"contains")

Optionally taps the one field labelled into first (same matching and errors as tap: nothing is typed on error), then types the text in chunks. Returns chunks, into, verified (the text now shows in a node where it did not before; false is not an error, a password field hides it), keyboard_shown and a note.

set_airplane_mode(serial, enabled)

Switches airplane mode with cmd connectivity airplane-mode, waits until airplane_mode_on matches (error otherwise), and returns airplane_mode, changed, and network_seen (from dumpsys connectivity, null when it does not say).

run_flutter_test(serial, project_dir, test_path, timeout_s=900)

Runs flutter test <test_path> -d <serial> --reporter json in the project, and returns passed / failed / skipped counts, failure names and messages, and the path of the full log kept as evidence. Exit code and event stream must agree for success to be true.

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\pip

Run 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/evidence

Option

Meaning

--allow-package PACKAGE

An app the agent may install and launch. Repeatable. None by default.

--allow-project DIR

A Flutter project (with pubspec.yaml) whose tests the agent may run. Repeatable.

--allow-device SERIAL

A physical device the agent may act on. Repeatable. Emulators need nothing.

--evidence-dir DIR

Screenshots and test logs. Default ./evidence.

--lock-dir DIR

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/evidence

Or 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 purpose

No 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_apk of a debug APK: installed and verified (package, versionName, sha256). launch_app: focused in 2.8 s. ui_elements: Flutter content-desc labels read. screenshot: 2560x1600 PNG returned and saved as evidence. tap with 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. tap on "a" with contains: 29 candidates listed, nothing tapped. tap on an absent label: refused. launch_app on com.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_app refused after 15 s because the launcher kept the focus. The app had crashed at start: its libflutter.so is for EM_AARCH64, the emulator is EM_X86_64. Since then, a launch whose process is dead reads that app's latest crash in logcat -d -b crash and reports its root cause, with a plain hint for this ABI mismatch. Re-run live the same day with the same APK: launch_app now answers "crashed at launch", quotes the EM_AARCH64/EM_X86_64 cause and gives the hint.

  • Not exercised live. run_flutter_test (the app had no integration_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_mode on, on again (changed: false), then off; the setting matched each time. network_seen stayed false right after switching back on: it is a snapshot, the network needs a moment.

  • restart_app on both apps: focused in 2.7 s and 5.6 s. On a package not allowed: refused.

  • type_text into 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 ;) and Répertoire (the é).

  • Typed into a screen where no field had the focus, type_text answered verified: false with 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: false after 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 mInputShown in dumpsys 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

    Related MCP Servers