Skip to content

Commit e41492a

Browse files
authored
Merge pull request #4060 from jandubois/bats-mcp
Create BATS tests for limactl-mcp
2 parents 067f30f + 8b1f07d commit e41492a

File tree

2 files changed

+332
-1
lines changed

2 files changed

+332
-1
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,7 @@ check-generated:
552552
(echo "Please run 'make generate' when making changes to proto files and check-in the generated file changes" && false)
553553

554554
.PHONY: bats
555-
bats: native
555+
bats: native limactl-plugins
556556
PATH=$$PWD/_output/bin:$$PATH ./hack/bats/lib/bats-core/bin/bats --timing ./hack/bats/tests
557557

558558
.PHONY: lint

hack/bats/tests/mcp.bats

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
# SPDX-FileCopyrightText: Copyright The Lima Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
load "../helpers/load"
5+
6+
NAME=bats
7+
8+
# TODO Move helper functions to shared location
9+
run_yq() {
10+
run -0 --separate-stderr limactl yq "$@"
11+
}
12+
13+
json_edit() {
14+
limactl yq --input-format json --output-format json --indent 0 "$@"
15+
}
16+
17+
# TODO The reusable Lima instance setup is copied from preserve-env.bats
18+
# TODO and should be factored out into helper functions.
19+
local_setup_file() {
20+
if [[ -n "${LIMA_BATS_REUSE_INSTANCE:-}" ]]; then
21+
run limactl list --format '{{.Status}}' "$NAME"
22+
[[ $status == 0 ]] && [[ $output == "Running" ]] && return
23+
fi
24+
limactl unprotect "$NAME" || :
25+
limactl delete --force "$NAME" || :
26+
# Make sure that the host agent doesn't inherit file handles 3 or 4.
27+
# Otherwise bats will not finish until the host agent exits.
28+
limactl start --yes --name "$NAME" template://default 3>&- 4>&-
29+
}
30+
31+
local_teardown_file() {
32+
if [[ -z "${LIMA_BATS_REUSE_INSTANCE:-}" ]]; then
33+
limactl delete --force "$NAME"
34+
fi
35+
}
36+
37+
local_setup() {
38+
cd "$PATH_BATS_ROOT"
39+
coproc MCP { limactl mcp serve "$NAME"; }
40+
41+
ID=0
42+
mcp initialize '{"protocolVersion":"2025-06-18"}'
43+
44+
# Each mcp request should increment the ID
45+
[[ $ID -eq 1 ]]
46+
47+
run_yq .serverInfo.name <<<"$output"
48+
assert_output "lima"
49+
}
50+
51+
local_teardown() {
52+
kill "${MCP_PID:?}" 2>&1 >/dev/null || :
53+
}
54+
55+
mcp() {
56+
local method=$1
57+
local params=${2:-}
58+
59+
local request
60+
printf -v request '{"jsonrpc":"2.0","id":%d,"method":"%s"}' "$((++ID))" "$method"
61+
if [[ -n $params ]]; then
62+
request=$(json_edit ".params=${params}" <<<"$request")
63+
fi
64+
65+
# send request to MCP server stdin
66+
echo "$request" >&"${MCP[1]}"
67+
68+
# read response from MCP server stdout with 5s timeout
69+
local json
70+
read -t 5 -r json <&"${MCP[0]}"
71+
72+
# verify that the response matches the request; also validates the output is valid JSON
73+
run_yq .id <<<"$json"
74+
assert_output "$ID"
75+
76+
# there must be no error object in the response
77+
run_yq .error <<<"$json"
78+
assert_output "null"
79+
80+
# set $output to .result
81+
run_yq .result <<<"$json"
82+
}
83+
84+
tools_call() {
85+
local name=$1
86+
local args=${2:-}
87+
88+
local params
89+
printf -v params '{"name":"%s"}' "$name"
90+
if [[ -n $args ]]; then
91+
params=$(json_edit ".arguments=${args}" <<<"$params")
92+
fi
93+
mcp tools/call "$params"
94+
}
95+
96+
@test 'list tools' {
97+
mcp tools/list
98+
run_yq '.tools[].name' <<<"$output"
99+
assert_line glob
100+
assert_line list_directory
101+
assert_line read_file
102+
assert_line run_shell_command
103+
assert_line search_file_content
104+
assert_line write_file
105+
}
106+
107+
@test 'verify that tools descriptions include input and output schema' {
108+
mcp tools/list
109+
run_yq '.tools[] | select(.name == "run_shell_command")' <<<"$output"
110+
json=$output
111+
112+
run_yq '.inputSchema.required[]' <<<"$json"
113+
assert_line command
114+
assert_line directory
115+
assert_output_lines_count 2
116+
117+
run_yq '.inputSchema.properties | keys[]' <<<"$json"
118+
assert_line command
119+
assert_line description
120+
assert_line directory
121+
assert_output_lines_count 3
122+
123+
run_yq '.outputSchema.required[]' <<<"$json"
124+
assert_line stdout
125+
assert_line stderr
126+
assert_output_lines_count 2
127+
128+
run_yq '.outputSchema.properties | keys[]' <<<"$json"
129+
assert_line error
130+
assert_line exit_code
131+
assert_line stdout
132+
assert_line stderr
133+
assert_output_lines_count 4
134+
}
135+
136+
@test 'run shell command returns command output' {
137+
run -0 limactl shell "$NAME" cat /etc/os-release
138+
assert_output
139+
expected=$output
140+
141+
tools_call run_shell_command '{"directory":"/etc","command":["cat","os-release"]}'
142+
json=$output
143+
144+
run_yq '.structuredContent.exit_code' <<<"$json"
145+
assert_output 0
146+
147+
run_yq '.structuredContent.stdout' <<<"$json"
148+
assert_output "$expected"
149+
150+
run_yq '.structuredContent.stderr' <<<"$json"
151+
refute_output
152+
153+
# The same data is also available as encoded JSON
154+
run_yq '.content[0].type' <<<"$json"
155+
assert_output "text"
156+
157+
run_yq '.content[0].text' <<<"$json"
158+
text=$output
159+
160+
run_yq '.exit_code' <<<"$text"
161+
assert_output 0
162+
163+
run_yq '.stdout' <<<"$text"
164+
assert_output "$expected"
165+
166+
run_yq '.stderr' <<<"$text"
167+
refute_output
168+
}
169+
170+
@test 'run shell command returns stderr and exit code' {
171+
tools_call run_shell_command '{"directory":"/","command":["bash","-c","echo NO>&2; exit 13"]}'
172+
json=$output
173+
174+
run_yq '.structuredContent.exit_code' <<<"$json"
175+
assert_output 13
176+
177+
run_yq '.structuredContent.error' <<<"$json"
178+
assert_output "exit status 13"
179+
180+
run_yq '.structuredContent.stdout' <<<"$json"
181+
refute_output
182+
183+
run_yq '.structuredContent.stderr' <<<"$json"
184+
assert_output "NO"
185+
}
186+
187+
@test 'run shell command fails if the directory does not exist' {
188+
tools_call run_shell_command '{"directory":"/etcetera","command":["cat","os-release"]}'
189+
json=$output
190+
191+
run_yq '.structuredContent.exit_code' <<<"$json"
192+
assert_output 1
193+
194+
run_yq '.structuredContent.stderr' <<<"$json"
195+
assert_output --partial "No such file or directory"
196+
}
197+
198+
@test 'read_file reads a file' {
199+
run -0 limactl shell "$NAME" cat /etc/os-release
200+
assert_output
201+
expected=$output
202+
203+
tools_call read_file '{"path":"/etc/os-release"}'
204+
json=$output
205+
206+
run_yq '.content[0].text' <<<"$json"
207+
run_yq '.content' <<<"$output"
208+
assert_output "$expected"
209+
210+
run_yq '.structuredContent.content' <<<"$json"
211+
assert_output "$expected"
212+
}
213+
214+
@test 'read_file returns an error when path does not exist' {
215+
tools_call read_file '{"path":"/etc/os-release-info"}'
216+
json=$output
217+
218+
run_yq '.isError' <<<"$json"
219+
assert_output "true"
220+
221+
run_yq '.content[0].text' <<<"$json"
222+
assert_output "file does not exist"
223+
}
224+
225+
@test 'read_file returns an error when path is not absolute' {
226+
tools_call read_file '{"path":"os-release"}'
227+
json=$output
228+
229+
run_yq '.isError' <<<"$json"
230+
assert_output "true"
231+
232+
run_yq '.content[0].text' <<<"$json"
233+
assert_output --partial "expected an absolute path"
234+
}
235+
236+
@test 'write_file creates new file and overwrites existing file' {
237+
limactl shell "$NAME" rm -f /tmp/mcp.test
238+
tools_call write_file '{"path":"/tmp/mcp.test","content":"foo"}'
239+
240+
run_yq '.content[0].text' <<<"$output"
241+
assert_output "{}"
242+
243+
run -0 limactl shell "$NAME" cat /tmp/mcp.test
244+
assert_output "foo"
245+
246+
tools_call write_file '{"path":"/tmp/mcp.test","content":"bar"}'
247+
248+
run_yq '.content[0].text' <<<"$output"
249+
assert_output "{}"
250+
251+
run -0 limactl shell "$NAME" cat /tmp/mcp.test
252+
assert_output "bar"
253+
}
254+
255+
@test 'write_file creates the directory if it does not yet exist' {
256+
skip "see https://github.com/lima-vm/lima/issues/4174"
257+
258+
limactl shell "$NAME" rm -rf /tmp/tmp
259+
tools_call write_file '{"path":"/tmp/tmp/tmp","content":"tmp"}'
260+
json=$output
261+
262+
run_yq '.isError' <<<"$json"
263+
assert_output "null"
264+
265+
run -0 limactl shell "$NAME" cat /tmp/tmp/tmp
266+
assert_output "tmp"
267+
}
268+
269+
@test 'write_file returns an error when the directory is not writable' {
270+
limactl shell "$NAME" mkdir -p /tmp/tmp
271+
limactl shell "$NAME" chmod 444 /tmp/tmp
272+
tools_call write_file '{"path":"/tmp/tmp/tmp","content":"tmp"}'
273+
json=$output
274+
275+
run_yq '.isError' <<<"$json"
276+
assert_output "true"
277+
278+
run_yq '.content[0].text' <<<"$json"
279+
assert_output "permission denied"
280+
}
281+
282+
@test 'write_file returns an error when path is not absolute' {
283+
tools_call write_file '{"path":"tmp/mcp.test","content":"baz"}'
284+
json=$output
285+
286+
run_yq '.isError' <<<"$json"
287+
assert_output "true"
288+
289+
run_yq '.content[0].text' <<<"$json"
290+
assert_output --partial "expected an absolute path"
291+
}
292+
293+
@test 'glob finds files by wildcard' {
294+
tools_call glob '{"pattern":"*/*p.bats"}'
295+
296+
run_yq '.structuredContent.matches[]' <<<"$output"
297+
assert_line --regexp '/tests/mcp.bats$'
298+
}
299+
300+
@test 'glob returns an empty list when the pattern does not match' {
301+
skip "see https://github.com/lima-vm/lima/issues/4173"
302+
303+
tools_call glob '{"pattern":"nothing.to.see"}'
304+
305+
run_yq '.structuredContent.matches[]' <<<"$output"
306+
assert_output_lines_count 0
307+
}
308+
309+
@test 'search_file_content finds text inside files' {
310+
tools_call search_file_content '{"pattern":"needle in a haystack"}'
311+
312+
run_yq '.structuredContent.git_grep_output' <<<"$output"
313+
assert_line --regexp '^tests/mcp.bats:[0-9]+: +tools_call'
314+
}
315+
316+
@test 'search_file_content can find unicode characters above U+FFFF' {
317+
# The light bulb emoji 💡 (U+1F4A1)
318+
tools_call search_file_content '{"pattern":"💡"}'
319+
320+
run_yq '.structuredContent.git_grep_output' <<<"$output"
321+
assert_line --regexp '^tests/mcp.bats:[0-9]+: +# The light bulb'
322+
assert_line --regexp '^tests/mcp.bats:[0-9]+: +tools_call'
323+
}
324+
325+
@test 'search_file_content returns an empty string if it cannot find the pattern' {
326+
tools_call search_file_content "$(printf '{"pattern":"\U0001f4a1 not found"}')"
327+
328+
run_yq '.structuredContent.git_grep_output' <<<"$output"
329+
refute_output
330+
}
331+

0 commit comments

Comments
 (0)