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.
Files changed (66) hide show
  1. package/AGENTS.md +327 -0
  2. package/LICENSE +21 -0
  3. package/README.md +491 -0
  4. package/VERSION +1 -0
  5. package/bin/device-devtools-mcp.js +106 -0
  6. package/bin/devicetools +315 -0
  7. package/config.example.json +63 -0
  8. package/integrations/agent-pointer.sh +48 -0
  9. package/integrations/claude/SKILL.md +8 -0
  10. package/integrations/cursor/devicetools.mdc +8 -0
  11. package/integrations/gemini/GEMINI.md +5 -0
  12. package/integrations/mcp/README.md +184 -0
  13. package/integrations/mcp/mcp.json +10 -0
  14. package/integrations/mcp/reference.sh +119 -0
  15. package/integrations/mcp/selftest.sh +158 -0
  16. package/integrations/mcp/server.sh +343 -0
  17. package/package.json +50 -0
  18. package/scripts/android/app.sh +241 -0
  19. package/scripts/android/back.sh +73 -0
  20. package/scripts/android/controls.sh +56 -0
  21. package/scripts/android/devices.sh +77 -0
  22. package/scripts/android/doctor.sh +253 -0
  23. package/scripts/android/lib.sh +699 -0
  24. package/scripts/android/logs.sh +289 -0
  25. package/scripts/android/permission.sh +119 -0
  26. package/scripts/android/settings.sh +63 -0
  27. package/scripts/android/setup.sh +97 -0
  28. package/scripts/android/tree.awk +166 -0
  29. package/scripts/android/tree.sh +127 -0
  30. package/scripts/android/type.sh +191 -0
  31. package/scripts/common/find.sh +95 -0
  32. package/scripts/common/key.sh +65 -0
  33. package/scripts/common/lib.sh +14 -0
  34. package/scripts/common/measure.sh +170 -0
  35. package/scripts/common/open.sh +131 -0
  36. package/scripts/common/screenshot.sh +102 -0
  37. package/scripts/common/scroll.sh +177 -0
  38. package/scripts/common/snapshot.sh +69 -0
  39. package/scripts/common/swipe.sh +171 -0
  40. package/scripts/common/tap.sh +226 -0
  41. package/scripts/common/wait.sh +212 -0
  42. package/scripts/common/waypoint.sh +141 -0
  43. package/scripts/dispatch.sh +21 -0
  44. package/scripts/flow.sh +266 -0
  45. package/scripts/init.sh +101 -0
  46. package/scripts/ios/app.sh +404 -0
  47. package/scripts/ios/back.sh +95 -0
  48. package/scripts/ios/controls.sh +68 -0
  49. package/scripts/ios/devices.sh +80 -0
  50. package/scripts/ios/doctor.sh +386 -0
  51. package/scripts/ios/lib.sh +864 -0
  52. package/scripts/ios/logs.sh +272 -0
  53. package/scripts/ios/permission.sh +108 -0
  54. package/scripts/ios/settings.sh +76 -0
  55. package/scripts/ios/setup.sh +175 -0
  56. package/scripts/ios/tree.sh +128 -0
  57. package/scripts/ios/type.sh +178 -0
  58. package/scripts/lib.sh +1032 -0
  59. package/scripts/links.tsv +44 -0
  60. package/scripts/relink.sh +121 -0
  61. package/scripts/run.sh +415 -0
  62. package/scripts/selftest.sh +1709 -0
  63. package/scripts/snapshot.awk +362 -0
  64. package/scripts/verify-npm-package.js +133 -0
  65. package/tests/fixtures/ios-contacts-list.expected +52 -0
  66. 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
+ });