android-mcp-harness
Allows observing and controlling an Android emulator, including getting status, UI hierarchy, screen capture, and opening the Apps settings.
Click on "Install 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., "@android-mcp-harnessCapture a screenshot and inspect the UI tree"
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.
El problema
Dar control de un teléfono a un agente suele resolverse de la peor manera posible:
una herramienta adb shell <lo que sea> y una captura de pantalla para que el
modelo adivine dónde pulsar. Eso es un intérprete de comandos remoto con acento
de IA — sin contrato, sin límites, sin pruebas y sin rastro de lo que pasó.
Este arnés hace lo contrario. Cada capacidad se declara antes de existir, el modelo nunca envía coordenadas, y toda acción que cambia algo deja una prueba en disco.
Cliente MCP → stdio → Arnés → Appium → UiAutomator2 → ADB → Emulador Android
▲
└── contrato · guardias · bloqueo único · evidenciaRelated MCP server: lazy-mobile-mcp
Qué sabe hacer
Doce herramientas. Ni una más de las declaradas.
👁️ Observar — no cambian nada de lo que se ve
Herramienta | Parámetros | Devuelve |
| — | UDID, versión de Android, modelo y versión de Appium |
|
| Lo que la pantalla dice y lo que se puede pulsar, con el selector de cada objetivo |
| — | PNG en |
| — | Identificadores de paquete instalados |
Las cuatro van por ADB de solo lectura. No abren sesión de Appium a propósito: crear una sesión puede traer una app al primer plano, y eso violaría la promesa de que observar no navega.
✋ Actuar — cada una deja evidencia
Herramienta | Parámetros | Qué hace |
|
| Resuelve la actividad |
| — | Reserva una cadena exclusiva de UI durante un tiempo acotado |
|
| Cierra esa cadena y libera el emulador |
|
| Pulsa un elemento localizado por semántica |
|
| Escribe texto acotado en un campo |
|
| Un gesto vertical normalizado |
|
| Una navegación Atrás |
| — | El flujo de demostración: Ajustes → Apps |
El modelo nunca manda coordenadas. El objetivo de un selector es exactamente
una de estas cinco claves. Cuando la misma etiqueta aparece varias veces, puede
llevar además un within con un único ancestro semántico que el propio árbol ha
publicado:
{"resource_id": "com.android.settings:id/search"} // id de recurso
{"text": "Calendar"} // texto exacto
{"content_desc":"Search"} // etiqueta accesible
{"text_contains":"Calen"} // texto parcial
{"input_hint": "Search"} // campo por su pista
{"text":"Save", "within":{"content_desc":"Personal profile"}} // contexto semánticoUn valor de selector puede llevar saltos de línea, porque Flutter funde los
textos de un widget en una sola descripción separada por ellos: Historial\nTab 2 of 3 es como esa pestaña se llama de verdad.
Si nada encaja, la respuesta es UI_ELEMENT_NOT_FOUND con una captura del
momento — y con lo que la pantalla sí ofrece:
No visible element matched 'input_hint' before timeout.
The screen offers: 'Android Auto' (button), 'Calendar' (button),
'Search…' (input), 'More options' (button).Un callejón sin salida que nombra las alternativas es un reintento, no un callejón. Nunca hay un plan B de "pulsa en el centro y a ver qué pasa".
El bucle se cierra solo
ui.get_tree no devuelve el volcado XML. Devuelve la pantalla traducida al
vocabulario que el propio servidor acepta:
{
"foreground_package": "com.android.settings",
"texts": ["All apps", "Calendar", "Clock", "…"],
"actions": [
{"selector": {"text": "Calendar"}, "label": "Calendar", "role": "button",
"enabled": true, "bounds": {"left": 0, "top": 525, "width": 1080, "height": 199}},
{"selector": {"resource_id": "…:id/q"}, "label": "Search", "role": "input", "enabled": true},
{"selector": {"text": "Clock"}, "label": "Clock", "role": "button", "ambiguous": true},
{"selector": {"content_desc": "Historial\nTab 2 of 3"}, "label": "Historial",
"role": "button", "covered_by_keyboard": true}
],
"can_scroll": true,
"keyboard": {"open": true, "top": 1517}
}Lo que sale de ui.get_tree entra tal cual en ui.tap. El modelo no interpreta
XML, no calcula posiciones y no adivina: lee, elige una entrada y la envía.
Cuatro detalles que importan:
roledistingue lo que se pulsa de lo que se escribe de lo que se conmuta, para que el modelo no intente teclear en un botón.withindesambigua una etiqueta repetida con un ancestro semántico, por ejemplo “Save dentro de Personal profile”. Si no existe tal contexto,ambiguouspermanece: pulsar sería una moneda al aire, y es mejor decirlo que fallar en silencio.Lo tapado por el teclado se avisa. El volcado de Android describe la ventana como si el teclado no estuviera encima, así que la barra de pestañas se leía disponible y no se podía tocar. Ahora llega
covered_by_keyboardy unkeyboard: {open, top}. No se oculta el objetivo: ocultarlo sería mentir por omisión, ydevice.backcierra el teclado.Los scrollables no se listan como objetivos:
ui.scrollactúa sobre la pantalla y no acepta selector, así que ofrecerlos sería inventar un blanco.
El volcado completo sigue disponible con include_raw: true para depurar un
selector a mano. Simplemente ha dejado de ser el precio de mirar la pantalla:
17.508 bytes de XML frente a 1.815 del resumen, un 90 % menos, medido sobre
la lista de aplicaciones de Ajustes.
Lo visual, hasta donde una máquina puede jurarlo
bounds viaja en cada acción y el arnés comprueba lo que es aritmética, no gusto:
{"issue": "touch_target_too_small", "selector": {"text": "Guardar"},
"size_px": [40, 40], "minimum_px": 126}Tres comprobaciones, y solo tres, porque son las únicas inequívocas: un elemento fuera de la pantalla, uno sin área, y un control más pequeño que una yema de dedo en los dos ejes (los 48 dp de Android, convertidos con la densidad real del dispositivo). Una fila ancha recortada por el scroll no se denuncia: sería un falso positivo, y el objetivo declarado es cero.
Si algo es bonito no se comprueba. Si un botón está fuera de la pantalla, sí.
La posición sale, nunca entra.
boundsse publica para auditar y jamás se acepta como selector. Leer dónde está algo y apuntar a un píxel son dos poderes distintos, y solo el primero es seguro de ceder.
La evidencia se puede ver, no solo citar
Cada captura se expone además como recurso MCP:
artifact://20260814-004916-785289-screen.pngUn cliente que no comparta disco con el arnés lee ahí la imagen. El recurso solo
sirve identificadores con la forma exacta que el arnés emite y comprueba que la
ruta resuelta siga dentro de artifacts/: un recorrido de directorios no es
representable.
Y la captura tiene que mostrar algo. Un emulador sin ventana o con la pila
gráfica rota responde a screencap con un PNG perfectamente válido de un solo
color plano, y cualquier comprobación que solo mirase los bytes mágicos lo daría
por bueno. El arnés decodifica la imagen con la biblioteca estándar —sin
dependencias— y si todos los píxeles son idénticos devuelve
EVIDENCE_WRITE_FAILED. Un fichero que existe no es una prueba.
Lo que se niega a hacer
Un arnés serio se define por lo que no expone:
❌ No hay
adb shellarbitrario. Cada comando ADB está escrito a mano en el código. La lista es cerrada.❌ No abre puertos. El transporte es stdio. Cero red.
❌ No acepta coordenadas. Ni del modelo, ni del cliente, ni por accidente.
❌ No usa el intérprete de comandos del sistema. Todo va por
subprocesscon lista de argumentos:;,&&,../..y--flagsmueren en la validación.❌ No deja sesiones huérfanas. Sin
session_id, cada acción abre su sesión, la usa y la cierra enfinally. Un flujo explícito se cierra conui.session.close, tras 60 s de inactividad o si vence su techo de acción.❌ No hay dos dueños del emulador. Un bloqueo único; la segunda operación simultánea recibe
EMULATOR_BUSYde inmediato, sin cola.
Una respuesta, siempre la misma forma
Toda herramienta —éxito o fallo— devuelve el mismo contrato:
{
"ok": true,
"operation_id": "7f3c…",
"tool": "ui.tap",
"data": { "target": {"text": "Calendar"}, "element_label": "Calendar",
"foreground_package": "com.android.settings" },
"evidence": { "artifact_id": "20260813-231622-282673-ui-tap.png",
"path": "artifacts/20260813-231622-282673-ui-tap.png" },
"error": null
}En fallo: ok: false, data: {}, y error con un código tipado de esta lista —
EMULATOR_UNAVAILABLE, APPIUM_UNAVAILABLE, EMULATOR_BUSY,
UI_ELEMENT_NOT_FOUND, UI_TREE_UNAVAILABLE, INVALID_SELECTOR,
INVALID_TEXT, INVALID_PACKAGE, INVALID_SCROLL_DIRECTION, APP_NOT_FOUND,
SETTINGS_FOREGROUND_FAILED, OPERATION_TIMEOUT, EVIDENCE_WRITE_FAILED,
INTERNAL_ERROR.
Cada operation_id es único e irrepetible: sirve para correlacionar lo que pidió
el modelo con el PNG que quedó en disco.
Cómo está construido
Arquitectura SUME: cada carpeta es una responsabilidad, cada fichero declara su contrato en cabecera.
flowchart LR
A["entradas/<br/>servidor MCP · CLI"] --> B["contratos/<br/>validación · forma pública"]
B --> C["logica/<br/>controlador · navegación · seguridad"]
C --> D["salidas/<br/>terminal"]
C --> E["artifacts/<br/>evidencia PNG"]Carpeta | Responsabilidad |
| Puertas: servidor MCP por stdio y comando de terminal |
| Validación de intenciones y forma pública de las respuestas |
| Guardias: qué UDID y qué Appium son aceptables |
| Adaptadores ADB y Appium, comandos fijos |
| Lectura del árbol, selección semántica, auditoría de maqueta y localizadores; sin coordenadas |
| Fachada MCP, ejecución acotada de Appium y bloqueo de exclusividad |
| Rutas únicas de evidencia, a prueba de reintentos |
| El contrato escrito antes del código y su oráculo |
Mapa completo en mapa-global/arquitectura.yaml
y bitácora en cambios/registro-cambios.md.
La división interna del servidor evita que una pieza mezcle responsabilidades:
Módulo | Una responsabilidad |
| Convertir XML Android en nodos visibles y sus ancestros. |
| Elegir roles, selectores y contexto |
| Medir bounds, teclado, zonas táctiles y solapes. |
| Orquestar las tres lecturas anteriores y publicar el resumen estable. |
| Exponer las doce herramientas, validar sus entradas y coordinar adaptadores. |
| Ejecutar llamadas Appium con techo, evidencia y cierre seguro del driver. |
La API MCP no cambia con esta división: es una frontera interna para que XML, decisiones semánticas y Appium no vuelvan a crecer en el mismo fichero.
¿Me falta algo?
.\.venv\Scripts\python -m entradas.comandos.doctorComprueba las ocho piezas de una vez y, por cada una que falte, dice qué hacer. No se para en la primera: ir descubriendo obstáculos de uno en uno convierte la instalación en un juego de adivinanzas.
[ok ] Python 3.13.5
[MISS] Java java version "1.8.0_491" is Java 8, older than 17
[ok ] ADB .../platform-tools/adb.exe
[MISS] Emulator The configured Android emulator is unavailable in ADB.
2 of 8 checks block a real campaign:
Java: Install JDK 17 and put its bin directory first on PATH, or set JAVA_HOME.
Emulator: Start a disposable AVD so that 'emulator-5554' comes online.Ese java de la primera ejecución era real: comprobar que un programa existe
es como se pasa una revisión y se falla una hora después.
Probarlo sin instalar nada
docker build -t android-mcp-harness .
docker run --rm android-mcp-harnessEl banco entero en unos ocho segundos, sin Python, sin Node y sin SDK en tu máquina. El número exacto de pruebas lo dice el propio comando, así que este párrafo no envejece.
El emulador no está dentro, a propósito. Necesitaría /dev/kvm, que Docker
Desktop en Windows y macOS no cede de forma fiable, y el driver UiAutomator2
reenvía puertos del dispositivo a través del servidor ADB: esos reenvíos
aparecerían en el anfitrión y serían inalcanzables desde dentro. Prometer lo
contrario sería una promesa que nadie ha probado.
Lo que la imagen sí demuestra: los contratos se cumplen, el banco pasa y el
servidor MCP arranca por stdio real publicando exactamente su catálogo. La
campaña contra un AVD sigue siendo nativa, y doctor dice qué falta para ella.
Puesta en marcha
Necesitas: Python 3.13, JDK 17, Android SDK con un AVD, y Node para Appium.
# 1. Emulador desechable
emulator -avd Medium_Phone
# 2. Cliente Python
python -m venv .venv
.\.venv\Scripts\python -m pip install -r requirements.txt
# 3. Appium (otra terminal)
npm install
$env:JAVA_HOME = 'C:\Program Files\Eclipse Adoptium\jdk-17'
$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
$env:Path = "$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path"
.\node_modules\.bin\appium.cmd
# 4. Demostración de humo
.\.venv\Scripts\python main.pyConectarlo a un cliente MCP
{
"mcpServers": {
"android-harness": {
"command": "C:/ruta/al/repo/.venv/Scripts/python.exe",
"args": ["-m", "entradas.mcp.server"],
"cwd": "C:/ruta/al/repo",
"env": { "ANDROID_UDID": "emulator-5554",
"APPIUM_URL": "http://127.0.0.1:4723" }
}
}
}Cliente de referencia: una tarea real, declarada
El cliente de referencia abre una app, observa la pantalla, encadena los pasos
semánticos bajo una sesión temporal y escribe un informe JSON con cada resultado
y evidencia. Copia y adapta
docs/reference-flow.example.json: los
selectores deben salir de ui.get_tree de tu propia app, no inventarse.
.\.venv\Scripts\python -m entradas.comandos.cliente_referencia docs/reference-flow.example.jsonEl ejemplo usa un paquete ficticio: sustituye package_name por el de la app
Flutter instalada en el AVD y sus pasos por la tarea que quieres verificar.
Para la APK local de Auralis Compra, el flujo seguro de exploración vive en
docs/auralis-compra-exploration-flow.example.json.
Parte de la pestaña Lista, solo visita Historial y Ajustes, y no añade artículos
ni accede a presupuesto o pago.
Solo dos variables. Ambas apuntan a tu máquina y tienen valor por defecto.
Verificación
El proyecto no se cree a sí mismo: se mide.
# Banco unitario (sin dispositivo)
.\.venv\Scripts\python -m unittest discover -s tests -v
# Campaña contra el emulador real
$env:ANDROID_MCP_RUN_EMULATOR = '1'
.\.venv\Scripts\python -m unittest tests.test_mcp_emulator_e2e -vLa campaña comprueba las promesas, no las funciones: que observar no navega, que dos capturas seguidas no se pisan, que la navegación llega a donde dice y deja prueba, que dos operaciones simultáneas no comparten el emulador, y que un cliente MCP externo ve el mismo catálogo a través de stdio real.
El oráculo — la verdad escrita por una persona, contra la que se compara todo —
vive en docs/eca/mcp-emulator-v1.md y no se
edita para convertir un fallo en verde.
En CI hay dos flujos y la separación es deliberada:
Flujo | Cuándo | ¿Bloquea? |
Cada push y cada PR | Sí — banco unitario en Python 3.12 y 3.13, más el catálogo MCP por stdio real | |
Semanal y a demanda | No — arranca un AVD y Appium de verdad y sube la evidencia |
Un emulador en CI es lento y a veces caprichoso. Una puerta que falla sin culpa del código enseña a la gente a ignorar el rojo, así que la campaña real informa en vez de bloquear, y sus capturas quedan como artefacto descargable.
Estado y límites honestos
Un proyecto que esconde dónde no llega no es serio. Esto es lo que hay:
✅ Verificado en local: Android 16 (API 36), Appium 3.6, emulador
emulator-5554. Presupuesto de ≤30 s por llamada cumplido con holgura.✅ Sin sesiones huérfanas, medido: se pregunta a Appium cuántas sesiones tiene antes y después de una tanda con éxitos y con fallos.
✅ Dos niveles de API en verde (34 y 36), en máquinas que no son la mía. Llegar ahí costó seis vueltas y descubrió que el buscador de Ajustes es una clase distinta en cada versión de Android.
✅ Probado contra una aplicación Flutter ajena, no solo contra Ajustes. Ese recorrido encontró tres fallos del propio arnés y devolvió un informe de accesibilidad utilizable sobre la app.
⚠️ Una capa de fabricante encima de Android sigue pudiendo mover selectores y tiempos. No está probado.
⚠️ El resumen es una opinión sobre la pantalla. Marca lo ambiguo, pero un diseño que no expone ni texto, ni
content-desc, niresource-idsigue sin ser accionable por semántica — y eso es un problema de la app, no del arnés.⚠️
ui.scrollsolo publicaupydown. El gesto horizontal está escrito y probado en la capa de navegación, pero no sale al catálogo: no hay campaña que lo mida contra un carrusel real, y una capacidad sin promesa verificable no pertenece a la superficie pública. Entra cuando exista su fila de oráculo.✅ Las acciones pueden encadenarse de forma explícita.
ui.session.opendevuelve un identificador opaco paraui.tap,ui.type_text,ui.scrollydevice.back; la sesión caduca tras 60 s sin uso (configurable conANDROID_MCP_FLOW_IDLE_TIMEOUT) y se puede liberar antes conui.session.close. Sin ese identificador, cada acción conserva el cierre inmediato de sesión original.✅ Una llamada colgada no puede bloquear a todos. Pasados 90 s (
ANDROID_MCP_ACTION_TIMEOUT) el arnés deja de esperar, devuelveOPERATION_TIMEOUT, anula el arriendo del flujo y suelta el emulador. Es un techo de seguridad, no el presupuesto declarado de ≤30 s. El hilo abandonado sigue corriendo hasta terminar solo: un hilo no se puede matar, y se dice en vez de fingir lo contrario.🚫 No integra agentes todavía. Auralis, Trinidad y Glas serán clientes de esta frontera — nunca una ampliación de su autoridad.
Hacia dónde va
Guardias de configuración aplicados en todas las puertas, no solo en las de lectura
Contrato FASER al día con las doce herramientas
Espera activa antes de declarar que una app no se abrió
Árbol UI resumido y filtrado en vez de XML crudo
Evidencia legible como recurso MCP, no como ruta
Segundo nivel de API en la campaña
Un AVD con capa de fabricante, no solo imágenes de Google
Una prueba que recorra las dos piezas juntas: todo lo que el resumen ofrece, el localizador lo encuentra
Una campaña E2E para publicar
ui.scrollhorizontal en un carrusel realEncadenar acciones sin perder el estado entre sesiones
ui.tapcapaz de desambiguar sin recurrir a coordenadasUn cliente de referencia que recorra una app real de principio a fin
Licencia
MIT.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseBqualityBmaintenanceAn MCP server that enables AI assistants to build, test, and debug Android applications by interacting directly with the Android development environment. It provides tools for managing emulators, executing Gradle tasks, running ADB commands, and performing UI automation via accessibility trees.1412116MIT
- AlicenseNot gradedqualityCmaintenanceLocal MCP server for Android and iOS mobile automation and performance telemetry, enabling device control (screenshot, tap, swipe, input, app launch) and metric collection (CPU, memory, launch time) via ADB, simctl, and WebDriverAgent with SQLite session history.151MIT
- AlicenseNot gradedqualityCmaintenanceMCP server that enables LLMs to control Android devices via ADB, providing tools for screen interaction and UI inspection.1MIT
- FlicenseNot gradedqualityDmaintenanceControls Android emulators from any MCP-capable client via ADB and emulator CLI, supporting AVD management, APK operations, screen interactions, and device diagnostics.1
Related MCP Connectors
MCP server for Appcircle mobile CI/CD platform.
An MCP server that let you interact with Cycloid.io Internal Development Portal and Platform
MCP server exposing the Backtest360 engine API as tools for AI agents.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/dajarony/android-mcp-harness'
If you have feedback or need assistance with the MCP directory API, please join our Discord server