android-driver 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- android_driver/__init__.py +3 -0
- android_driver/actions.py +209 -0
- android_driver/adb.py +355 -0
- android_driver/build.py +81 -0
- android_driver/config.py +211 -0
- android_driver/drivers/__init__.py +22 -0
- android_driver/drivers/adb_driver.py +104 -0
- android_driver/drivers/base.py +146 -0
- android_driver/drivers/factory.py +29 -0
- android_driver/drivers/u2_driver.py +100 -0
- android_driver/emulator.py +277 -0
- android_driver/expect.py +190 -0
- android_driver/log.py +16 -0
- android_driver/recipes.py +547 -0
- android_driver/record.py +112 -0
- android_driver/run.py +292 -0
- android_driver/scan.py +155 -0
- android_driver/server.py +777 -0
- android_driver/session.py +144 -0
- android_driver/ui.py +261 -0
- android_driver-0.0.1.dist-info/METADATA +270 -0
- android_driver-0.0.1.dist-info/RECORD +25 -0
- android_driver-0.0.1.dist-info/WHEEL +4 -0
- android_driver-0.0.1.dist-info/entry_points.txt +2 -0
- android_driver-0.0.1.dist-info/licenses/LICENSE +21 -0
android_driver/server.py
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
"""MCP entrypoint. Thin registrations only — the logic lives in the modules below.
|
|
2
|
+
|
|
3
|
+
Return-shape convention:
|
|
4
|
+
* action tools return `{"ok": bool, "error"?: str, ...}` so an agent can branch
|
|
5
|
+
without exception handling;
|
|
6
|
+
* assertions add `"passed"`, which mirrors `ok`;
|
|
7
|
+
* read tools (`screen`, `logcat_read`, `dump_ui_xml`) return their natural type.
|
|
8
|
+
|
|
9
|
+
Every device-touching failure captures a screenshot and a hierarchy dump on its
|
|
10
|
+
way out, into the open run's directory or `runs/failures/` — evidence you did not
|
|
11
|
+
have to think to collect is the only kind that survives a long agent session.
|
|
12
|
+
|
|
13
|
+
stdout belongs to the JSON-RPC frame. Every diagnostic goes to stderr via `log`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from mcp.server.fastmcp import FastMCP, Image
|
|
23
|
+
|
|
24
|
+
from . import actions, adb, emulator, expect, scan, ui
|
|
25
|
+
from . import config as config_mod
|
|
26
|
+
from . import recipes as recipes_mod
|
|
27
|
+
from .log import log
|
|
28
|
+
from .record import Recorder
|
|
29
|
+
from .run import Runs
|
|
30
|
+
from .session import Session
|
|
31
|
+
|
|
32
|
+
mcp = FastMCP("android-driver")
|
|
33
|
+
|
|
34
|
+
CFG = config_mod.load()
|
|
35
|
+
SESSION = Session(CFG)
|
|
36
|
+
RUNS = Runs(CFG)
|
|
37
|
+
RECORDER = Recorder()
|
|
38
|
+
RECIPES: dict[str, recipes_mod.Recipe] = {}
|
|
39
|
+
_SELECTORS: scan.Selectors | None = None
|
|
40
|
+
# Names this server registered for recipes, so `reload_config` can retire them.
|
|
41
|
+
_RECIPE_TOOLS: list[str] = []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ok(**fields: Any) -> dict[str, Any]:
|
|
45
|
+
return {"ok": True, **fields}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _err(exc: BaseException) -> dict[str, Any]:
|
|
49
|
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _act(tool: str, fn, *, artifacts: bool = False) -> dict[str, Any]:
|
|
53
|
+
"""Run one action: time it, record it on the open run, capture evidence if it fails."""
|
|
54
|
+
started = time.monotonic()
|
|
55
|
+
try:
|
|
56
|
+
payload = fn()
|
|
57
|
+
payload = payload if isinstance(payload, dict) else ({} if payload is None else {"result": payload})
|
|
58
|
+
if payload.get("ok") is False: # an assertion that legitimately failed
|
|
59
|
+
evidence = RUNS.capture(SESSION, tool) if artifacts else {}
|
|
60
|
+
payload = {**payload, **evidence}
|
|
61
|
+
RUNS.record_event(tool, "failed", time.monotonic() - started, recipes_mod.summarize(payload))
|
|
62
|
+
return payload
|
|
63
|
+
payload.pop("ok", None)
|
|
64
|
+
RUNS.record_event(tool, "ok", time.monotonic() - started, recipes_mod.summarize(payload))
|
|
65
|
+
return _ok(**payload)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
duration = time.monotonic() - started
|
|
68
|
+
evidence = RUNS.capture(SESSION, tool) if artifacts else {}
|
|
69
|
+
RUNS.record_event(tool, "failed", duration, {"error": f"{type(e).__name__}: {e}", **evidence})
|
|
70
|
+
return {**_err(e), **evidence}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _runner() -> recipes_mod.Runner:
|
|
74
|
+
return recipes_mod.Runner(recipes_mod.Context(SESSION, CFG, RUNS), RECIPES)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _selectors(refresh: bool = False) -> scan.Selectors:
|
|
78
|
+
global _SELECTORS
|
|
79
|
+
if _SELECTORS is None or refresh:
|
|
80
|
+
_SELECTORS = scan.scan(CFG)
|
|
81
|
+
return _SELECTORS
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── emulator lifecycle ────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@mcp.tool()
|
|
88
|
+
def list_avds() -> dict[str, Any]:
|
|
89
|
+
"""List every Android Virtual Device configured on this machine."""
|
|
90
|
+
return _act("list_avds", lambda: {"avds": emulator.list_avds(), "running": emulator.running_emulators()})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@mcp.tool()
|
|
94
|
+
def start_emulator(
|
|
95
|
+
avd: str,
|
|
96
|
+
headless: bool = False,
|
|
97
|
+
cold_boot: bool = False,
|
|
98
|
+
wipe_data: bool = False,
|
|
99
|
+
snapshot: str | None = None,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""Boot an AVD and wait until it is fully usable, then select it for this session.
|
|
102
|
+
|
|
103
|
+
Returns the emulator's serial. If the AVD is already running, reuses it.
|
|
104
|
+
`cold_boot` skips the saved quick-boot state; `wipe_data` factory-resets first.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def run() -> dict[str, Any]:
|
|
108
|
+
result = emulator.start(
|
|
109
|
+
avd,
|
|
110
|
+
headless=headless,
|
|
111
|
+
cold_boot=cold_boot,
|
|
112
|
+
wipe_data=wipe_data,
|
|
113
|
+
snapshot=snapshot,
|
|
114
|
+
boot_timeout_s=CFG.timing.boot_timeout_s,
|
|
115
|
+
)
|
|
116
|
+
if result.get("ok") and result.get("serial"):
|
|
117
|
+
SESSION.select(result["serial"])
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
return _act("start_emulator", run)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@mcp.tool()
|
|
124
|
+
def stop_emulator(serial: str | None = None) -> dict[str, Any]:
|
|
125
|
+
"""Shut down a running emulator (defaults to the selected device)."""
|
|
126
|
+
|
|
127
|
+
def run() -> dict[str, Any]:
|
|
128
|
+
target = serial or SESSION.serial
|
|
129
|
+
result = emulator.stop(target)
|
|
130
|
+
if target == SESSION.current_serial:
|
|
131
|
+
# The device this session is holding a connection to is now gone.
|
|
132
|
+
SESSION.reconnect()
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
return _act("stop_emulator", run)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@mcp.tool()
|
|
139
|
+
def wait_for_boot(serial: str | None = None, timeout_s: int | None = None) -> dict[str, Any]:
|
|
140
|
+
"""Block until the device finishes booting (framework up and boot animation done)."""
|
|
141
|
+
return _act(
|
|
142
|
+
"wait_for_boot",
|
|
143
|
+
lambda: emulator.wait_for_boot(serial or SESSION.serial, timeout_s or CFG.timing.boot_timeout_s),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@mcp.tool()
|
|
148
|
+
def snapshot_save(name: str) -> dict[str, Any]:
|
|
149
|
+
"""Freeze the emulator's exact current state under `name`.
|
|
150
|
+
|
|
151
|
+
Save one right after the app is installed and sitting on the screen your test
|
|
152
|
+
starts from. Restoring it later is 10-30x faster than reinstalling and
|
|
153
|
+
re-navigating, and it is byte-identical every time — which is what makes an
|
|
154
|
+
intermittent bug reproducible.
|
|
155
|
+
"""
|
|
156
|
+
return _act("snapshot_save", lambda: emulator.snapshot_save(SESSION.serial, name))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@mcp.tool()
|
|
160
|
+
def snapshot_load(name: str) -> dict[str, Any]:
|
|
161
|
+
"""Restore the emulator to a previously saved snapshot, and wait until it is drivable.
|
|
162
|
+
|
|
163
|
+
Two things to know. The restore rewinds the device's clock to the moment the
|
|
164
|
+
snapshot was saved, so logcat timestamps afterwards run behind the host's —
|
|
165
|
+
compare them to each other, not to your watch. And the restored image carries
|
|
166
|
+
the log buffer it was saved with, so open the run *after* this call:
|
|
167
|
+
`run_start` clears logcat, and a crash that predates the snapshot would
|
|
168
|
+
otherwise be re-reported on every single attempt.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def run() -> dict[str, Any]:
|
|
172
|
+
result = emulator.snapshot_load(SESSION.serial, name)
|
|
173
|
+
SESSION.reconnect()
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
return _act("snapshot_load", run)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@mcp.tool()
|
|
180
|
+
def snapshot_list() -> dict[str, Any]:
|
|
181
|
+
"""List snapshots saved for the running emulator."""
|
|
182
|
+
return _act("snapshot_list", lambda: {"snapshots": emulator.snapshot_list(SESSION.serial)})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@mcp.tool()
|
|
186
|
+
def snapshot_delete(name: str) -> dict[str, Any]:
|
|
187
|
+
"""Delete a saved snapshot."""
|
|
188
|
+
return _act("snapshot_delete", lambda: emulator.snapshot_delete(SESSION.serial, name))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ── device ────────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mcp.tool()
|
|
195
|
+
def list_devices() -> dict[str, Any]:
|
|
196
|
+
"""List attached devices and emulators in state `device`."""
|
|
197
|
+
return _act("list_devices", lambda: {"devices": adb.list_devices(), "selected": SESSION.current_serial})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@mcp.tool()
|
|
201
|
+
def select_device(serial: str) -> dict[str, Any]:
|
|
202
|
+
"""Pin a device for the rest of this session."""
|
|
203
|
+
return _act("select_device", lambda: (SESSION.select(serial), {"serial": serial})[1])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@mcp.tool()
|
|
207
|
+
def device_info() -> dict[str, Any]:
|
|
208
|
+
"""Model, Android version, ABI, screen size and density of the selected device."""
|
|
209
|
+
return _act("device_info", lambda: adb.device_info(SESSION.serial))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── app lifecycle ─────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@mcp.tool()
|
|
216
|
+
def build_app() -> dict[str, Any]:
|
|
217
|
+
"""Run the project's configured build command and return the APK it produced."""
|
|
218
|
+
return _act("build_app", lambda: actions.build_app(CFG))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@mcp.tool()
|
|
222
|
+
def install_app(
|
|
223
|
+
apk_path: str | None = None, build_first: bool = False, pkg: str | None = None
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Install the app: force-stop, uninstall, install, grant runtime permissions, verify.
|
|
226
|
+
|
|
227
|
+
Uninstall-then-install is the default because debug APKs from different
|
|
228
|
+
branches carry different signing keys, and reinstalling over one with the
|
|
229
|
+
other fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE.
|
|
230
|
+
|
|
231
|
+
`pkg` overrides the configured package, so an explicit APK can be installed
|
|
232
|
+
with no project config at all.
|
|
233
|
+
"""
|
|
234
|
+
return _act("install_app", lambda: actions.install_app(SESSION, CFG, apk_path, build_first, pkg))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@mcp.tool()
|
|
238
|
+
def uninstall_app(pkg: str | None = None) -> dict[str, Any]:
|
|
239
|
+
"""Uninstall a package (defaults to the configured app)."""
|
|
240
|
+
return _act("uninstall_app", lambda: actions.uninstall_app(SESSION, CFG, pkg))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@mcp.tool()
|
|
244
|
+
def app_info(pkg: str | None = None) -> dict[str, Any]:
|
|
245
|
+
"""Whether the app is installed, and its version metadata."""
|
|
246
|
+
return _act("app_info", lambda: adb.app_info(SESSION.serial, pkg or CFG.package))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@mcp.tool()
|
|
250
|
+
def launch_app(pkg: str | None = None, cold: bool = True) -> dict[str, Any]:
|
|
251
|
+
"""Start the app and wait out cold-start rendering.
|
|
252
|
+
|
|
253
|
+
`cold=True` force-stops first, so the app really starts from scratch rather
|
|
254
|
+
than resuming whatever screen it was left on.
|
|
255
|
+
"""
|
|
256
|
+
return _act("launch_app", lambda: actions.launch_app(SESSION, CFG, pkg, cold), artifacts=True)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@mcp.tool()
|
|
260
|
+
def force_stop(pkg: str | None = None) -> dict[str, Any]:
|
|
261
|
+
"""Kill every process of the app."""
|
|
262
|
+
return _act("force_stop", lambda: actions.force_stop(SESSION, CFG, pkg))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@mcp.tool()
|
|
266
|
+
def clear_app_data(pkg: str | None = None) -> dict[str, Any]:
|
|
267
|
+
"""Wipe the app's data and cache, returning it to first-launch state."""
|
|
268
|
+
return _act("clear_app_data", lambda: actions.clear_app_data(SESSION, CFG, pkg))
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ── UI ────────────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@mcp.tool()
|
|
275
|
+
def screen() -> str:
|
|
276
|
+
"""Read the current screen as a compact list of interactive and readable elements.
|
|
277
|
+
|
|
278
|
+
Each line carries a `#N` reference you can pass straight to `tap`:
|
|
279
|
+
|
|
280
|
+
#1 [Button] "Sign in" desc=login_button @(540,1320)
|
|
281
|
+
#2 [EditText] "" hint="Email" @(540,980)
|
|
282
|
+
|
|
283
|
+
Call this before interacting with an unfamiliar screen. For the raw
|
|
284
|
+
accessibility tree, use `dump_ui_xml` — but it is very large, so prefer this.
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
return ui.render(SESSION.refresh(), header=SESSION.header())
|
|
288
|
+
except Exception as e:
|
|
289
|
+
return f"error: {type(e).__name__}: {e}"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@mcp.tool()
|
|
293
|
+
def tap(
|
|
294
|
+
ref: str | None = None,
|
|
295
|
+
text: str | None = None,
|
|
296
|
+
contains: str | None = None,
|
|
297
|
+
desc: str | None = None,
|
|
298
|
+
id: str | None = None,
|
|
299
|
+
index: int = 0,
|
|
300
|
+
) -> dict[str, Any]:
|
|
301
|
+
"""Tap an element. Give exactly one selector.
|
|
302
|
+
|
|
303
|
+
`ref` is a `#N` from `screen`. `text` and `desc` match exactly; `contains`
|
|
304
|
+
matches a substring of either, case-insensitively. `index` picks among
|
|
305
|
+
multiple matches.
|
|
306
|
+
"""
|
|
307
|
+
return _act(
|
|
308
|
+
"tap",
|
|
309
|
+
lambda: actions.tap(SESSION, ref=ref, text=text, contains=contains, desc=desc, rid=id, index=index),
|
|
310
|
+
artifacts=True,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@mcp.tool()
|
|
315
|
+
def tap_xy(x: int, y: int) -> dict[str, Any]:
|
|
316
|
+
"""Tap raw device coordinates. Prefer `tap` with a selector where possible."""
|
|
317
|
+
return _act("tap_xy", lambda: actions.tap_xy(SESSION, x, y), artifacts=True)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@mcp.tool()
|
|
321
|
+
def long_press(
|
|
322
|
+
ref: str | None = None,
|
|
323
|
+
text: str | None = None,
|
|
324
|
+
contains: str | None = None,
|
|
325
|
+
desc: str | None = None,
|
|
326
|
+
id: str | None = None,
|
|
327
|
+
duration_s: float = 1.0,
|
|
328
|
+
index: int = 0,
|
|
329
|
+
) -> dict[str, Any]:
|
|
330
|
+
"""Press and hold an element — context menus, drag handles, multi-select."""
|
|
331
|
+
return _act(
|
|
332
|
+
"long_press",
|
|
333
|
+
lambda: actions.long_press(
|
|
334
|
+
SESSION, ref=ref, text=text, contains=contains, desc=desc, rid=id,
|
|
335
|
+
duration_s=duration_s, index=index,
|
|
336
|
+
),
|
|
337
|
+
artifacts=True,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@mcp.tool()
|
|
342
|
+
def type_text(
|
|
343
|
+
text: str,
|
|
344
|
+
ref: str | None = None,
|
|
345
|
+
desc: str | None = None,
|
|
346
|
+
id: str | None = None,
|
|
347
|
+
contains: str | None = None,
|
|
348
|
+
index: int = 0,
|
|
349
|
+
) -> dict[str, Any]:
|
|
350
|
+
"""Replace a text field's contents with `text`, then close the keyboard.
|
|
351
|
+
|
|
352
|
+
Targets the field directly rather than tapping and typing, which is the only
|
|
353
|
+
reliable approach on Jetpack Compose — a tap does not always move focus and
|
|
354
|
+
the text can land in the wrong field.
|
|
355
|
+
"""
|
|
356
|
+
return _act(
|
|
357
|
+
"type_text",
|
|
358
|
+
lambda: actions.type_text(SESSION, text, ref=ref, desc=desc, rid=id, contains=contains, index=index),
|
|
359
|
+
artifacts=True,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@mcp.tool()
|
|
364
|
+
def swipe(direction: str = "up", distance: float = 0.6, duration_s: float = 0.3) -> dict[str, Any]:
|
|
365
|
+
"""Swipe across the middle of the screen. `direction`: up, down, left, right.
|
|
366
|
+
|
|
367
|
+
`up` scrolls content downward (the usual "show me more"). `distance` is a
|
|
368
|
+
fraction of the screen.
|
|
369
|
+
"""
|
|
370
|
+
return _act("swipe", lambda: actions.swipe(SESSION, direction, distance, duration_s))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@mcp.tool()
|
|
374
|
+
def scroll_to(
|
|
375
|
+
text: str | None = None,
|
|
376
|
+
contains: str | None = None,
|
|
377
|
+
desc: str | None = None,
|
|
378
|
+
id: str | None = None,
|
|
379
|
+
direction: str = "up",
|
|
380
|
+
max_swipes: int = 8,
|
|
381
|
+
) -> dict[str, Any]:
|
|
382
|
+
"""Swipe until an element comes into view, then stop.
|
|
383
|
+
|
|
384
|
+
Works on both backends — it is a swipe loop, not a driver feature — so a
|
|
385
|
+
target below the fold does not need coordinates worked out by hand.
|
|
386
|
+
"""
|
|
387
|
+
return _act(
|
|
388
|
+
"scroll_to",
|
|
389
|
+
lambda: actions.scroll_to(
|
|
390
|
+
SESSION, text=text, contains=contains, desc=desc, rid=id,
|
|
391
|
+
direction=direction, max_swipes=max_swipes,
|
|
392
|
+
),
|
|
393
|
+
artifacts=True,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@mcp.tool()
|
|
398
|
+
def press_key(key: str) -> dict[str, Any]:
|
|
399
|
+
"""Send a hardware key: back, home, enter, recent, volume_up, delete, or a raw KEYCODE_*."""
|
|
400
|
+
return _act("press_key", lambda: actions.press_key(SESSION, key))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@mcp.tool()
|
|
404
|
+
def screenshot(name: str | None = None) -> Image:
|
|
405
|
+
"""Capture the screen as a PNG. Use `screen` first — it is far cheaper for finding elements.
|
|
406
|
+
|
|
407
|
+
Inside an open run the file lands in that run's directory, so it ends up in
|
|
408
|
+
the report without any extra bookkeeping.
|
|
409
|
+
"""
|
|
410
|
+
stem = name or f"screen-{int(time.time() * 1000) % 100000}"
|
|
411
|
+
path = RUNS.artifact_dir("screenshots") / f"{stem}.png"
|
|
412
|
+
actions.screenshot(SESSION, path)
|
|
413
|
+
RUNS.record_event("screenshot", "ok", 0.0, {"screenshot": str(path)})
|
|
414
|
+
return Image(path=str(path), format="png")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@mcp.tool()
|
|
418
|
+
def dump_ui_xml() -> str:
|
|
419
|
+
"""The raw accessibility hierarchy XML.
|
|
420
|
+
|
|
421
|
+
Large (tens of thousands of tokens on a busy screen). Use `screen` unless you
|
|
422
|
+
specifically need attributes it does not surface.
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
return SESSION.driver.dump_hierarchy()
|
|
426
|
+
except Exception as e:
|
|
427
|
+
return f"error: {type(e).__name__}: {e}"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ── assertions ────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@mcp.tool()
|
|
434
|
+
def expect_visible(
|
|
435
|
+
text: str | None = None,
|
|
436
|
+
contains: str | None = None,
|
|
437
|
+
desc: str | None = None,
|
|
438
|
+
id: str | None = None,
|
|
439
|
+
cls: str | None = None,
|
|
440
|
+
timeout_s: float = 10.0,
|
|
441
|
+
) -> dict[str, Any]:
|
|
442
|
+
"""Assert an element appears within `timeout_s`. Polls, so it is safe right after a tap.
|
|
443
|
+
|
|
444
|
+
On failure the result carries the screen index that *was* there, so you can
|
|
445
|
+
see what the app actually showed without another call.
|
|
446
|
+
"""
|
|
447
|
+
return _act(
|
|
448
|
+
"expect_visible",
|
|
449
|
+
lambda: expect.visible(
|
|
450
|
+
SESSION, timeout_s, text=text, contains=contains, desc=desc, rid=id, cls=cls
|
|
451
|
+
),
|
|
452
|
+
artifacts=True,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@mcp.tool()
|
|
457
|
+
def expect_gone(
|
|
458
|
+
text: str | None = None,
|
|
459
|
+
contains: str | None = None,
|
|
460
|
+
desc: str | None = None,
|
|
461
|
+
id: str | None = None,
|
|
462
|
+
cls: str | None = None,
|
|
463
|
+
timeout_s: float = 10.0,
|
|
464
|
+
) -> dict[str, Any]:
|
|
465
|
+
"""Assert an element disappears within `timeout_s` — dialogs, spinners, toasts."""
|
|
466
|
+
return _act(
|
|
467
|
+
"expect_gone",
|
|
468
|
+
lambda: expect.gone(SESSION, timeout_s, text=text, contains=contains, desc=desc, rid=id, cls=cls),
|
|
469
|
+
artifacts=True,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@mcp.tool()
|
|
474
|
+
def expect_log(
|
|
475
|
+
pattern: str,
|
|
476
|
+
timeout_s: float = 30.0,
|
|
477
|
+
only_app: bool = True,
|
|
478
|
+
level: str | None = None,
|
|
479
|
+
) -> dict[str, Any]:
|
|
480
|
+
"""Assert a logcat line matching the regex `pattern` shows up within `timeout_s`.
|
|
481
|
+
|
|
482
|
+
Clear the buffer first (`logcat_clear`, or open a run) — otherwise a match
|
|
483
|
+
left over from an earlier attempt passes this trivially.
|
|
484
|
+
"""
|
|
485
|
+
return _act(
|
|
486
|
+
"expect_log",
|
|
487
|
+
lambda: expect.log_matches(SESSION, CFG, pattern, timeout_s, only_app=only_app, level=level),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@mcp.tool()
|
|
492
|
+
def expect_no_crash(pkg: str | None = None, lines: int = 4000) -> dict[str, Any]:
|
|
493
|
+
"""Assert the app did not crash: no fatal exception, ANR, native abort or tombstone.
|
|
494
|
+
|
|
495
|
+
Reads the `crash` buffer as well as `main`, because a native abort never
|
|
496
|
+
reaches `main` at all.
|
|
497
|
+
"""
|
|
498
|
+
return _act("expect_no_crash", lambda: expect.no_crash(SESSION, CFG, pkg, lines))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ── runs ──────────────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@mcp.tool()
|
|
505
|
+
def run_start(name: str, note: str = "", clear_log: bool = True) -> dict[str, Any]:
|
|
506
|
+
"""Open a run: from here every action is timed and recorded, and failures save evidence.
|
|
507
|
+
|
|
508
|
+
Clears logcat by default so the run's log slice covers exactly this attempt.
|
|
509
|
+
Close it with `run_end`, which writes `report.md` and `timeline.json`.
|
|
510
|
+
"""
|
|
511
|
+
return _act("run_start", lambda: RUNS.start(SESSION, name, note, clear_log))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@mcp.tool()
|
|
515
|
+
def run_end() -> dict[str, Any]:
|
|
516
|
+
"""Close the open run: write the logcat slice, timeline and report; return the verdict."""
|
|
517
|
+
return _act("run_end", lambda: RUNS.end(SESSION))
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@mcp.tool()
|
|
521
|
+
def run_list(limit: int = 20) -> dict[str, Any]:
|
|
522
|
+
"""List previous runs, newest first, with their pass/fail verdict."""
|
|
523
|
+
return _act("run_list", lambda: {"runs": RUNS.list_runs(limit)})
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@mcp.tool()
|
|
527
|
+
def record_start(
|
|
528
|
+
name: str | None = None,
|
|
529
|
+
bit_rate_mbps: float = 4.0,
|
|
530
|
+
size: str | None = None,
|
|
531
|
+
time_limit_s: int = 180,
|
|
532
|
+
) -> dict[str, Any]:
|
|
533
|
+
"""Start recording the screen. `screenrecord` caps a clip at 180 seconds."""
|
|
534
|
+
return _act(
|
|
535
|
+
"record_start",
|
|
536
|
+
lambda: RECORDER.start(
|
|
537
|
+
SESSION.serial, name=name, bit_rate_mbps=bit_rate_mbps, size=size, time_limit_s=time_limit_s
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@mcp.tool()
|
|
543
|
+
def record_stop(name: str | None = None) -> dict[str, Any]:
|
|
544
|
+
"""Stop recording and pull the MP4 into the open run's directory (or `runs/`)."""
|
|
545
|
+
|
|
546
|
+
def run() -> dict[str, Any]:
|
|
547
|
+
stem = name or "recording"
|
|
548
|
+
dest = RUNS.artifact_dir("recordings") / f"{stem}.mp4"
|
|
549
|
+
result = RECORDER.stop(dest)
|
|
550
|
+
if RUNS.current is not None:
|
|
551
|
+
RUNS.current.recording = result
|
|
552
|
+
return result
|
|
553
|
+
|
|
554
|
+
return _act("record_stop", run)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ── recipes and selectors ─────────────────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@mcp.tool()
|
|
561
|
+
def list_recipes() -> dict[str, Any]:
|
|
562
|
+
"""The project's configured flows, with their parameters and steps."""
|
|
563
|
+
return _ok(
|
|
564
|
+
recipes=[
|
|
565
|
+
{
|
|
566
|
+
"name": r.name,
|
|
567
|
+
"description": r.description,
|
|
568
|
+
"params": [
|
|
569
|
+
{"name": p.name, "type": p.type, "required": p.required, "default": p.default}
|
|
570
|
+
for p in r.params
|
|
571
|
+
],
|
|
572
|
+
"steps": [s.name for s in r.steps],
|
|
573
|
+
}
|
|
574
|
+
for r in RECIPES.values()
|
|
575
|
+
]
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@mcp.tool()
|
|
580
|
+
def run_recipe(name: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
581
|
+
"""Run a configured flow by name.
|
|
582
|
+
|
|
583
|
+
Each recipe is also registered as its own tool with typed parameters; this is
|
|
584
|
+
the generic escape hatch for building a call programmatically.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
def run() -> dict[str, Any]:
|
|
588
|
+
if name not in RECIPES:
|
|
589
|
+
raise KeyError(f"no recipe named {name!r}. Known: {sorted(RECIPES) or '(none configured)'}")
|
|
590
|
+
return _runner().run(RECIPES[name], params or {})
|
|
591
|
+
|
|
592
|
+
return _act(f"recipe:{name}", run)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@mcp.tool()
|
|
596
|
+
def reload_config() -> dict[str, Any]:
|
|
597
|
+
"""Re-read the project config and recipes without restarting the server.
|
|
598
|
+
|
|
599
|
+
The config is read once at startup, so editing `.android-driver.yaml` — or
|
|
600
|
+
creating one — otherwise needs an MCP reconnect before anything picks it up.
|
|
601
|
+
Recipe tools are re-registered here, but most clients cache the tool list, so
|
|
602
|
+
a recipe you have just *added* may still need a client-side refresh to appear.
|
|
603
|
+
The selected device is preserved.
|
|
604
|
+
"""
|
|
605
|
+
|
|
606
|
+
def run() -> dict[str, Any]:
|
|
607
|
+
global CFG, SESSION, RUNS, _SELECTORS
|
|
608
|
+
if RUNS.current is not None and not RUNS.current.finished:
|
|
609
|
+
raise RuntimeError(
|
|
610
|
+
f"run {RUNS.current.id!r} is still open; call `run_end` before reloading"
|
|
611
|
+
)
|
|
612
|
+
serial = SESSION.current_serial
|
|
613
|
+
CFG = config_mod.load()
|
|
614
|
+
SESSION = Session(CFG)
|
|
615
|
+
if serial:
|
|
616
|
+
try:
|
|
617
|
+
SESSION.select(serial)
|
|
618
|
+
except Exception as e:
|
|
619
|
+
log("config", f"could not re-select {serial}: {e}")
|
|
620
|
+
RUNS = Runs(CFG)
|
|
621
|
+
_SELECTORS = None
|
|
622
|
+
return {
|
|
623
|
+
"config": str(CFG.source) if CFG.source else "defaults (no config file found)",
|
|
624
|
+
"package": CFG.app.package,
|
|
625
|
+
"recipe_tools": register_recipes(),
|
|
626
|
+
"device": SESSION.current_serial,
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return _act("reload_config", run)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
@mcp.tool()
|
|
633
|
+
def list_selectors(kind: str | None = None, contains: str | None = None, limit: int = 200) -> dict[str, Any]:
|
|
634
|
+
"""Selector literals declared in the project's own sources.
|
|
635
|
+
|
|
636
|
+
Scans for `testTag` / `contentDescription` / `android:id` / string resources
|
|
637
|
+
so you can write a recipe against a name that exists instead of guessing one.
|
|
638
|
+
`kind` filters to tag, desc, id or text.
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
def run() -> dict[str, Any]:
|
|
642
|
+
found = _selectors()
|
|
643
|
+
data = found.to_dict()
|
|
644
|
+
if kind:
|
|
645
|
+
data = {k: v for k, v in data.items() if k == kind}
|
|
646
|
+
if contains:
|
|
647
|
+
needle = contains.lower()
|
|
648
|
+
data = {k: [s for s in v if needle in s.lower()] for k, v in data.items()}
|
|
649
|
+
return {
|
|
650
|
+
"files_scanned": found.files_scanned,
|
|
651
|
+
"selectors": {k: v[:limit] for k, v in data.items() if v},
|
|
652
|
+
"runtime_templates": sorted(found.templates)[:limit],
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return _act("list_selectors", run)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@mcp.tool()
|
|
659
|
+
def check_recipes() -> dict[str, Any]:
|
|
660
|
+
"""Cross-check every recipe's selectors against the project's sources.
|
|
661
|
+
|
|
662
|
+
Catches the rename that would otherwise surface as "element not found" three
|
|
663
|
+
steps into a flow.
|
|
664
|
+
"""
|
|
665
|
+
|
|
666
|
+
def run() -> dict[str, Any]:
|
|
667
|
+
warnings = scan.check_recipes(RECIPES, _selectors(refresh=True))
|
|
668
|
+
return {"recipes": len(RECIPES), "warnings": warnings, "clean": not warnings}
|
|
669
|
+
|
|
670
|
+
return _act("check_recipes", run)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ── logs and shell ────────────────────────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@mcp.tool()
|
|
677
|
+
def logcat_clear() -> dict[str, Any]:
|
|
678
|
+
"""Clear the log buffer. Call before an action you want a clean log for."""
|
|
679
|
+
return _act("logcat_clear", lambda: (adb.logcat_clear(SESSION.serial), {})[1])
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@mcp.tool()
|
|
683
|
+
def logcat_read(
|
|
684
|
+
lines: int = 300,
|
|
685
|
+
pattern: str | None = None,
|
|
686
|
+
only_app: bool = True,
|
|
687
|
+
level: str | None = None,
|
|
688
|
+
) -> str:
|
|
689
|
+
"""Read recent log lines. `pattern` is a regex; `level` is one of V D I W E F."""
|
|
690
|
+
try:
|
|
691
|
+
pkg = CFG.app.package if (only_app and CFG.app.package) else None
|
|
692
|
+
found = adb.logcat_dump(SESSION.serial, lines=lines, pkg=pkg, pattern=pattern, level=level)
|
|
693
|
+
return "\n".join(found) if found else "(no matching log lines)"
|
|
694
|
+
except Exception as e:
|
|
695
|
+
return f"error: {type(e).__name__}: {e}"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
@mcp.tool()
|
|
699
|
+
def shell(cmd: str) -> dict[str, Any]:
|
|
700
|
+
"""Run an arbitrary `adb shell` command. Pipes and quoting work.
|
|
701
|
+
|
|
702
|
+
Unrestricted by design: this is a development tool, not a sandbox. It can
|
|
703
|
+
modify or wipe anything on the device.
|
|
704
|
+
"""
|
|
705
|
+
return _act("shell", lambda: adb.shell_result(SESSION.serial, cmd))
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ── startup ───────────────────────────────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
RESERVED_TOOL_NAMES = (
|
|
712
|
+
"list_avds", "start_emulator", "stop_emulator", "wait_for_boot",
|
|
713
|
+
"snapshot_save", "snapshot_load", "snapshot_list", "snapshot_delete",
|
|
714
|
+
"list_devices", "select_device", "device_info",
|
|
715
|
+
"build_app", "install_app", "uninstall_app", "app_info", "launch_app",
|
|
716
|
+
"force_stop", "clear_app_data",
|
|
717
|
+
"screen", "tap", "tap_xy", "long_press", "type_text", "swipe", "scroll_to",
|
|
718
|
+
"press_key", "screenshot", "dump_ui_xml",
|
|
719
|
+
"expect_visible", "expect_gone", "expect_log", "expect_no_crash",
|
|
720
|
+
"run_start", "run_end", "run_list", "record_start", "record_stop",
|
|
721
|
+
"list_recipes", "run_recipe", "list_selectors", "check_recipes", "reload_config",
|
|
722
|
+
"logcat_clear", "logcat_read", "shell",
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def register_recipes() -> list[str]:
|
|
727
|
+
"""Register each configured recipe as its own MCP tool with typed parameters.
|
|
728
|
+
|
|
729
|
+
Existing recipe tools are retired first. FastMCP's `add_tool` returns the
|
|
730
|
+
*existing* tool when a name is already taken rather than replacing it, so
|
|
731
|
+
without this a reload would leave every recipe frozen at the definition the
|
|
732
|
+
server started with — the exact thing a reload is supposed to fix.
|
|
733
|
+
"""
|
|
734
|
+
global RECIPES
|
|
735
|
+
for name in _RECIPE_TOOLS:
|
|
736
|
+
try:
|
|
737
|
+
mcp._tool_manager.remove_tool(name) # no public API for this yet
|
|
738
|
+
except Exception as e:
|
|
739
|
+
log("recipes", f"could not retire the old {name!r} tool: {e}")
|
|
740
|
+
_RECIPE_TOOLS.clear()
|
|
741
|
+
RECIPES = recipes_mod.load_all(CFG)
|
|
742
|
+
reserved: set[str] = set(RESERVED_TOOL_NAMES)
|
|
743
|
+
registered: list[str] = []
|
|
744
|
+
for name, recipe in RECIPES.items():
|
|
745
|
+
tool_name = name if name not in reserved else f"recipe_{name}"
|
|
746
|
+
try:
|
|
747
|
+
mcp.add_tool(recipes_mod.build_tool(recipe, _runner), name=tool_name)
|
|
748
|
+
except Exception as e:
|
|
749
|
+
log("recipes", f"could not register {name!r} as a tool: {e}")
|
|
750
|
+
continue
|
|
751
|
+
reserved.add(tool_name)
|
|
752
|
+
registered.append(tool_name)
|
|
753
|
+
_RECIPE_TOOLS.append(tool_name)
|
|
754
|
+
if registered:
|
|
755
|
+
log("recipes", f"registered {len(registered)} recipe tool(s): {', '.join(registered)}")
|
|
756
|
+
return registered
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _warn_about_drift() -> None:
|
|
760
|
+
"""Scan sources for selector drift in the background — never block startup on it."""
|
|
761
|
+
try:
|
|
762
|
+
for warning in scan.check_recipes(RECIPES, _selectors()):
|
|
763
|
+
log("recipes", f"WARNING: {warning}")
|
|
764
|
+
except Exception as e:
|
|
765
|
+
log("recipes", f"selector check skipped: {e}")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def main() -> None:
|
|
769
|
+
log("server", f"android_driver starting (config={CFG.source or 'defaults'})")
|
|
770
|
+
register_recipes()
|
|
771
|
+
if RECIPES:
|
|
772
|
+
threading.Thread(target=_warn_about_drift, daemon=True).start()
|
|
773
|
+
mcp.run()
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
if __name__ == "__main__":
|
|
777
|
+
main()
|