device-devtools-mcp 0.1.0
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.
- package/AGENTS.md +327 -0
- package/LICENSE +21 -0
- package/README.md +491 -0
- package/VERSION +1 -0
- package/bin/device-devtools-mcp.js +106 -0
- package/bin/devicetools +315 -0
- package/config.example.json +63 -0
- package/integrations/agent-pointer.sh +48 -0
- package/integrations/claude/SKILL.md +8 -0
- package/integrations/cursor/devicetools.mdc +8 -0
- package/integrations/gemini/GEMINI.md +5 -0
- package/integrations/mcp/README.md +184 -0
- package/integrations/mcp/mcp.json +10 -0
- package/integrations/mcp/reference.sh +119 -0
- package/integrations/mcp/selftest.sh +158 -0
- package/integrations/mcp/server.sh +343 -0
- package/package.json +50 -0
- package/scripts/android/app.sh +241 -0
- package/scripts/android/back.sh +73 -0
- package/scripts/android/controls.sh +56 -0
- package/scripts/android/devices.sh +77 -0
- package/scripts/android/doctor.sh +253 -0
- package/scripts/android/lib.sh +699 -0
- package/scripts/android/logs.sh +289 -0
- package/scripts/android/permission.sh +119 -0
- package/scripts/android/settings.sh +63 -0
- package/scripts/android/setup.sh +97 -0
- package/scripts/android/tree.awk +166 -0
- package/scripts/android/tree.sh +127 -0
- package/scripts/android/type.sh +191 -0
- package/scripts/common/find.sh +95 -0
- package/scripts/common/key.sh +65 -0
- package/scripts/common/lib.sh +14 -0
- package/scripts/common/measure.sh +170 -0
- package/scripts/common/open.sh +131 -0
- package/scripts/common/screenshot.sh +102 -0
- package/scripts/common/scroll.sh +177 -0
- package/scripts/common/snapshot.sh +69 -0
- package/scripts/common/swipe.sh +171 -0
- package/scripts/common/tap.sh +226 -0
- package/scripts/common/wait.sh +212 -0
- package/scripts/common/waypoint.sh +141 -0
- package/scripts/dispatch.sh +21 -0
- package/scripts/flow.sh +266 -0
- package/scripts/init.sh +101 -0
- package/scripts/ios/app.sh +404 -0
- package/scripts/ios/back.sh +95 -0
- package/scripts/ios/controls.sh +68 -0
- package/scripts/ios/devices.sh +80 -0
- package/scripts/ios/doctor.sh +386 -0
- package/scripts/ios/lib.sh +864 -0
- package/scripts/ios/logs.sh +272 -0
- package/scripts/ios/permission.sh +108 -0
- package/scripts/ios/settings.sh +76 -0
- package/scripts/ios/setup.sh +175 -0
- package/scripts/ios/tree.sh +128 -0
- package/scripts/ios/type.sh +178 -0
- package/scripts/lib.sh +1032 -0
- package/scripts/links.tsv +44 -0
- package/scripts/relink.sh +121 -0
- package/scripts/run.sh +415 -0
- package/scripts/selftest.sh +1709 -0
- package/scripts/snapshot.awk +362 -0
- package/scripts/verify-npm-package.js +133 -0
- package/tests/fixtures/ios-contacts-list.expected +52 -0
- package/tests/fixtures/ios-contacts-list.rows +140 -0
package/README.md
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
# DeviceTools for agents
|
|
2
|
+
|
|
3
|
+
DeviceTools for agents (`device-devtools-mcp`) lets your coding agent (such as
|
|
4
|
+
Claude, Codex, Cursor or Copilot) control and inspect a real iOS or Android app
|
|
5
|
+
running on a phone plugged into your Mac. A CLI is also provided for use without
|
|
6
|
+
MCP.
|
|
7
|
+
|
|
8
|
+
Chrome DevTools MCP gives an agent eyes and hands on a web page. This is the
|
|
9
|
+
same thing for a native app, on hardware you can hold — and for a lot of teams
|
|
10
|
+
the phone is the only place the app builds at all.
|
|
11
|
+
|
|
12
|
+
## Key features
|
|
13
|
+
|
|
14
|
+
- **Reading, not looking**: `snapshot` returns every element that can be acted
|
|
15
|
+
on or carries text, with its rectangle. About a third the cost of a
|
|
16
|
+
screenshot, and unlike a screenshot it can be measured.
|
|
17
|
+
- **Layout faults, computed**: overlap, escaping a parent, off screen, hit areas
|
|
18
|
+
below the platform minimum, a control whose centre lands under the status bar,
|
|
19
|
+
text the OS truncated. Every number is a subtraction between two rectangles.
|
|
20
|
+
- **Testability, linted**: a control with no accessibility identifier is named,
|
|
21
|
+
because the only selector left for it is its label — and a label changes when
|
|
22
|
+
the copy is rewritten or the app is read in another language.
|
|
23
|
+
- **Refusal over guessing**: a selector matching two controls, or a uid that no
|
|
24
|
+
longer names what it named, stops the action instead of tapping something.
|
|
25
|
+
- **Installing a build**: `app install` puts a bundle on the device this config
|
|
26
|
+
selects and no other, and says why iOS would refuse it before iOS does.
|
|
27
|
+
- **Several steps, one call**: `run` takes a list — tap, type, tap, wait — and
|
|
28
|
+
`flow` saves that list in your repository under a name. Logging in stops
|
|
29
|
+
being twelve calls and becomes one word.
|
|
30
|
+
|
|
31
|
+
## Disclaimers
|
|
32
|
+
|
|
33
|
+
DeviceTools exposes the screen and the logs of the app under test to the MCP
|
|
34
|
+
client, allowing it to inspect and modify anything the app shows. Screenshots
|
|
35
|
+
are sent to your model provider unmasked.
|
|
36
|
+
|
|
37
|
+
Control comes from XCUITest (iOS) and UiAutomator2 (Android). Nothing is added
|
|
38
|
+
to your app: no debug server, no `#if DEBUG` hook, no test-only endpoint, and no
|
|
39
|
+
jailbreak. There is no `evaluate_script` — a native app is not a scripting host,
|
|
40
|
+
so state that is not in the accessibility tree is not observable from here.
|
|
41
|
+
|
|
42
|
+
Android is written and unit-checked but has never been run against a physical
|
|
43
|
+
device. iOS is proven on an iPhone 13 Pro Max.
|
|
44
|
+
|
|
45
|
+
## Usage statistics
|
|
46
|
+
|
|
47
|
+
None are collected. DeviceTools makes no network request of its own; everything
|
|
48
|
+
it talks to is on this machine or on the cable.
|
|
49
|
+
|
|
50
|
+
## Update checks
|
|
51
|
+
|
|
52
|
+
None are automatic. `devicetools update` fetches, reports how many commits
|
|
53
|
+
behind you are, and refuses to pull over uncommitted changes.
|
|
54
|
+
|
|
55
|
+
## Requirements
|
|
56
|
+
|
|
57
|
+
- macOS for iOS; macOS or Linux for Android
|
|
58
|
+
- `jq` and `curl`
|
|
59
|
+
- iOS: Xcode, an Apple Developer account, `libimobiledevice`, `ios-deploy`
|
|
60
|
+
- Android: `adb`
|
|
61
|
+
- A device attached over USB, unlocked, with the app already installed
|
|
62
|
+
- Node 18+ **only if you install with `npx`** — it starts the server and does
|
|
63
|
+
nothing else. The tool itself is bash: no runtime dependency, no npm packages,
|
|
64
|
+
and the same bytes run from a clone with no Node at all.
|
|
65
|
+
|
|
66
|
+
## Getting started
|
|
67
|
+
|
|
68
|
+
Add this to your MCP client:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"devicetools": {
|
|
74
|
+
"command": "npx",
|
|
75
|
+
"args": ["-y", "device-devtools-mcp@latest"],
|
|
76
|
+
"env": {
|
|
77
|
+
"DEVICETOOLS_PROJECT": "/Users/you/work/my-app"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
> [!NOTE]
|
|
85
|
+
> `device-devtools-mcp@latest` keeps your client on the newest version. While
|
|
86
|
+
> the repository is private and the package is unpublished, use
|
|
87
|
+
> `["-y", "github:hung977/device-devtools-mcp"]` instead — it works the same
|
|
88
|
+
> way for anyone with access to the repository.
|
|
89
|
+
> `DEVICETOOLS_PROJECT` is the repository holding the app under test: its
|
|
90
|
+
> `config.json` is the config, and flows you save land in its `flows/`
|
|
91
|
+
> directory. An MCP server has no working directory, so it cannot find that for
|
|
92
|
+
> itself.
|
|
93
|
+
|
|
94
|
+
Then, once per app:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npx device-devtools-mcp@latest init ~/work/my-app # writes config.json there
|
|
98
|
+
$EDITOR ~/work/my-app/config.json # the udid, the bundle id, your team
|
|
99
|
+
npx device-devtools-mcp@latest doctor # says what is still missing
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The same binary is the server (no arguments) and the command line (any verb), so
|
|
103
|
+
there is nothing else to install.
|
|
104
|
+
|
|
105
|
+
### iOS needs a driver built once
|
|
106
|
+
|
|
107
|
+
`doctor` will tell you if it is not there. This is the one step `npx` cannot do
|
|
108
|
+
for you — XCUITest has to be signed with your Apple team, on your machine:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npx device-devtools-mcp@latest setup
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
It clones WebDriverAgent, rewrites its bundle identifiers, signs and builds it.
|
|
115
|
+
Four things only a person can do, and it prints whichever are missing: sign in
|
|
116
|
+
to Xcode, register the device UDID with your team, trust the developer
|
|
117
|
+
certificate on the phone, and turn on **Settings → Developer → Enable UI
|
|
118
|
+
Automation**.
|
|
119
|
+
|
|
120
|
+
Android needs `adb` and nothing else.
|
|
121
|
+
|
|
122
|
+
### From a clone instead
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
git clone https://github.com/hung977/device-devtools-mcp ~/tools/devicetools
|
|
126
|
+
cd ~/tools/devicetools
|
|
127
|
+
./install.sh --ios --app com.yourcompany.app # or --android
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`install.sh` installs missing dependencies with Homebrew, puts `devicetools` on
|
|
131
|
+
your PATH, detects the attached device, writes `config.json`, builds the driver,
|
|
132
|
+
and runs `doctor`. Running it again is safe: an existing `config.json` is filled
|
|
133
|
+
in, never overwritten. More than one phone attached, or more than one Apple team
|
|
134
|
+
on the keychain, and it lists them and asks.
|
|
135
|
+
|
|
136
|
+
### MCP client configuration
|
|
137
|
+
|
|
138
|
+
<details>
|
|
139
|
+
<summary>Claude Code</summary>
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
claude mcp add devicetools --scope user \
|
|
143
|
+
--env DEVICETOOLS_PROJECT=/Users/you/work/my-app \
|
|
144
|
+
-- npx -y device-devtools-mcp@latest
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
</details>
|
|
148
|
+
|
|
149
|
+
<details>
|
|
150
|
+
<summary>Codex</summary>
|
|
151
|
+
|
|
152
|
+
In `~/.codex/config.toml`:
|
|
153
|
+
|
|
154
|
+
```toml
|
|
155
|
+
[mcp_servers.devicetools]
|
|
156
|
+
command = "npx"
|
|
157
|
+
args = ["-y", "device-devtools-mcp@latest"]
|
|
158
|
+
env = { DEVICETOOLS_PROJECT = "/Users/you/work/my-app" }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
</details>
|
|
162
|
+
|
|
163
|
+
<details>
|
|
164
|
+
<summary>Cursor, Windsurf, Copilot, and anything else that reads mcpServers</summary>
|
|
165
|
+
|
|
166
|
+
Use the JSON at the top of this section.
|
|
167
|
+
|
|
168
|
+
</details>
|
|
169
|
+
|
|
170
|
+
<details>
|
|
171
|
+
<summary>From a clone, without npx</summary>
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"mcpServers": {
|
|
176
|
+
"devicetools": {
|
|
177
|
+
"command": "/Users/you/tools/devicetools/integrations/mcp/server.sh",
|
|
178
|
+
"env": {
|
|
179
|
+
"DEVICETOOLS_CONFIG": "/Users/you/tools/devicetools/config.json"
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
</details>
|
|
187
|
+
|
|
188
|
+
### Without MCP
|
|
189
|
+
|
|
190
|
+
An agent that can run a shell needs no server. Drop a pointer to `AGENTS.md`
|
|
191
|
+
into your repository:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
integrations/agent-pointer.sh ~/work/my-app claude # cursor | gemini | codex | mcp
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Your first prompt
|
|
198
|
+
|
|
199
|
+
> Open the transfer screen and tell me if anything is laid out wrong.
|
|
200
|
+
|
|
201
|
+
## Tool reference
|
|
202
|
+
|
|
203
|
+
See [Tool reference](docs/tool-reference.md), generated from the schemas the
|
|
204
|
+
server sends. The contract an agent reads is [`AGENTS.md`](AGENTS.md).
|
|
205
|
+
|
|
206
|
+
## Tools
|
|
207
|
+
|
|
208
|
+
- **Reading the screen** (3 tools)
|
|
209
|
+
- [`snapshot`](docs/tool-reference.md#snapshot)
|
|
210
|
+
- [`screenshot`](docs/tool-reference.md#screenshot)
|
|
211
|
+
- [`measure`](docs/tool-reference.md#measure)
|
|
212
|
+
- **Input automation** (7 tools)
|
|
213
|
+
- [`tap`](docs/tool-reference.md#tap)
|
|
214
|
+
- [`type`](docs/tool-reference.md#type)
|
|
215
|
+
- [`swipe`](docs/tool-reference.md#swipe)
|
|
216
|
+
- [`scroll`](docs/tool-reference.md#scroll)
|
|
217
|
+
- [`back`](docs/tool-reference.md#back)
|
|
218
|
+
- [`key`](docs/tool-reference.md#key)
|
|
219
|
+
- [`wait`](docs/tool-reference.md#wait)
|
|
220
|
+
- **Batches and flows** (2 tools)
|
|
221
|
+
- [`run`](docs/tool-reference.md#run)
|
|
222
|
+
- [`flow`](docs/tool-reference.md#flow)
|
|
223
|
+
- **The app** (2 tools)
|
|
224
|
+
- [`app`](docs/tool-reference.md#app)
|
|
225
|
+
- [`open`](docs/tool-reference.md#open)
|
|
226
|
+
- **The device** (5 tools)
|
|
227
|
+
- [`doctor`](docs/tool-reference.md#doctor)
|
|
228
|
+
- [`devices`](docs/tool-reference.md#devices)
|
|
229
|
+
- [`logs`](docs/tool-reference.md#logs)
|
|
230
|
+
- [`settings`](docs/tool-reference.md#settings)
|
|
231
|
+
- [`permission`](docs/tool-reference.md#permission)
|
|
232
|
+
- **The project** (1 tool)
|
|
233
|
+
- [`init`](docs/tool-reference.md#init)
|
|
234
|
+
- **Getting back** (1 tool)
|
|
235
|
+
- [`waypoint`](docs/tool-reference.md#waypoint)
|
|
236
|
+
|
|
237
|
+
The CLI is the same program with positional arguments: `devicetools snapshot`,
|
|
238
|
+
`devicetools tap 19`, `devicetools logs start`. It also carries `find`, `tree`
|
|
239
|
+
and `controls`, which are not exposed over MCP.
|
|
240
|
+
|
|
241
|
+
## Configuration
|
|
242
|
+
|
|
243
|
+
Everything lives in `config.json` beside the checkout. Edit it without opening
|
|
244
|
+
it:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
devicetools config # print it
|
|
248
|
+
devicetools config get app.bundle_id
|
|
249
|
+
devicetools config set app.bundle_id com.other.app
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
- **`platform`**
|
|
253
|
+
Which adapter runs. The device, `wda` and `ua2` blocks belonging to the other
|
|
254
|
+
platform are ignored, not wrong.
|
|
255
|
+
- **Type:** string, `ios` or `android`
|
|
256
|
+
- **`device.udid` / `device.serial`**
|
|
257
|
+
The phone. `install.sh` fills this in from what is attached.
|
|
258
|
+
- **Type:** string
|
|
259
|
+
- **`device.kind`**
|
|
260
|
+
- **Type:** string, one of `auto`, `device`, `simulator`, `emulator`
|
|
261
|
+
- **Default:** `auto`
|
|
262
|
+
- **`app.bundle_id` / `app.package`**
|
|
263
|
+
The app under test. Every verb acts on this one unless told otherwise.
|
|
264
|
+
- **Type:** string
|
|
265
|
+
- **`wda.team_id`**
|
|
266
|
+
The Apple team that signs WebDriverAgent. Not the app's team, and not the ten
|
|
267
|
+
characters in parentheses in `security find-identity` — it is the certificate's
|
|
268
|
+
`OU`.
|
|
269
|
+
- **Type:** string
|
|
270
|
+
- **`timeouts.wait_seconds`**
|
|
271
|
+
How long `wait` waits when not told.
|
|
272
|
+
- **Type:** number
|
|
273
|
+
- **Default:** `15`
|
|
274
|
+
- **`logs.network_pattern`**
|
|
275
|
+
What counts as a request-shaped log line.
|
|
276
|
+
- **Type:** string, an extended regular expression
|
|
277
|
+
- **`scroll.max_swipes`**
|
|
278
|
+
The ceiling on `scroll --until`.
|
|
279
|
+
- **Type:** number
|
|
280
|
+
- **Default:** `15`
|
|
281
|
+
- **`paths.flows_dir`**
|
|
282
|
+
Where `flow save` writes. Relative paths resolve against the project, not the
|
|
283
|
+
checkout, because a flow belongs to the app it describes.
|
|
284
|
+
- **Type:** string
|
|
285
|
+
- **Default:** `flows`
|
|
286
|
+
- **`vars`**
|
|
287
|
+
Values for the `{{ name }}` placeholders in a flow. Anything secret is better
|
|
288
|
+
passed as `DEVICETOOLS_VAR_<NAME>` in the environment — `config.json` is a
|
|
289
|
+
file, and a file gets copied.
|
|
290
|
+
- **Type:** object
|
|
291
|
+
|
|
292
|
+
A key that does not exist is treated as a typo: `config set` lists the real keys
|
|
293
|
+
and asks for `--new`. The type comes from the value already there, so a number
|
|
294
|
+
stays a number. Point the server at a different file with `DEVICETOOLS_CONFIG`;
|
|
295
|
+
that is also how you drive two apps, one config and one server each.
|
|
296
|
+
|
|
297
|
+
## Concepts
|
|
298
|
+
|
|
299
|
+
### uids die at the next snapshot
|
|
300
|
+
|
|
301
|
+
The number in brackets is valid until the next `snapshot` and no longer. Acting
|
|
302
|
+
on one re-reads the screen and requires the element to still be exactly what was
|
|
303
|
+
numbered — same type, identifier, label, value and rectangle, give or take two
|
|
304
|
+
points. If it is not, the action is refused and says what changed. Nothing is
|
|
305
|
+
scored, and there is no runner-up.
|
|
306
|
+
|
|
307
|
+
Every action takes `--snapshot`, which prints the screen it left behind and
|
|
308
|
+
renumbers the uids in the same call. That is the cheapest thing in here: half
|
|
309
|
+
the calls in a measured session were snapshots taken only to see what the
|
|
310
|
+
previous action had produced.
|
|
311
|
+
|
|
312
|
+
### Two points is nothing
|
|
313
|
+
|
|
314
|
+
Both drivers report rectangles as floating point and this pipeline floors them,
|
|
315
|
+
so any pair of edges carries up to two points of error. Differences at or below
|
|
316
|
+
that are not reported. On a real screen this took sixty-three warnings down to
|
|
317
|
+
eight, and the eight were true.
|
|
318
|
+
|
|
319
|
+
### The screen hash, and what it does not cover
|
|
320
|
+
|
|
321
|
+
`SCREEN "Auth.LoginView" #6360` names the screen by a short hash over the type
|
|
322
|
+
and label of everything shown. It answers "am I still on the same screen", so a
|
|
323
|
+
field filling up hashes the same. A second digest covers values, which is why an
|
|
324
|
+
action can report `SCREEN same — #6360, contents changed`.
|
|
325
|
+
|
|
326
|
+
### Selectors match whole
|
|
327
|
+
|
|
328
|
+
`id:`, `label:`, `text:`, `kind:` and a plain string all match a value **whole**.
|
|
329
|
+
`text:OTP` does not find a label reading "Vui lòng nhập mã OTP…". `snapshot
|
|
330
|
+
--grep OTP` does, because reading may guess and acting may not — a substring
|
|
331
|
+
match is how a tap lands on the caption instead of the button.
|
|
332
|
+
|
|
333
|
+
`kind:<Type>` names the element's class, the word `snapshot` prints first on the
|
|
334
|
+
line. It is the escape hatch for the control `snapshot` warns about: no
|
|
335
|
+
identifier, and no label either. Usually written with `--index`:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
devicetools tap kind:SecureTextField --index 0
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
The alternative is a raw coordinate, and coordinates break — on this app, once
|
|
342
|
+
the keyboard went down the sheet holding that field moved from y=507 to y=774.
|
|
343
|
+
|
|
344
|
+
### Batches, and flows
|
|
345
|
+
|
|
346
|
+
An agent that taps, reads, taps, reads spends most of its calls reading, and
|
|
347
|
+
every call re-sends the whole conversation. `run` collapses that:
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
devicetools run --steps '[
|
|
351
|
+
{"tap": "label:Đăng nhập"},
|
|
352
|
+
{"type": "{{ password }}"},
|
|
353
|
+
{"key": "hide"},
|
|
354
|
+
{"tap": "label:Xác nhận"},
|
|
355
|
+
{"wait": "screen:Home"}
|
|
356
|
+
]' --var password="$PASSWORD"
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
A step is an object with exactly one verb in it; any other key is that verb's
|
|
360
|
+
flag. It stops at the first failing step and prints the screen it stopped on —
|
|
361
|
+
fail-fast with the scene, because a batch that says only "step 3 failed" has
|
|
362
|
+
traded a legible error for a black box.
|
|
363
|
+
|
|
364
|
+
uids are refused inside a batch. A uid names a position on a screen the caller
|
|
365
|
+
was shown before the batch began, and by step three that screen is two actions
|
|
366
|
+
old. Selectors resolve against what is actually there.
|
|
367
|
+
|
|
368
|
+
`flow` gives a batch a name and puts it in `flows/<name>.json` in the project —
|
|
369
|
+
run `devicetools init` in your app's repository first, or it lands in the
|
|
370
|
+
DeviceTools checkout, which every app on the machine shares. `flows/` is
|
|
371
|
+
deliberately not gitignored: it is the reason to have a project at all.
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
devicetools flow save login --steps '[...]'
|
|
375
|
+
devicetools flow run login --var password="$PASSWORD"
|
|
376
|
+
devicetools flow list
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Credentials stay as `{{ name }}` and are filled at run time from `--var`, from
|
|
380
|
+
`DEVICETOOLS_VAR_<NAME>`, or from `.vars` in the config — and the substituted
|
|
381
|
+
value is masked back to `{{ name }}` in everything a run prints. The variables a
|
|
382
|
+
flow needs are read out of its own steps when it is saved, including those of
|
|
383
|
+
any flow it includes.
|
|
384
|
+
|
|
385
|
+
A step of `{"flow": "login"}` runs a saved flow inside another, so `activate` is
|
|
386
|
+
`login` plus the OTP rather than a second copy of logging in.
|
|
387
|
+
|
|
388
|
+
`{"expect": "screen:Auth.LoginView"}` is how a flow says where it expects to
|
|
389
|
+
start — a `wait` with a three-second deadline, so it fails at the door rather
|
|
390
|
+
than tapping into a screen it was not written for. On a real app "log in" has
|
|
391
|
+
three different routes depending on whether the device is activated.
|
|
392
|
+
|
|
393
|
+
`{"snapshot": true}` is a flag on any step, not a step of its own:
|
|
394
|
+
`{"tap": "label:X", "snapshot": true}` acts and hands back the new screen.
|
|
395
|
+
|
|
396
|
+
Logs are attached only to the step that **failed**. In a batch of eight, the
|
|
397
|
+
system's own log lines outweighed everything the batch said; `--logs` keeps
|
|
398
|
+
them all.
|
|
399
|
+
|
|
400
|
+
There is no `flow record`. Watching somebody tap produces a list of coordinates,
|
|
401
|
+
which is the one spelling guaranteed to break when the layout moves.
|
|
402
|
+
|
|
403
|
+
### Logs have to be started
|
|
404
|
+
|
|
405
|
+
`logs action=start`, once per session. An action must not spawn a background
|
|
406
|
+
collector as a side effect of touching a button, and the device under test
|
|
407
|
+
produced twenty-one thousand log lines in thirty seconds.
|
|
408
|
+
|
|
409
|
+
Only the app's own lines are kept. The OS logs a great deal on an app's behalf
|
|
410
|
+
under its process name — hit testing, the accelerometer, lifecycle — and on a
|
|
411
|
+
real 40,775-line capture every one of the 162 lines carrying the app's process
|
|
412
|
+
name came from a system framework, while the app's own logging was zero lines.
|
|
413
|
+
`logs.exclude_subsystems` is that list; `logs --raw` shows everything.
|
|
414
|
+
|
|
415
|
+
`logs --network` filters the app's own output. It is not a proxy — a proxy needs
|
|
416
|
+
a CA certificate on the device and an app that pins defeats one — so an app that
|
|
417
|
+
does not print its traffic shows nothing.
|
|
418
|
+
|
|
419
|
+
### Installing a build
|
|
420
|
+
|
|
421
|
+
DeviceTools does not build. It installs what your build produced, onto the
|
|
422
|
+
device this config selects and no other:
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
devicetools app install ~/DerivedData/Build/Products/Debug-iphoneos/My.app --launch
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
```
|
|
429
|
+
OK installed com.example.my
|
|
430
|
+
version 3.4.1 (1207)
|
|
431
|
+
signed signed, team ABCDE12345
|
|
432
|
+
onto 00008030-… (Work iPhone)
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
A bundle built with `CODE_SIGNING_ALLOWED=NO` is refused before it reaches the
|
|
436
|
+
phone, with that named as the likely cause. iOS calls it
|
|
437
|
+
`ApplicationVerificationFailed`, which sends people to read profiles instead.
|
|
438
|
+
|
|
439
|
+
`doctor` does not look at certificates or provisioning profiles. Whose account
|
|
440
|
+
signs the app is the build's business; a check standing outside the build can
|
|
441
|
+
only guess, and on a machine with fifteen teams on the keychain it guessed
|
|
442
|
+
wrong. The signature `app install` reads comes from the bundle in front of it.
|
|
443
|
+
|
|
444
|
+
### The two directories, and how MCP names one
|
|
445
|
+
|
|
446
|
+
`DT_HOME` is the checkout — scripts, adapters, `AGENTS.md`. `DT_ROOT` is where
|
|
447
|
+
`config.json`, `flows/`, `.state/` and `.runs/` live, which by default is the
|
|
448
|
+
checkout too. `devicetools init <dir>` marks a different directory with a
|
|
449
|
+
`.devicetools` file, and every relative path in a config resolves against it.
|
|
450
|
+
|
|
451
|
+
From a shell, the project is found by walking up from where you are standing,
|
|
452
|
+
the way git does. **An MCP server has no such thing**: the host sets its working
|
|
453
|
+
directory when it spawns the process and it never changes, so every walk-up
|
|
454
|
+
lands in the checkout no matter which repository you are working in. So every
|
|
455
|
+
verb takes `--project <dir>` — `project` in the arguments over MCP — and that
|
|
456
|
+
names the project for that call:
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
devicetools flow save login --project ~/work/my-app --steps '[...]'
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Its `config.json` becomes the config for that call too, ahead of anything in the
|
|
463
|
+
environment: a flag on this call is more specific than a variable set when the
|
|
464
|
+
server started.
|
|
465
|
+
|
|
466
|
+
## Known limitations
|
|
467
|
+
|
|
468
|
+
| You see | Do this |
|
|
469
|
+
| --- | --- |
|
|
470
|
+
| `Timed out while enabling automation mode` | Settings → Developer → Enable UI Automation, unlock the screen |
|
|
471
|
+
| `the runner is not signed for this device` | Register the UDID with your team, then `devicetools setup --force` |
|
|
472
|
+
| `N teams on this machine` (no terminal) | `./install.sh --ios --team <ID>` |
|
|
473
|
+
| `N iPhones attached` (no terminal) | `./install.sh --ios --udid <UDID>` |
|
|
474
|
+
| `Unable to find a destination matching` | Reconnect the cable, unlock, trust this computer |
|
|
475
|
+
| `bundle is unsigned` | Rebuild with signing on; a Makefile probably sets `CODE_SIGNING_ALLOWED=NO` |
|
|
476
|
+
| exit 3 mid-session | `devicetools doctor --recover` |
|
|
477
|
+
| `uid 7 is stale — …` | `snapshot` again |
|
|
478
|
+
| `logs` returns nothing | `logs start` was never called |
|
|
479
|
+
|
|
480
|
+
Exit codes are uniform: `0` done, `1` usage, `2` environment or configuration,
|
|
481
|
+
`3` device unreachable, `4` the command ran and the screen did not satisfy it.
|
|
482
|
+
|
|
483
|
+
## Uninstalling
|
|
484
|
+
|
|
485
|
+
```bash
|
|
486
|
+
./uninstall.sh # symlink, MCP registration, .state/, background processes
|
|
487
|
+
./uninstall.sh --all # also the driver on the phone, its build, and config.json
|
|
488
|
+
./uninstall.sh --dry-run # preview
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Homebrew packages and the checkout itself are left alone.
|
package/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.0
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// device-devtools-mcp — the npx entry point.
|
|
3
|
+
//
|
|
4
|
+
// npx device-devtools-mcp@latest the MCP server, on stdin/stdout
|
|
5
|
+
// npx device-devtools-mcp@latest doctor any CLI verb, same as `devicetools`
|
|
6
|
+
//
|
|
7
|
+
// WHY THERE IS JAVASCRIPT IN A PROJECT THAT SAYS "NO NODE.JS".
|
|
8
|
+
//
|
|
9
|
+
// There still is none in the tool. Every verb is bash, every adapter is bash,
|
|
10
|
+
// and the MCP server is bash; this file starts one of them and then does
|
|
11
|
+
// nothing. It exists because `npx <name>@latest` is how an MCP server gets
|
|
12
|
+
// installed in practice — the host configuration people paste is `npx`, and a
|
|
13
|
+
// `git clone` plus a `./install.sh` is a different conversation with whoever is
|
|
14
|
+
// setting it up. Node is the delivery mechanism, not a dependency of the work.
|
|
15
|
+
//
|
|
16
|
+
// It has no dependencies of its own, deliberately: a supply chain is a strange
|
|
17
|
+
// thing to accept in return for one spawn call.
|
|
18
|
+
//
|
|
19
|
+
// STDIO IS INHERITED, NOT PIPED. The MCP protocol is JSON-RPC on stdin and
|
|
20
|
+
// stdout, one message per line. Anything this file printed to stdout would be
|
|
21
|
+
// read by the host as a malformed message, so it prints nothing there ever —
|
|
22
|
+
// diagnostics go to stderr, which the host shows in its log.
|
|
23
|
+
//
|
|
24
|
+
// bash IS INVOKED EXPLICITLY rather than executing the script by its own
|
|
25
|
+
// shebang. npm preserves the executable bit in a tarball, but a package
|
|
26
|
+
// extracted by something else may not, and "permission denied" from inside an
|
|
27
|
+
// npx cache is a confusing place to start debugging.
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
32
|
+
const { join } = require('node:path');
|
|
33
|
+
const { existsSync } = require('node:fs');
|
|
34
|
+
|
|
35
|
+
const root = join(__dirname, '..');
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
|
|
38
|
+
// No arguments means the server, which is what a host's `npx` line sends.
|
|
39
|
+
// Anything else is the command line, so that `npx device-devtools-mcp doctor` works
|
|
40
|
+
// without a second package.
|
|
41
|
+
const server = args.length === 0;
|
|
42
|
+
const target = server
|
|
43
|
+
? join(root, 'integrations', 'mcp', 'server.sh')
|
|
44
|
+
: join(root, 'bin', 'devicetools');
|
|
45
|
+
|
|
46
|
+
if (!existsSync(target)) {
|
|
47
|
+
process.stderr.write(
|
|
48
|
+
`device-devtools-mcp: ${target} is missing — this package did not unpack completely\n`);
|
|
49
|
+
process.exit(2);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// THE ONE THING npm BREAKS THAT NOTHING ELSE HERE COULD SURVIVE.
|
|
53
|
+
//
|
|
54
|
+
// Every verb in scripts/ is a symlink to dispatch.sh, and every adapter's copy
|
|
55
|
+
// of a shared verb is a symlink into scripts/common/ whose own directory is
|
|
56
|
+
// what selects the adapter. `npm pack` carries none of them — measured: 63
|
|
57
|
+
// files in the tarball instead of 110, and zero symlinks, not dereferenced but
|
|
58
|
+
// simply absent. Installed like that the server answers `unknown tool` to all
|
|
59
|
+
// twenty-one of them while looking perfectly fine.
|
|
60
|
+
//
|
|
61
|
+
// So they are put back here, from a manifest generated out of the repository.
|
|
62
|
+
// Not in a postinstall script: `npm install --ignore-scripts` is common and
|
|
63
|
+
// some organisations enforce it, and a tool that is silently broken under a
|
|
64
|
+
// security setting is worse than one that is loudly broken. This runs from the
|
|
65
|
+
// entry point, where nothing can switch it off, and on a git checkout it finds
|
|
66
|
+
// nothing to do and says nothing.
|
|
67
|
+
if (!existsSync(join(root, 'scripts', 'dispatch.sh'))) {
|
|
68
|
+
process.stderr.write(
|
|
69
|
+
'device-devtools-mcp: scripts/dispatch.sh is missing — this package did not unpack completely\n');
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
if (!existsSync(join(root, 'scripts', 'tap.sh'))) {
|
|
73
|
+
const relink = spawnSync('/bin/bash', [join(root, 'scripts', 'relink.sh')],
|
|
74
|
+
{ stdio: ['ignore', 'pipe', 'inherit'] });
|
|
75
|
+
if (relink.status !== 0) {
|
|
76
|
+
process.stderr.write('device-devtools-mcp: the verb surface could not be restored\n');
|
|
77
|
+
process.exit(2);
|
|
78
|
+
}
|
|
79
|
+
// stdout is the JSON-RPC channel in server mode, so this goes to the log.
|
|
80
|
+
process.stderr.write(`device-devtools-mcp: ${String(relink.stdout).trim()}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const child = spawn('/bin/bash', [target, ...args], {
|
|
84
|
+
stdio: 'inherit',
|
|
85
|
+
env: { ...process.env, DEVICETOOLS_VIA_NPX: '1' },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Passed on rather than swallowed: the host stops an MCP server by signalling
|
|
89
|
+
// it, and a wrapper that ignored that would leave the real server running with
|
|
90
|
+
// a device port held open.
|
|
91
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
92
|
+
process.on(sig, () => { try { child.kill(sig); } catch { /* already gone */ } });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
child.on('error', (err) => {
|
|
96
|
+
process.stderr.write(`device-devtools-mcp: could not start bash — ${err.message}\n`);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// The child's exit code is the contract every verb here documents: 0 done,
|
|
101
|
+
// 1 usage, 2 environment, 3 device unreachable, 4 the screen did not satisfy.
|
|
102
|
+
// Reporting anything else would break the one thing a caller can rely on.
|
|
103
|
+
child.on('exit', (code, signal) => {
|
|
104
|
+
if (signal) { process.kill(process.pid, signal); return; }
|
|
105
|
+
process.exit(code === null ? 1 : code);
|
|
106
|
+
});
|