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
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+ # reference.sh — docs/tool-reference.md, from the schemas the server sends.
3
+ #
4
+ # integrations/mcp/reference.sh print it
5
+ # integrations/mcp/reference.sh --write write docs/tool-reference.md
6
+ #
7
+ # THE DOCUMENT WAS BEING KEPT BY HAND, WHICH IS THE SAME AS NOT BEING KEPT.
8
+ #
9
+ # It was generated once and edited afterwards, so every schema change since has
10
+ # had to be copied into it by somebody remembering to. The MCP self-test could
11
+ # only catch a tool with no section at all — a description that had moved on, or
12
+ # a parameter added to a tool that already had a heading, went through silently.
13
+ # Now the file is output, the self-test diffs it against this, and a stale
14
+ # reference fails the suite the way a stale test would.
15
+ #
16
+ # The groups below are the one thing not derivable from a schema: which tools
17
+ # belong together is an editorial judgement, and it is the same judgement README
18
+ # makes. A tool missing from every group still appears — under "Everything else",
19
+ # where it is impossible not to notice.
20
+ #
21
+ # exit 0 printed, or written
22
+ # exit 1 usage error
23
+ # exit 2 the server would not answer
24
+
25
+ set -euo pipefail
26
+
27
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ ROOT="$(cd "$HERE/../.." && pwd)"
29
+ OUT="$ROOT/docs/tool-reference.md"
30
+
31
+ WRITE=0
32
+ case "${1-}" in
33
+ --write) WRITE=1 ;;
34
+ -h|--help) awk 'NR > 1 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;;
35
+ "") ;;
36
+ *) printf 'unknown argument: %s\n' "$1" >&2; exit 1 ;;
37
+ esac
38
+
39
+ # NOT `GROUPS`. That is a bash built-in array of the current user's group ids,
40
+ # and assigning to it is ignored: "$GROUPS" expanded to 20 — the gid of staff on
41
+ # macOS — so the loop below read one line, matched no tool, and quietly filed
42
+ # every tool under "Everything else". Nothing errored.
43
+ TOOL_GROUPS="Reading the screen:snapshot screenshot measure
44
+ Input automation:tap type swipe scroll back key wait
45
+ Batches and flows:run flow
46
+ The app:app open
47
+ The device:doctor devices logs settings permission
48
+ The project:init
49
+ Getting back:waypoint"
50
+
51
+ TOOLS="$( { printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}'
52
+ printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
53
+ } | "$HERE/server.sh" 2>/dev/null | jq -c 'select(.id==2) | .result.tools' | head -1)"
54
+ [ -n "$TOOLS" ] || { printf 'the server did not answer tools/list\n' >&2; exit 2; }
55
+
56
+ render() {
57
+ cat <<'HEAD'
58
+ # Tool reference
59
+
60
+ Generated by `integrations/mcp/reference.sh` from the schemas the server sends
61
+ at handshake time. Do not edit it by hand: the self-test regenerates it and
62
+ fails on a difference. Every tool is `scripts/<name>.sh` on the command line,
63
+ with positional arguments in place of `key=value`.
64
+ HEAD
65
+
66
+ local listed=""
67
+ while IFS= read -r line; do
68
+ [ -n "$line" ] || continue
69
+ local title names any
70
+ title="${line%%:*}"
71
+ names="${line#*:}"
72
+ any=0
73
+ for n in $names; do
74
+ printf '%s' "$TOOLS" | jq -e --arg n "$n" 'any(.name == $n)' >/dev/null 2>&1 || continue
75
+ [ "$any" -eq 1 ] || { printf '\n## %s\n' "$title"; any=1; }
76
+ section "$n"
77
+ listed="$listed $n"
78
+ done
79
+ done <<< "$TOOL_GROUPS"
80
+
81
+ # A `case` inside a command substitution inside this function is a bash 3.2
82
+ # parse error, so the leftovers are collected with a plain loop.
83
+ local rest="" n
84
+ for n in $(printf '%s' "$TOOLS" | jq -r '.[].name'); do
85
+ case " $listed " in *" $n "*) continue ;; esac
86
+ rest="$rest $n"
87
+ done
88
+ if [ -n "$rest" ]; then
89
+ printf '\n## Everything else\n'
90
+ for n in $rest; do section "$n"; done
91
+ fi
92
+ }
93
+
94
+ section() {
95
+ printf '%s' "$TOOLS" | jq -r --arg n "$1" '
96
+ .[] | select(.name == $n)
97
+ | "\n### `\(.name)`\n\n\(.description)\n"
98
+ + ( (.inputSchema.properties // {})
99
+ | to_entries
100
+ | if length == 0 then ""
101
+ else "\n| Parameter | Type | |\n| --- | --- | --- |\n"
102
+ + ( map( "| `\(.key)` | "
103
+ # An enum says more than "string" does, and the hand-kept
104
+ # version of this file printed it. Escaped, because a bare
105
+ # pipe would end the table cell.
106
+ + ( if (.value.enum // null) != null
107
+ then (.value.enum | map("`\(.)`") | join(" \\| "))
108
+ else "`\(.value.type)`" end )
109
+ + " | \(.value.description // "") |" )
110
+ | join("\n") )
111
+ end )'
112
+ }
113
+
114
+ if [ "$WRITE" -eq 1 ]; then
115
+ render > "$OUT.new" && mv "$OUT.new" "$OUT" || { rm -f "$OUT.new"; exit 2; }
116
+ printf 'wrote %s\n' "$OUT"
117
+ else
118
+ render
119
+ fi
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # selftest.sh — check the MCP layer without a device.
3
+ #
4
+ # integrations/mcp/selftest.sh [config.json]
5
+ #
6
+ # Everything here is protocol: the handshake, the tool list, how a tool's
7
+ # arguments become a command line, the exit-code contract, and the ways a
8
+ # request can be malformed.
9
+ #
10
+ # THE VERBS IT RUNS TOUCH NOTHING. `waypoint list` reads a file in the state
11
+ # directory. `measure abc` fails on its argument before it ever loads a config.
12
+ # Both answer the same way on a machine with no phone attached as on one with,
13
+ # which is the property this file needs and the reason it can run in CI.
14
+ #
15
+ # An earlier version ran `repair stats`, chosen because the log was empty and an
16
+ # empty log exits 4 — so the check proved a non-zero exit was reported. Then the
17
+ # log got its first entries, `stats` started succeeding, and a check about the
18
+ # protocol went red because of data somebody added to the project. A test whose
19
+ # result depends on a file that is supposed to grow is a test with a fuse in it.
20
+ #
21
+ # exit 0 every check passed
22
+ # exit 1 a check failed — the failing line says what was expected
23
+ set -euo pipefail
24
+
25
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ ROOT="$(cd "$HERE/../.." && pwd)"
27
+ CONFIG="${1:-$ROOT/config.json}"
28
+ [ -f "$CONFIG" ] || { echo "no config at $CONFIG" >&2; exit 1; }
29
+
30
+ OUT="$(mktemp "${TMPDIR:-/tmp}/devicetools-selftest.XXXXXX")"
31
+ trap 'rm -f "$OUT"' EXIT
32
+
33
+ {
34
+ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}'
35
+ echo '{"jsonrpc":"2.0","method":"notifications/initialized"}'
36
+ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
37
+ echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"waypoint","arguments":{"action":"list"}}}'
38
+ echo '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"measure","arguments":{"uid":"abc"}}}'
39
+ echo '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"nope","arguments":{}}}'
40
+ echo '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"../../etc/passwd","arguments":{}}}'
41
+ echo '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"waypoint","arguments":{"action":"goto","name":"one two","dry":false}}}'
42
+ echo 'not json at all'
43
+ echo '{"jsonrpc":"2.0","id":8,"method":"ping"}'
44
+ } | DEVICETOOLS_CONFIG="$CONFIG" "$HERE/server.sh" > "$OUT" 2>/dev/null
45
+
46
+ fails=0
47
+ check() { # check <description> <jq-filter> <expected>
48
+ local got
49
+ got="$(jq -r "$2" < "$OUT" 2>/dev/null || true)"
50
+ if [ "$got" = "$3" ]; then
51
+ printf 'OK %s\n' "$1"
52
+ else
53
+ printf 'FAIL %s — expected %s, got %s\n' "$1" "$3" "$got"
54
+ fails=$(( fails + 1 ))
55
+ fi
56
+ }
57
+
58
+ # Ten lines went in: eight requests, one unparseable, and one notification. A
59
+ # notification carries no id and must never be answered, not even to say the
60
+ # method was unknown — so nine replies come back, not ten.
61
+ check "one reply per request, none for the notification" \
62
+ '[., inputs] | length' '9'
63
+ check "handshake echoes the version the client asked for" \
64
+ 'select(.id==1) | .result.protocolVersion' '2025-03-26'
65
+
66
+ # --- the documentation cannot fall behind the schema ---------------------------
67
+ #
68
+ # docs/tool-reference.md is generated from these schemas and README lists the
69
+ # same names. Both are hand-edited afterwards, so both can drift, and a tool
70
+ # missing from the reference is a link in README that goes nowhere.
71
+
72
+ REF="$ROOT/docs/tool-reference.md"
73
+ RM="$ROOT/README.md"
74
+ missing_readme=""
75
+ for t in $(jq -r 'select(.id==2) | .result.tools[].name' < "$OUT" 2>/dev/null | sort); do
76
+ grep -q "(docs/tool-reference.md#$t)" "$RM" 2>/dev/null || missing_readme="$missing_readme $t"
77
+ done
78
+ check "every tool is linked from README" \
79
+ 'empty' "$(printf '%s' "$missing_readme")"
80
+
81
+ # THE REFERENCE IS OUTPUT, NOT A DOCUMENT SOMEBODY MAINTAINS.
82
+ #
83
+ # The check this replaces only asked whether each tool had a heading. A
84
+ # description that had moved on, or a parameter added to a tool that already had
85
+ # one, went through in silence — and both had. Regenerating and diffing is the
86
+ # same rule the fixtures live under: the recorded thing either still matches
87
+ # what the code produces or the suite is red.
88
+ if diff "$REF" <(DEVICETOOLS_CONFIG="$CONFIG" "$HERE/reference.sh" 2>/dev/null) >/dev/null 2>&1; then
89
+ printf 'OK docs/tool-reference.md is what reference.sh produces\n'
90
+ else
91
+ printf 'FAIL docs/tool-reference.md is stale — run integrations/mcp/reference.sh --write\n'
92
+ fails=$(( fails + 1 ))
93
+ fi
94
+
95
+ # THE SURFACE. Twenty-one named tools, and the list is sorted here so that adding
96
+ # one in the middle of the table does not fail this check for the wrong reason.
97
+ check "twenty-one named tools, and these twenty-one" \
98
+ 'select(.id==2) | .result.tools | map(.name) | sort | join(" ")' \
99
+ 'app back devices doctor flow init key logs measure open permission run screenshot scroll settings snapshot swipe tap type wait waypoint'
100
+
101
+ # THE RULE THE WHOLE TABLE RESTS ON. A tool's name is its script's name, so
102
+ # there is no mapping table to keep in sync — and a mapping table is exactly the
103
+ # kind of second list that does not get kept in sync.
104
+ check "every tool name is a script that exists" \
105
+ 'select(.id==2) | .result.tools | map(.name) | join("\n")' \
106
+ "$(jq -r 'select(.id==2) | .result.tools[].name' < "$OUT" 2>/dev/null \
107
+ | while read -r t; do [ -e "$ROOT/scripts/$t.sh" ] && printf '%s\n' "$t"; done \
108
+ | sed '$!s/$//' | paste -sd '\n' -)"
109
+
110
+ # Real schemas, not a verb enum. This is the whole reason the two-tool design was
111
+ # reversed: an agent should be able to act correctly on its first call without
112
+ # fetching a document first.
113
+ check "snapshot has named properties" \
114
+ 'select(.id==2) | .result.tools[] | select(.name=="snapshot") | .inputSchema.properties | keys | sort | join(",")' \
115
+ 'all,grep'
116
+ check "no tool takes a free-form verb any more" \
117
+ 'select(.id==2) | [.result.tools[] | select(.inputSchema.properties.verb != null)] | length' '0'
118
+ check "the documentation tool is gone with the enum that needed it" \
119
+ 'select(.id==2) | [.result.tools[] | select(.name | test("docs"))] | length' '0'
120
+
121
+ # Arguments. An object has to become a command line, and the positional ones
122
+ # have to arrive as positions.
123
+ check "a verb runs and reports success" \
124
+ 'select(.id==3) | .result.isError' 'false'
125
+ check "a usage error is reported as an error with its code" \
126
+ 'select(.id==4) | .result.content[0].text | test("EXIT 1")' 'true'
127
+ check "a positional argument arrives as a position" \
128
+ 'select(.id==4) | .result.content[0].text | test("got .abc.")' 'true'
129
+ # The space inside the value must survive. If the arguments were joined into one
130
+ # string and re-split, waypoint would see two arguments and say so.
131
+ #
132
+ # THE VERB HERE MATTERS. An earlier version of this check used
133
+ # `snapshot --grep "one two"`, which passed for a week and then failed the
134
+ # moment nobody had a phone plugged in: snapshot exits 3 before it ever looks at
135
+ # its arguments. That is the fuse this file's own header warns about, and it had
136
+ # one in it. `waypoint goto` refuses an unknown name before it touches anything,
137
+ # and quotes the name back.
138
+ check "a value containing a space stays one argument" \
139
+ 'select(.id==7) | .result.content[0].text | test("no waypoint called .one two.")' 'true'
140
+ # A false boolean is an absent flag, not "--dry false", which every one of these
141
+ # scripts would read as a stray positional argument and refuse.
142
+ check "a false boolean sends no flag" \
143
+ 'select(.id==7) | .result.content[0].text | test("too many arguments") | not' 'true'
144
+
145
+ check "unknown tool is a protocol error" \
146
+ 'select(.id==5) | .error.code' '-32602'
147
+ check "a path is not a tool name" \
148
+ 'select(.id==6) | .error.code' '-32602'
149
+ check "unparseable input answers with a null id" \
150
+ 'select(.error.code==-32700) | .id' 'null'
151
+ check "ping" 'select(.id==8) | .result | length' '0'
152
+
153
+ if [ "$fails" -eq 0 ]; then
154
+ printf 'RESULT PASS mcp selftest\n'
155
+ else
156
+ printf 'RESULT FAIL mcp selftest — %d check(s)\n' "$fails" >&2
157
+ exit 1
158
+ fi
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env bash
2
+ # server.sh — DeviceTools as an MCP server, over stdio.
3
+ #
4
+ # integrations/mcp/server.sh
5
+ #
6
+ # It speaks JSON-RPC 2.0 on stdin/stdout, one message per line, and does exactly
7
+ # one thing: run a script in scripts/ and hand back its stdout and its exit code.
8
+ # There is no logic here. A verb the scripts cannot do is a verb this cannot do,
9
+ # and a verb added to scripts/ appears here without an edit — the tool's enum is
10
+ # read from the directory, not from a list kept in this file.
11
+ #
12
+ # WHY IT IS A WRAPPER AND NOTHING MORE. Everything downstream — the runner, the
13
+ # recorder, diagnose — reads the same one-line stdout contract. The moment this
14
+ # layer starts reformatting, retrying or summarising, an agent driving over MCP
15
+ # and an engineer driving over the shell are running two different products, and
16
+ # the difference surfaces as a bug in whichever is used less.
17
+ #
18
+ # The surface is twenty named tools; see the note above TOOLS below for why that
19
+ # is not the two it started as.
20
+ #
21
+ # Environment:
22
+ # DEVICETOOLS_CONFIG config file to use (default: the repo's config.json)
23
+ # DEVICETOOLS_MCP_TIMEOUT seconds before a verb is killed (default: 300)
24
+ # DEVICETOOLS_VAR_* test variables; passed through to the scripts as-is
25
+ #
26
+ # Diagnostics go to stderr, which the host shows in its MCP log. Nothing but
27
+ # JSON-RPC ever goes to stdout.
28
+ set -euo pipefail
29
+
30
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ ROOT="$(cd "$HERE/../.." && pwd)"
32
+ SERVER_VERSION="0.1.0"
33
+ TIMEOUT="${DEVICETOOLS_MCP_TIMEOUT:-300}"
34
+
35
+ # Protocol revisions this server can hold a conversation in, newest first. A
36
+ # client asking for one of them gets that one back; a client asking for anything
37
+ # else gets the newest, which is what the specification says to do.
38
+ SUPPORTED="2025-06-18 2025-03-26 2024-11-05"
39
+
40
+ log() { printf 'device-devtools-mcp: %s\n' "$*" >&2; }
41
+
42
+ command -v jq >/dev/null 2>&1 || { log "jq is not installed — DeviceTools needs it"; exit 2; }
43
+
44
+ # A verb reached this way must spell its remedies as tool names. "run
45
+ # scripts/snapshot.sh" is a path a model cannot call.
46
+ export DT_MCP=1
47
+
48
+ # The same four places, in the same order, as scripts/lib.sh — see the note
49
+ # there. Repeated rather than shared because this file deliberately sources
50
+ # nothing: lib.sh installs EXIT and USR1 traps, and this process owns its own.
51
+ # A self-test asserts the two agree.
52
+ USER_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/devicetools/config.json"
53
+ if [ -n "${DEVICETOOLS_CONFIG:-}" ]; then CONFIG="$DEVICETOOLS_CONFIG"
54
+ elif [ -n "${DEVICETOOLS_PROJECT:-}" ] && [ -f "$DEVICETOOLS_PROJECT/config.json" ]
55
+ then CONFIG="$DEVICETOOLS_PROJECT/config.json"
56
+ elif [ -f "$ROOT/config.json" ]; then CONFIG="$ROOT/config.json"
57
+ else CONFIG="$USER_CONFIG"
58
+ fi
59
+ [ -f "$CONFIG" ] || {
60
+ log "no config — looked in $ROOT/config.json and $USER_CONFIG"
61
+ log "make one: npx device-devtools-mcp init <the repository holding your app>"
62
+ log "then point this server at it with project=<that path> on any tool, or"
63
+ log "set DEVICETOOLS_CONFIG in this server's env"
64
+ exit 2
65
+ }
66
+ jq -e . "$CONFIG" >/dev/null 2>&1 || { log "$CONFIG is not valid JSON"; exit 2; }
67
+ export DEVICETOOLS_CONFIG="$CONFIG"
68
+
69
+ WORK="$(mktemp -d "${TMPDIR:-/tmp}/device-devtools-mcp.XXXXXX")"
70
+ cleanup() { rm -rf "$WORK"; }
71
+ trap cleanup EXIT
72
+
73
+ # ---------------------------------------------------------------- the surface
74
+
75
+
76
+ # TWENTY TOOLS, NOT TWO — AND WHY THAT REVERSED.
77
+ #
78
+ # The earlier design sent one `devicetools` tool with the verb as an enum, plus a
79
+ # documentation tool, because a schema is billed on every request and the whole
80
+ # surface fitted in 1.8 KB. That was right when the job was a long authoring
81
+ # session which could afford to read a 30 KB contract before its first action.
82
+ #
83
+ # The job is now a handful of calls in the middle of somebody's debugging.
84
+ # There, fetching documentation before acting is a wasted round trip, and a verb
85
+ # passed as a bare enum string invites wrong arguments with no schema to catch
86
+ # them. The schemas are a couple of thousand tokens on every request. That is
87
+ # the price of the agent being right on its first call, and against a debugging
88
+ # session it is worth paying.
89
+ #
90
+ # `run` and `flow` earn their place differently from the rest: they are the two
91
+ # tools that reduce how many times any of the others are called.
92
+ #
93
+ # A TOOL'S NAME IS ITS SCRIPT'S NAME. There is no mapping table, because a
94
+ # mapping table is a second list to keep in sync and it would not be kept in
95
+ # sync. `snapshot` runs scripts/snapshot.sh and nothing else.
96
+ #
97
+ # `setup` is deliberately absent. It needs an Xcode sign-in and a device
98
+ # registration that must not be faked, and an agent should not be running it
99
+ # unattended.
100
+
101
+ tool() { # tool <name> <description> <properties-json>
102
+ jq -nc --arg n "$1" --arg d "$2" --argjson p "$3" \
103
+ '{name:$n, description:$d, inputSchema:{type:"object", properties:$p, additionalProperties:false}}'
104
+ }
105
+
106
+ TOOLS="$(jq -sc '.' <<TOOLS_END
107
+ $(tool doctor "Check that the device, the driver and the app are all reachable. Run this first in every session, and again the moment any tool exits 3." '{}')
108
+ $(tool devices "List the attached devices and simulators, and say which one the config selects." '{}')
109
+ $(tool app "The app under test: get it in front of you, restart it, or put a new build on the phone. Launching asks the operating system by bundle id, so it works from the home screen or from another app — no icon has to be found or tapped. Start every session with launch. install goes to the device this config selects and to no other." '{"action":{"type":"string","enum":["launch","relaunch","kill","reset","state","install","uninstall"],"description":"launch brings it to the front. relaunch kills and launches, keeping stored data. reset uninstalls and reinstalls, discarding it. kill terminates it. state reports whether it is running. install puts a built bundle on the phone. uninstall removes it."},"path":{"type":"string","description":"For install: the .app bundle to put on the phone. DeviceTools does not build — give it what your build produced."},"bundle-id":{"type":"string","description":"For launch and uninstall: act on this app instead of the one in the config."},"launch":{"type":"boolean","description":"For install and reset: bring the app up once it is on."},"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
110
+ $(tool open "Open a deep link on the device and report which app came to the front. The URL needs a scheme: myapp://path, or https://. Not the app lifecycle — that is the app tool." '{"target":{"type":"string","description":"The URL to open, scheme included."},"expect-app":{"type":"string","description":"Fail unless this bundle id or package is the app that answered."},"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
111
+ $(tool snapshot "Read the screen: every element that can be acted on or carries text, with its rectangle, plus warnings — overlap, escaping a parent, off screen, a hit area below the platform minimum, text the OS truncated, and a control with no accessibility identifier. The number in brackets is a uid, valid only until the next snapshot; #name beside it is the identifier, which is the one selector that survives a change of copy or of language, so prefer id: over label: wherever the screen offers one. THIS IS THE DEFAULT WAY TO SEE. It costs about a third of a screenshot and, unlike one, it can be measured." '{"all":{"type":"boolean","description":"Every element, not just what can be acted on or carries text."},"grep":{"type":"string","description":"Case-insensitive substring of type, identifier, label or value. Searches the whole tree, including what the filter hides."}}')
112
+ $(tool screenshot "The screen as an image. Use this only for what a tree cannot say: a wrong icon, a blurry asset, a colour, a contrast, a gradient. For anything about position, size or text, snapshot is cheaper and exact." '{"scale":{"type":"integer","description":"Longest edge in pixels. Default 768, about 360 tokens."},"full":{"type":"boolean","description":"Device resolution, no downscale — about 1500 tokens."},"name":{"type":"string","description":"Optional file name; re-using one overwrites it."}}')
113
+ $(tool measure "Everything about one element: its rectangle, what contains it, what is above and below and how far, whether its text was truncated, which siblings it overlaps and by how much, whether its hit area meets the platform minimum. snapshot groups its warnings; this is how you find out which sibling." '{"uid":{"type":"integer","description":"The bracketed number from the last snapshot."}}')
114
+ $(tool tap "Tap one element. Pass a uid from the last snapshot, or a selector: id:, label:, text:, kind:<Type>, xy:x,y, or a plain string matched against id, label and value. kind: is the escape hatch for a control that snapshot flags as having no identifier and that also has no label — use it with index rather than falling back to raw coordinates, which break the moment the layout moves. Refuses rather than guesses when a selector matches more than one element, or when a uid no longer names what it named." '{"target":{"type":"string","description":"A uid such as \"7\", or a selector: id:, label:, text:, kind:<Type> for a control with no name at all, xy:x,y, or a plain string matched against id, label and value. Every selector matches a value WHOLE — text:OTP does not find a label containing OTP; snapshot grep searches inside."},"index":{"type":"integer","description":"Act on the Nth match, zero-based, when a selector deliberately matches several."},"count":{"type":"integer","description":"Tap the same point this many times, up to 20. Resolved once. Use this for a keypad — six digits is one call, not six."},"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
115
+ $(tool type "Type into whatever has keyboard focus, and verify that something changed. Tap the field first." '{"text":{"type":"string","description":"What to type."},"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
116
+ $(tool swipe "Swipe. Either a direction, or between two elements." '{"direction":{"type":"string","enum":["up","down","left","right"],"description":"Which way the content moves."},"from":{"type":"string","description":"Start point: a uid, a selector, or xy:x,y. Use with to, instead of direction."},"to":{"type":"string","description":"End point, same spellings as from."},"duration":{"type":"integer","description":"Milliseconds. Longer is slower; a slow drag is how some controls tell a swipe from a fling."},"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
117
+ $(tool scroll "Scroll, optionally until something appears." '{"direction":{"type":"string","description":"up, down, left or right."},"until":{"type":"string","description":"Optional selector to stop at."},"times":{"type":"integer","description":"How many swipes, when there is no --until."},"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
118
+ $(tool key "Put the on-screen keyboard away. tap refuses when the keyboard stands between it and a control; this is how you clear it." '{"action":{"type":"string","enum":["hide"],"description":"hide"},"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
119
+ $(tool back "Go back one screen. Fails if the screen does not change." '{"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
120
+ $(tool wait "Wait until an element appears, or disappears, then report which happened." '{"target":{"type":"string","description":"A uid or a selector."},"gone":{"type":"boolean","description":"Wait for it to disappear instead of appear."},"timeout":{"type":"integer","description":"Seconds. Defaults to the config."},"snapshot":{"type":"boolean","description":"Print the screen this left behind and renumber the uids, in the same call. Ask for it whenever the next thing you would do is read the screen — it halves the round trips of finding your way."}}')
121
+ $(tool logs "What the app under test printed. This is the Console panel. Only the app's own lines: the OS logs a great deal on an app's behalf under its process name, and none of it is the app talking. Pass action=start once per session to begin collecting; until then actions carry no log lines." '{"action":{"type":"string","description":"start, stop or status. Omit to read."},"grep":{"type":"string","description":"Only lines containing this."},"since":{"type":"string","description":"Seconds, e.g. 30."},"network":{"type":"boolean","description":"Only request- and response-shaped lines from the app. NOT a proxy: this filters the app'"'"'s own logging, so it shows nothing from an app that does not print its traffic."},"raw":{"type":"boolean","description":"Every line the device logged, not just the app'"'"'s. Thousands of lines from other processes — use only when the default returns nothing and you need to know why."},"lines":{"type":"integer","description":"How many lines at most."}}')
122
+ $(tool init "Make a directory into a DeviceTools project, so that config.json, flows/ and .state/ live in the repository holding the app instead of in the DeviceTools checkout. Run this once per app, then pass project=<that path> to everything else. Nothing is overwritten: an existing .devicetools, config.json or .gitignore entry is reported and left alone." '{"path":{"type":"string","description":"Absolute path of the repository. Required — there is no working directory on this side of the connection, and a default would make the DeviceTools checkout itself the project."}}')
123
+ $(tool run "Several steps in one call. Each step is an object with exactly one verb in it — {\"tap\":\"label:X\"}, {\"type\":\"...\"}, {\"wait\":\"screen:Home\"} — and any other key becomes a flag for that verb. USE THIS INSTEAD OF CALLING tap, type AND tap AGAIN: what a person does as one continuous act costs one call here, not four, and the number of calls is what this loop costs. It stops at the first failing step and shows you the screen it stopped on. Selectors only: a uid means a position on a screen that is already stale by the next step, so uids are refused." '{"steps":{"type":"array","items":{"type":"object"},"description":"The steps, in order. One verb per object; extra keys are that verb'"'"'s flags — including snapshot:true, which is a flag here and not a second verb. {\"expect\":\"screen:Auth.LoginView\"} is a wait with a short deadline: put one first so a batch fails at the door when the app is not where it was written to start."},"vars":{"type":"object","description":"Values for the {{ name }} placeholders in the steps. Put credentials here rather than in the steps, and they are masked back to {{ name }} in everything this prints."},"dry-run":{"type":"boolean","description":"Check every step and print what would run, without touching the device."},"logs":{"type":"boolean","description":"Keep the LOG block on every step. By default only the step that FAILED keeps its own — in a batch of eight, system log lines outweighed everything the batch actually said."},"project":{"type":"string","description":"Absolute path of the repository holding the app under test. Everything this call resolves — the config, flows/, .state/ — lands there instead of in the DeviceTools checkout. THERE IS NO WORKING DIRECTORY ON THIS SIDE OF THE CONNECTION: the walk-up rule that finds a project from a shell cannot see where you are, so name it. Every tool takes this."}}')
124
+ $(tool flow "A batch worth keeping, under a name — saved in the project as flows/<name>.json, so it is shared and reviewed like code. Every flow in an app starts by logging in; save that once and the next twenty sessions say flow run login. A step of {\"flow\":\"login\"} runs a saved flow inside another, so nothing has to repeat it. Credentials stay as {{ name }} and are supplied at run time." '{"action":{"type":"string","enum":["save","run","list","show","delete"],"description":"save writes one, run plays it, list names them, show prints one, delete removes it."},"name":{"type":"string","description":"The flow'"'"'s name."},"steps":{"type":"array","items":{"type":"object"},"description":"For save: the steps, in the same shape the run tool takes."},"description":{"type":"string","description":"For save: one line about what this flow does, shown by list."},"vars":{"type":"object","description":"For run: values for the {{ name }} placeholders the flow declares."},"dry-run":{"type":"boolean","description":"For run: check every step and print what would run, without touching the device."},"project":{"type":"string","description":"Absolute path of the repository holding the app under test. Everything this call resolves — the config, flows/, .state/ — lands there instead of in the DeviceTools checkout. THERE IS NO WORKING DIRECTORY ON THIS SIDE OF THE CONNECTION: the walk-up rule that finds a project from a shell cannot see where you are, so name it. Every tool takes this."}}')
125
+ $(tool waypoint "Mark where the app is now, or walk back to a place marked earlier. Use goto after reinstalling a build, which puts the app back on its first screen." '{"action":{"type":"string","description":"mark, goto, list or forget."},"name":{"type":"string","description":"The waypoint name, for mark, goto and forget."}}')
126
+ $(tool settings "Change a device condition that is invisible in the tree: light or dark appearance, text size, orientation. Not available on a physical iPhone, which exits 2 and says so." '{"key":{"type":"string","description":"appearance, textsize or orientation."},"value":{"type":"string","description":"The value to set."}}')
127
+ $(tool permission "Grant, revoke or reset a system permission for the app under test." '{"action":{"type":"string","description":"allow, deny or reset."},"name":{"type":"string","description":"The permission, e.g. camera, notifications, location."}}')
128
+ TOOLS_END
129
+ )"
130
+
131
+
132
+ # ------------------------------------------------------------------- running
133
+
134
+ # run_child — run a command with a deadline, without GNU timeout, which macOS
135
+ # does not ship.
136
+ #
137
+ # `set -m` puts the child in its own process group so the kill reaches what it
138
+ # spawned. A verb that hangs is usually hanging inside curl or adb, and killing
139
+ # only the shell that started them leaves them holding the device.
140
+ #
141
+ # STDIN COMES FROM /dev/null, AND THAT IS NOT TIDINESS. This server's stdin is
142
+ # the JSON-RPC stream. A child that reads stdin — `adb shell` does, always —
143
+ # would swallow the next request off the wire and the session would hang with no
144
+ # error anywhere.
145
+ CHILD_RC=0
146
+ CHILD_TIMED_OUT=0
147
+ run_child() {
148
+ local out="$1" err="$2"; shift 2
149
+ local pid ticks=0 limit=$(( TIMEOUT * 4 ))
150
+ CHILD_RC=0
151
+ CHILD_TIMED_OUT=0
152
+ set -m
153
+ "$@" >"$out" 2>"$err" </dev/null &
154
+ pid=$!
155
+ set +m
156
+ while kill -0 "$pid" 2>/dev/null; do
157
+ if [ "$ticks" -ge "$limit" ]; then
158
+ kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
159
+ sleep 1
160
+ kill -KILL -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
161
+ CHILD_TIMED_OUT=1
162
+ break
163
+ fi
164
+ sleep 0.25
165
+ ticks=$(( ticks + 1 ))
166
+ done
167
+ wait "$pid" 2>/dev/null || CHILD_RC=$?
168
+ [ "$CHILD_TIMED_OUT" -eq 0 ] || CHILD_RC=124
169
+ }
170
+
171
+ rc_meaning() {
172
+ case "$1" in
173
+ 0) printf 'done' ;;
174
+ 1) printf 'usage error' ;;
175
+ 2) printf 'environment or configuration problem' ;;
176
+ 3) printf 'device unreachable — run doctor' ;;
177
+ 4) printf 'the command ran but the screen did not satisfy it' ;;
178
+ 124) printf 'killed after %ss — set DEVICETOOLS_MCP_TIMEOUT to allow longer' "$TIMEOUT" ;;
179
+ *) printf 'unexpected exit code' ;;
180
+ esac
181
+ }
182
+
183
+ # ------------------------------------------------------------------ protocol
184
+
185
+ emit() { printf '%s\n' "$1"; }
186
+
187
+ reply() { # reply <id-json> <result-json>
188
+ emit "$(jq -cn --argjson id "$1" --argjson result "$2" '{jsonrpc:"2.0", id:$id, result:$result}')"
189
+ }
190
+
191
+ fail() { # fail <id-json> <code> <message>
192
+ emit "$(jq -cn --argjson id "$1" --argjson code "$2" --arg msg "$3" \
193
+ '{jsonrpc:"2.0", id:$id, error:{code:$code, message:$msg}}')"
194
+ }
195
+
196
+ # A tool result. isError is set for any non-zero exit, including 4 and 5, which
197
+ # are ordinary outcomes rather than faults — the host marks them, and the model
198
+ # still reads the text and decides what they mean.
199
+ tool_text() { # tool_text <id-json> <text> <is-error>
200
+ reply "$1" "$(jq -cn --arg t "$2" --argjson e "$3" '{content:[{type:"text",text:$t}], isError:$e}')"
201
+ }
202
+
203
+ # argv_from <arguments-json> — an object becomes a command line.
204
+ #
205
+ # NOTHING GOES THROUGH A SHELL. Arguments come out NUL-separated and are read
206
+ # straight into an array, so an argument is an argument whatever is in it: no
207
+ # quoting to satisfy, none to get wrong, and no injection surface. The app on
208
+ # the other end of this is somebody's production app. NUL and not newline,
209
+ # because `type` sends whatever text it is given and that can contain one.
210
+ #
211
+ # The positional list is the order the scripts read their arguments in. Anything
212
+ # not on it becomes --key value. A boolean becomes the presence or absence of
213
+ # --key, never "--key false", which every one of these scripts would read as a
214
+ # stray positional argument and refuse.
215
+ argv_from() {
216
+ printf '%s' "$1" | jq -j '
217
+ ["action","path","bundle-id","name","uid","target","text","direction","from","to","key","value"] as $pos
218
+ | . as $a
219
+
220
+ # EVERY REFERENCE IS BOUND TO A NAME, and that is not style. Inside
221
+ # `$a | has(.)` the dot is $a, not the key being tested, so the whole
222
+ # positional branch silently produced nothing and every tool was called
223
+ # with no arguments at all.
224
+ | ( $pos[] as $k
225
+ | select($a | has($k))
226
+ | ($a[$k] | tostring) + "\u0000" ),
227
+ ( $a | to_entries[] as $e
228
+ | select(($pos | index($e.key)) == null)
229
+ | if ($e.value | type) == "boolean"
230
+ then (if $e.value then "--" + $e.key + "\u0000" else empty end)
231
+ else "--" + $e.key + "\u0000" + ($e.value | tostring) + "\u0000"
232
+ end )
233
+ '
234
+ }
235
+
236
+ handle_verb() { # handle_verb <id-json> <verb> <arguments-json>
237
+ local id="$1" verb="$2" a="$3" args=() out err text rc p
238
+ while IFS= read -r -d '' one; do args+=("$one"); done < <(argv_from "$a")
239
+
240
+ out="$WORK/out"; err="$WORK/err"
241
+ run_child "$out" "$err" "$ROOT/scripts/$verb.sh" ${args[@]+"${args[@]}"}
242
+ rc="$CHILD_RC"
243
+
244
+ # A VERB WHOSE WHOLE OUTPUT IS A PATH TO AN IMAGE SENDS THE IMAGE.
245
+ #
246
+ # screenshot prints a filesystem path, which is a string the model cannot
247
+ # open, and the point of that verb is that somebody looks at the picture. The
248
+ # rule is written against the output rather than the verb's name, so there is
249
+ # no second list of "verbs that produce images" to fall out of date.
250
+ if [ "$rc" -eq 0 ]; then
251
+ p="$(cat "$out")"
252
+ case "$p" in
253
+ /*.png)
254
+ if [ -f "$p" ] && [ "$(wc -l < "$out" | tr -d ' ')" -le 1 ]; then
255
+ log "$verb -> image, $(wc -c < "$p" | tr -d ' ') bytes"
256
+ reply "$id" "$(jq -cn --arg d "$(base64 < "$p" | tr -d '\n')" \
257
+ '{content:[{type:"image",data:$d,mimeType:"image/png"}],isError:false}')"
258
+ return 0
259
+ fi ;;
260
+ esac
261
+ fi
262
+
263
+ text="$(cat "$out")"
264
+ if [ "$rc" -ne 0 ]; then
265
+ [ -z "$text" ] || text="$text
266
+ "
267
+ text="$text$(cat "$err")
268
+ EXIT $rc — $(rc_meaning "$rc")"
269
+ fi
270
+ [ -n "$text" ] || text="OK"
271
+
272
+ # THE ARGUMENTS ARE NOT LOGGED. `type` carries whatever the test types, and on
273
+ # this app that is a credential. The host writes stderr to a file.
274
+ log "$verb (${#args[@]} args) -> $rc"
275
+ tool_text "$id" "$text" "$( [ "$rc" -eq 0 ] && echo false || echo true )"
276
+ }
277
+
278
+
279
+ handle() { # handle <request-line>
280
+ local req="$1" method id has_id
281
+
282
+ if ! printf '%s' "$req" | jq -e . >/dev/null 2>&1; then
283
+ fail null -32700 "parse error"
284
+ return 0
285
+ fi
286
+
287
+ method="$(printf '%s' "$req" | jq -r '.method // empty')"
288
+ has_id="$(printf '%s' "$req" | jq -r 'has("id")')"
289
+ id="$(printf '%s' "$req" | jq -c '.id // null')"
290
+
291
+ # A notification carries no id and must never be answered, not even to say the
292
+ # method was unknown.
293
+ if [ "$has_id" != true ]; then
294
+ log "notification $method"
295
+ return 0
296
+ fi
297
+
298
+ case "$method" in
299
+ initialize)
300
+ local want got
301
+ want="$(printf '%s' "$req" | jq -r '.params.protocolVersion // empty')"
302
+ got="${SUPPORTED%% *}"
303
+ for v in $SUPPORTED; do [ "$v" = "$want" ] && got="$v"; done
304
+ log "initialize — client asked for ${want:-nothing}, answering $got"
305
+ reply "$id" "$(jq -cn --arg p "$got" --arg v "$SERVER_VERSION" '{
306
+ protocolVersion: $p,
307
+ capabilities: { tools: {} },
308
+ serverInfo: { name: "devicetools", version: $v },
309
+ instructions: "DeviceTools is DevTools for a phone attached to this machine. Call doctor first, then app launch — it opens the app under test by bundle id from wherever the phone happens to be, so you never have to find an icon. snapshot reads the screen: numbered elements with their rectangles, layout faults, and #name where the app supplies an accessibility identifier. Prefer id: over label: when there is one; a label changes with the copy. Act on the numbers — tap 7, measure 7 — but a number is only valid until the next snapshot. TWO THINGS DECIDE WHAT THIS COSTS YOU, AND BOTH ARE THE NUMBER OF CALLS. Pass snapshot=true to any action and it hands back the screen it produced, renumbered, in the same call — that is free, because the action reads the tree anyway. And when you already know several steps, send them as one run: run with steps [{\"tap\":\"label:X\"},{\"type\":\"...\"},{\"wait\":\"screen:Home\"}] does in one call what four calls did, stops at the first failure and shows you the screen it stopped on. Selectors only inside a run — a uid is stale by the next step. If you work out a sequence worth keeping, flow save puts it in the project under a name. Use screenshot only for what a tree cannot say: a wrong icon, a colour, a gradient. If the person is working in a repository, call init with its path once and then pass project=<that path> to everything: you have no working directory, so a saved flow otherwise lands in the DeviceTools checkout where every app on the machine shares it. After you reinstall a build, app relaunch then waypoint goto returns you to where you were."
310
+ }')" ;;
311
+
312
+ ping) reply "$id" '{}' ;;
313
+
314
+ tools/list) reply "$id" "$(jq -cn --argjson t "$TOOLS" '{tools:$t}')" ;;
315
+
316
+ tools/call)
317
+ local name a
318
+ name="$(printf '%s' "$req" | jq -r '.params.name // empty')"
319
+ a="$(printf '%s' "$req" | jq -c '.params.arguments // {}')"
320
+ case "$name" in
321
+ */*|.*|"") fail "$id" -32602 "not a tool name: $name" ;;
322
+ *)
323
+ if [ -x "$ROOT/scripts/$name.sh" ]; then
324
+ handle_verb "$id" "$name" "$a"
325
+ else
326
+ fail "$id" -32602 "unknown tool: $name"
327
+ fi ;;
328
+ esac ;;
329
+
330
+
331
+ *) fail "$id" -32601 "unknown method: $method" ;;
332
+ esac
333
+ }
334
+
335
+ log "ready — $ROOT, config $CONFIG, timeout ${TIMEOUT}s"
336
+
337
+ # One request at a time, deliberately. There is one device on the other end of
338
+ # this, and two verbs touching one screen at once is a race with a real tap in
339
+ # it. Serial is the correct semantics here, not a limitation of the loop.
340
+ while IFS= read -r line; do
341
+ [ -n "$line" ] || continue
342
+ handle "$line"
343
+ done