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
|
@@ -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
|