android-mcp-harness
by dajarony
README.md
<h1 align="center">Android MCP Harness</h1>
<p align="center">
<strong>Un modelo de lenguaje que usa un Android de verdad — sin tocar un solo píxel a ciegas.</strong>
</p>
<p align="center">
<a href="https://github.com/dajarony/android-mcp-harness/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/dajarony/android-mcp-harness/actions/workflows/ci.yml/badge.svg"></a>
<a href="https://github.com/dajarony/android-mcp-harness/actions/workflows/eca.yml"><img alt="ECA" src="https://github.com/dajarony/android-mcp-harness/actions/workflows/eca.yml/badge.svg"></a>
<img alt="Python" src="https://img.shields.io/badge/Python-3.13-3776AB?logo=python&logoColor=white">
<img alt="MCP" src="https://img.shields.io/badge/MCP-stdio%20local-5A45FF">
<img alt="Appium" src="https://img.shields.io/badge/Appium-3.6-662D91?logo=appium&logoColor=white">
<img alt="Android" src="https://img.shields.io/badge/Android-16%20(API%2036)-3DDC84?logo=android&logoColor=white">
<img alt="Sin red" src="https://img.shields.io/badge/red-0%20puertos%20abiertos-16A34A">
</p>
---
## 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.
```text
Cliente MCP → stdio → Arnés → Appium → UiAutomator2 → ADB → Emulador Android
▲
└── contrato · guardias · bloqueo único · evidencia
```
---
## 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 |
|---|---|---|
| `emulator.get_status` | — | UDID, versión de Android, modelo y versión de Appium |
| `ui.get_tree` | `include_raw?`, `session_id?` | Lo que la pantalla **dice** y lo que se puede **pulsar**, con el selector de cada objetivo |
| `screen.capture` | — | PNG en `artifacts/` + su `artifact_id` y su `uri` legible |
| `app.list_installed` | — | 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 |
|---|---|---|
| `app.open` | `package_name` | Resuelve la actividad `MAIN/LAUNCHER` del paquete y la lanza |
| `ui.session.open` | — | Reserva una cadena exclusiva de UI durante un tiempo acotado |
| `ui.session.close` | `session_id` | Cierra esa cadena y libera el emulador |
| `ui.tap` | `selector`, `session_id?` | Pulsa **un** elemento localizado por semántica |
| `ui.type_text` | `selector`, `text`, `session_id?` | Escribe texto acotado en un campo |
| `ui.scroll` | `direction` (`up` \| `down`), `session_id?` | Un gesto vertical normalizado |
| `device.back` | `session_id?` | Una navegación Atrás |
| `settings.open_apps` | — | 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:
```jsonc
{"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ántico
```
Un 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**:
```text
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**:
```json
{
"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:
- **`role`** distingue 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.
- **`within`** desambigua una etiqueta repetida con un ancestro semántico, por
ejemplo “Save dentro de Personal profile”. Si no existe tal contexto,
**`ambiguous`** permanece: 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_keyboard` y un
`keyboard: {open, top}`. No se oculta el objetivo: ocultarlo sería mentir por
omisión, y `device.back` cierra el teclado.
- **Los scrollables no se listan** como objetivos: `ui.scroll` actú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:
```json
{"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.** `bounds` se 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:
```text
artifact://20260814-004916-785289-screen.png
```
Un 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 shell` arbitrario.** 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 `subprocess`
con lista de argumentos: `;`, `&&`, `../..` y `--flags` mueren en la validación.
- ❌ **No deja sesiones huérfanas.** Sin `session_id`, cada acción abre su
sesión, la usa y la cierra en `finally`. Un flujo explícito se cierra con
`ui.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_BUSY` de inmediato, sin cola.
---
## Una respuesta, siempre la misma forma
Toda herramienta —éxito o fallo— devuelve el mismo contrato:
```json
{
"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.
```mermaid
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 |
|---|---|
| `entradas/` | Puertas: servidor MCP por stdio y comando de terminal |
| `contratos/` | Validación de intenciones y forma pública de las respuestas |
| `logica/seguridad/` | Guardias: qué UDID y qué Appium son aceptables |
| `logica/infraestructura/` | Adaptadores ADB y Appium, comandos fijos |
| `logica/navegacion/` | Lectura del árbol, selección semántica, auditoría de maqueta y localizadores; sin coordenadas |
| `logica/servicios/mcp_server/` | Fachada MCP, ejecución acotada de Appium y bloqueo de exclusividad |
| `logica/evidencias/` | Rutas únicas de evidencia, a prueba de reintentos |
| `docs/faser/` · `docs/eca/` | El contrato escrito **antes** del código y su oráculo |
Mapa completo en [`mapa-global/arquitectura.yaml`](mapa-global/arquitectura.yaml)
y bitácora en [`cambios/registro-cambios.md`](cambios/registro-cambios.md).
La división interna del servidor evita que una pieza mezcle responsabilidades:
| Módulo | Una responsabilidad |
|---|---|
| `navegacion/arbol.py` | Convertir XML Android en nodos visibles y sus ancestros. |
| `navegacion/objetivos.py` | Elegir roles, selectores y contexto `within` sin actuar sobre la pantalla. |
| `navegacion/maqueta.py` | Medir bounds, teclado, zonas táctiles y solapes. |
| `navegacion/resumen.py` | Orquestar las tres lecturas anteriores y publicar el resumen estable. |
| `mcp_server/controller.py` | Exponer las doce herramientas, validar sus entradas y coordinar adaptadores. |
| `mcp_server/ejecutor_ui.py` | 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?
```powershell
.\.venv\Scripts\python -m entradas.comandos.doctor
```
Comprueba 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.
```text
[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
```bash
docker build -t android-mcp-harness .
docker run --rm android-mcp-harness
```
El 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.
```powershell
# 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.py
```
### Conectarlo a un cliente MCP
```json
{
"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`](docs/reference-flow.example.json): los
selectores deben salir de `ui.get_tree` de tu propia app, no inventarse.
```powershell
.\.venv\Scripts\python -m entradas.comandos.cliente_referencia docs/reference-flow.example.json
```
El 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`](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.
```powershell
# 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 -v
```
La 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`](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? |
|---|---|---|
| [`ci.yml`](.github/workflows/ci.yml) | Cada push y cada PR | Sí — banco unitario en Python 3.12 y 3.13, más el catálogo MCP por stdio real |
| [`eca.yml`](.github/workflows/eca.yml) | 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`, ni `resource-id` sigue sin
ser accionable por semántica — y eso es un problema de la app, no del arnés.
- ⚠️ **`ui.scroll` solo publica `up` y `down`.** 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.open`
devuelve un identificador opaco para `ui.tap`, `ui.type_text`, `ui.scroll` y
`device.back`; la sesión caduca tras 60 s sin uso (configurable con
`ANDROID_MCP_FLOW_IDLE_TIMEOUT`) y se puede liberar antes con
`ui.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, devuelve
`OPERATION_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
- [x] Guardias de configuración aplicados en **todas** las puertas, no solo en las de lectura
- [x] Contrato FASER al día con las doce herramientas
- [x] Espera activa antes de declarar que una app no se abrió
- [x] Árbol UI resumido y filtrado en vez de XML crudo
- [x] Evidencia legible como recurso MCP, no como ruta
- [x] Segundo nivel de API en la campaña
- [ ] Un AVD con capa de fabricante, no solo imágenes de Google
- [x] 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.scroll` horizontal en un carrusel real
- [x] Encadenar acciones sin perder el estado entre sesiones
- [x] `ui.tap` capaz de desambiguar sin recurrir a coordenadas
- [x] Un cliente de referencia que recorra una app real de principio a fin
---
## Licencia
MIT.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues