android-layout-inspector-mcp
by Rixmerz
README.md
# android-layout-inspector-mcp
Spatial inspection of a **live Android UI** for AI agents. Pulls the real view
hierarchy off a device or emulator over `adb` + `uiautomator`, and reports
layout problems as **measured geometry** — overlap area in px², touch targets
in dp, clipped fractions — not as an opinion about a screenshot.
The Android counterpart of
[layout-inspector-mcp](https://github.com/Rixmerz/layout-inspector-mcp), which
does the same job for web pages via Playwright.
## Why
An agent that changes a layout has no way to know whether the result overlaps,
clips, or leaves a control too small to tap. Screenshots are the obvious answer
and the wrong one: judging a 4px collision by looking at pixels is guesswork,
and legitimate overlays get reported as bugs.
This measures instead. Every node's bounds, cross-referenced for intersections,
with the overlap area in px² and touch targets converted to dp against the
device's real density. Deterministic and reproducible: it tells you *which two
nodes* collide, not "something looks off". Use a screenshot afterwards to
confirm a finding looks wrong to a human, not to find it.
No Android Studio required.
## Requirements
- Python 3.11+
- [`uv`](https://docs.astral.sh/uv/) — used by the plugin launch path
- `adb` on `PATH`, or `ADB_PATH`, or `ANDROID_HOME` pointing at an SDK that
contains `platform-tools/adb`
- A device with USB debugging on, or a running emulator
## Install as a plugin
```bash
claude plugin install android-layout-inspector --marketplace Rixmerz/claude-plugins
```
## Install as a plain MCP server
```bash
git clone https://github.com/Rixmerz/android-layout-inspector-mcp.git
cd android-layout-inspector-mcp
uv sync
claude mcp add android-layout-inspector -- uv run --project "$PWD" android-layout-inspector-mcp
```
## Tools
| Tool | Purpose |
| --- | --- |
| `detect_issues(device?, xml_path?)` | The main one. Every check below, sorted by severity, each with the geometry that produced it. |
| `inspect_layout(device?, xml_path?, interactive_only?, max_nodes?)` | Full computed hierarchy: bounds in px and dp, class, ids, flags. |
| `element_context(selector, device?, xml_path?)` | Deep dive on one node — ancestry, siblings, children, and what collides with it. Matches a resource-id, then exact text, then a substring. |
| `accessibility_spatial(device?, xml_path?)` | Touch targets under 48dp, interactive nodes covered by something else, clickables a screen reader cannot announce. |
| `compare_orientations(device?)` | Runs the checks in portrait and landscape and diffs them. The Android answer to responsive breakpoints. |
| `list_devices()` | Attached devices and their adb state. |
Every analysis tool accepts `xml_path` instead of a device, so a saved
`uiautomator` dump can be analysed in CI with no hardware attached.
## What it detects
| Check | Fires when |
| --- | --- |
| `overlap` | Two unrelated **leaf** nodes with content collide. Ancestor/descendant overlap is the layout working, not a bug, and is never reported. Sub-4px² and sub-2% intersections are treated as rounding. |
| `clipped` / `offscreen` | A node with content falls partly or wholly outside the viewport. |
| `zero_size` | A node carries text, a description or a click handler but was laid out with no area. |
| `small_touch_target` | An enabled interactive node is under **48dp** on either axis — Material's minimum, not the web's 44px. |
| `occluded_interactive` | A clickable node is fully covered by an unrelated node drawn *after* it, so the tap never reaches it. |
| `unlabeled_interactive` | A clickable node has no text, no `content-desc`, and no labeled descendant, so TalkBack announces nothing. |
| `compose_not_inspectable` | See below. |
## Jetpack Compose: the one thing you must do
By default a Compose screen collapses into a handful of anonymous
`android.view.View` nodes. The geometry is real, but **nothing can be selected
by id**, which makes every finding much harder to act on. Add this to your root
composable:
```kotlin
@OptIn(ExperimentalComposeUiApi::class)
Surface(
modifier = Modifier
.fillMaxSize()
.semantics { testTagsAsResourceId = true }
) { /* ... */ }
```
Then every `Modifier.testTag("boton_sumar")` shows up as
`resource-id="boton_sumar"`. `detect_issues` raises
`compose_not_inspectable` when it sees a Compose tree that skipped this.
## What this deliberately does not do
Three things are measurable on the web and are **not** recoverable from a
`uiautomator` dump. They are left out rather than approximated:
**Text truncation.** `uiautomator` reports the full string even when the view
ellipsized it — verified on both a classic `TextView` with
`android:ellipsize="end"` and a Compose `Text` with `TextOverflow.Ellipsis`.
There is no signal in the dump, so no check pretends to find one.
**Fully off-screen nodes on a live device.** Android drops them from the
hierarchy before `uiautomator` ever sees them. The `offscreen` check still
fires on saved dumps and on partially clipped nodes, which is the case that
actually reaches the dump.
**Real occlusion by opacity.** Draw order and containment are known; alpha and
elevation are not. `occluded_interactive` requires full geometric containment
by a later sibling, which is conservative on purpose.
One more caveat worth knowing: **Compose already expands every clickable to
48dp** for accessibility, so `small_touch_target` mostly catches classic View
layouts and code that overrides `LocalMinimumInteractiveComponentSize`.
## Verified
`uv run pytest` — unit checks on crafted hierarchies plus a fixture captured
from a real emulator.
End to end against a Pixel 8 AVD (API 36, 1080x2400 @ 420dpi):
`overlap` (15211px², 74% of the smaller node), `unlabeled_interactive` on two
Compose buttons, `small_touch_target` on a 24x24dp `Button` in a classic View
layout, and `compare_orientations` correctly reading 1080x2400 in portrait and
2400x1080 in landscape.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues