ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/t/16_vim_auth.t
Revision: 1.24
Committed: Sun Oct 18 10:19:43 2026 UTC (46 hours, 4 minutes ago) by root
Content type: application/x-troff
Branch: MAIN
CVS Tags: HEAD
Changes since 1.23: +1 -1 lines
Log Message:
*** empty log message ***

File Contents

# Content
1 use common::sense;
2 use Test::More;
3 use JSON::XS ();
4 use IO::Socket::INET ();
5 use IO::Select ();
6 use Protocol::WebSocket::Frame ();
7 use IPC::Open3 ();
8 use POSIX qw(WNOHANG);
9 use Time::HiRes ();
10 use File::Temp ();
11 use File::Path ();
12 use Symbol ();
13
14 my $dir = File::Temp::tempdir (CLEANUP => 1);
15 File::Path::mkpath (["$dir/home", "$dir/config"], 0, 0700);
16 local $ENV{HOME} = "$dir/home";
17 local $ENV{XDG_CONFIG_HOME} = "$dir/config";
18
19 my $policy = "$dir/config/anyevent-mcp/rules/vim-default.ini";
20 File::Path::mkpath (["$dir/config/anyevent-mcp/rules"], 0, 0700);
21
22 sub permission_rules {
23 open my $fh, ">", $policy or die "$policy: $!";
24 chmod 0600, $policy or die "$policy: $!";
25 print {$fh} join "", map {
26 my ($decision, $action, $subject) = @$_;
27 "[rule]\ndecision=$decision\naction=$action\n"
28 . (defined $subject ? "subject=$subject\n" : "")
29 . ($decision eq "allow" ? "sandbox-grants=\n" : "") . "\n"
30 } @_;
31 close $fh or die "$policy: $!";
32 }
33
34 permission_rules (["allow", "read"], ["allow", "write"]);
35
36 my $listener = IO::Socket::INET->new (
37 LocalAddr => "127.0.0.1", LocalPort => 0, Proto => "tcp", Listen => 1,
38 ) or die "allocate TCP test port: $!";
39 my $port = $listener->sockport;
40 close $listener;
41
42 my $err = Symbol::gensym;
43 local $ENV{PERL_ANYEVENT_VERBOSE} = 6; # include notice-level rejection logs
44 my $pid = IPC::Open3::open3 (my $in, my $out, $err, $^X, "-Ilib",
45 "-e", 'unshift @INC, sub { die "Coro must not be loaded\\n" if $_[1] =~ m{^Coro(?:/|\\.pm)}; return }; do shift @ARGV; die $@ if $@;',
46 "./bin/mcp-vim", "--name", "default", "--stdio", "--bind", "127.0.0.1:$port",
47 "--token", "primary", "--token", "secondary", "--ask", "mcp");
48 my $old = select $in;
49 $| = 1;
50 select $old;
51
52 sub tcp {
53 IO::Socket::INET->new (
54 PeerAddr => "127.0.0.1", PeerPort => $port,
55 Proto => "tcp", Timeout => 1,
56 )
57 }
58
59 my $ready;
60 for (1 .. 1000) {
61 $ready = tcp ();
62 last if $ready;
63 Time::HiRes::sleep (0.03);
64 }
65 ok $ready, "mcp-vim listens on TCP with two static tokens";
66 # leave this connection silent while the other protocols are exercised.
67
68 sub rpc {
69 my ($id, $method, $params) = @_;
70 print {$in} JSON::XS::encode_json ({
71 jsonrpc => "2.0", id => $id, method => $method, params => $params,
72 }), "\n" or die "send JSONL request: $!";
73 IO::Select->new ($out)->can_read (30) or die "MCP stdio response timed out";
74 my $line = <$out> // die "unexpected MCP stdio EOF";
75 JSON::XS::decode_json $line
76 }
77
78 sub target_names {
79 my $r = rpc (++$main::request_id, "tools/call",
80 { name => "targets", arguments => {} });
81 return [] unless $r->{result}{content}[0]{text};
82 JSON::XS::decode_json $r->{result}{content}[0]{text}
83 }
84
85 sub vim_connect {
86 my ($token, $target) = @_;
87 my $sock = tcp () or die "connect private Vim TCP: $!";
88 my $hello = JSON::XS::encode_json ([
89 "mcp-vim", $token, 1, 0, $target, "test-$target",
90 ]) . "\n";
91 print {$sock} $hello or die "send private Vim hello: $!";
92 $sock
93 }
94
95 sub upgrade {
96 my ($token, $path) = @_;
97 my $sock = tcp () or die "connect browser WS TCP: $!";
98 $sock->autoflush (1);
99 $path //= "/$token/http://page.example:818";
100 my $req = "GET $path HTTP/1.1\r\n"
101 . "Host: 127.0.0.1:$port\r\n"
102 . "Upgrade: websocket\r\nConnection: Upgrade\r\n"
103 . "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
104 . "Sec-WebSocket-Version: 13\r\n"
105 . "Origin: http://page.example:818\r\n\r\n";
106 print {$sock} $req or die "send WebSocket upgrade: $!";
107
108 my $bytes = "";
109 my $deadline = Time::HiRes::time () + 2;
110 while ($bytes !~ /\r\n\r\n/ && Time::HiRes::time () < $deadline) {
111 last unless IO::Select->new ($sock)->can_read (0.2);
112 my $n = sysread $sock, my $chunk, 4096;
113 last unless $n;
114 $bytes .= $chunk;
115 }
116
117 my ($headers, $frames) = split /\r\n\r\n/, $bytes, 2;
118 ($sock, $headers // "", $frames // "")
119 }
120
121 sub ws_close_code {
122 my ($sock, $initial) = @_;
123 my $frame = Protocol::WebSocket::Frame->new;
124 $frame->append ($initial) if length $initial;
125 my $deadline = Time::HiRes::time () + 3;
126 while (Time::HiRes::time () < $deadline) {
127 while (defined (my $payload = $frame->next_bytes)) {
128 return unpack ("n", $payload) if $frame->opcode == 8 && length ($payload) >= 2;
129 }
130 next unless IO::Select->new ($sock)->can_read (0.2);
131 my $n = sysread $sock, my $bytes, 8192;
132 last unless $n;
133 $frame->append ($bytes);
134 }
135 undef
136 }
137
138 my $init = rpc (1, "initialize", {
139 protocolVersion => "2025-11-25", capabilities => {},
140 clientInfo => { name => "auth-test", version => "0" },
141 });
142 ok !$init->{error} && $init->{result}{serverInfo},
143 "stdio control channel initializes while a TCP peer sends no first byte";
144 is $init->{result}{serverInfo}{name}, "mcp-vim", "stdio reports mcp-vim as its identity";
145 like $init->{result}{instructions}, qr/Live Vim editing/,
146 "stdio gives MCP clients live-buffer guidance";
147 unlike JSON::XS::encode_json ($init->{result}), qr/xpcse/i,
148 "stdio initialization does not expose the legacy browser name";
149
150 my $discover = rpc (2, "server/discover", {});
151 is $discover->{result}{instructions}, $init->{result}{instructions},
152 "discovery gives the same Vim instructions as initialize";
153
154 {
155 my $http = tcp () or die "connect MCP HTTP: $!";
156 my $body = JSON::XS::encode_json ({
157 jsonrpc => "2.0", id => 3, method => "initialize",
158 params => {
159 protocolVersion => "2025-11-25", capabilities => {},
160 clientInfo => { name => "auth-test", version => "0" },
161 },
162 });
163 print {$http} "POST / HTTP/1.1\r\n",
164 "Host: 127.0.0.1:$port\r\n",
165 "Authorization: Bearer primary\r\n",
166 "Content-Type: application/json\r\n",
167 "Content-Length: ", length $body, "\r\n\r\n", $body
168 or die "send MCP HTTP request: $!";
169 local $/;
170 my $response = <$http> // "";
171 close $http;
172 my ($headers, $json) = split /\r\n\r\n/, $response, 2;
173 like $headers, qr{\AHTTP/1\.1 200\b}, "HTTP initialization succeeds";
174 my $result = eval { JSON::XS::decode_json $json };
175 is $result->{result}{serverInfo}{name}, "mcp-vim", "HTTP reports mcp-vim as its identity";
176 is $result->{result}{instructions}, $init->{result}{instructions},
177 "HTTP gives the same Vim instructions as stdio";
178 }
179
180 my $empty_state = rpc (4, "tools/call", { name => "state", arguments => {} });
181 is_deeply JSON::XS::decode_json ($empty_state->{result}{content}[0]{text}), {},
182 "state with no connected editors completes immediately with an empty object";
183
184 my $bad = vim_connect ("unknown", "bad-vim");
185 my $before = target_names ();
186 ok !grep ($_ eq "bad-vim", @$before), "unknown private Vim token cannot register a target";
187 close $bad;
188
189 my $valid = vim_connect ("secondary", "secondary-vim");
190 my $visible;
191 for (1 .. 1000) {
192 my $names = target_names ();
193 $visible = grep $_ eq "secondary-vim", @$names;
194 last if $visible;
195 Time::HiRes::sleep (0.03);
196 }
197 ok $visible, "secondary token authenticates private Vim handshake";
198
199 # A private Vim RPC supplying disjoint ranges must yield a separate
200 # [range=...] metadata text entry and an unchanged lines text entry for each.
201 print {$in} JSON::XS::encode_json ({
202 jsonrpc => "2.0", id => 180, method => "tools/call",
203 params => { name => "read-lines", arguments => {
204 target => "secondary-vim", buffer => 1, ranges => ["727,728", "985,986"],
205 } },
206 }), "\n" or die "send Vim read-lines request: $!";
207
208 my $wire = "";
209 my $vim_call;
210 for (1 .. 100) {
211 IO::Select->new ($valid)->can_read (0.3)
212 or next;
213 my $n = sysread $valid, my $fragment, 8192;
214 last unless $n;
215 $wire .= $fragment;
216 $vim_call = eval { JSON::XS::decode_json $wire };
217 last if $vim_call;
218 }
219 ok $vim_call && $vim_call->[1][0] eq "call"
220 && $vim_call->[1][2] eq "read_lines",
221 "read-lines delegates disjoint ranges to the private Vim RPC";
222 if ($vim_call) {
223 $valid->autoflush (1);
224 print {$valid} JSON::XS::encode_json ([0, [
225 reply => $vim_call->[1][1], 1, {
226 changedtick => 7,
227 ranges => [
228 { range => "727,728", start => 727, end => 728, lines => ["function alpha () {", " return a;"] },
229 { range => "985,986", start => 985, end => 986, lines => ["function beta () {", " return b;"] },
230 ],
231 },
232 ]]) or die "send mock Vim RPC reply: $!";
233 }
234 IO::Select->new ($out)->can_read (30) or die "read-lines MCP reply timeout";
235 my $lines_reply = JSON::XS::decode_json (scalar <$out>);
236 my $blocks = $lines_reply->{result}{content} || [];
237 is @{$blocks}, 5, "Vim returns initial metadata plus two text entries per requested range";
238 is_deeply [map $_->{type}, @$blocks], [("text") x 5],
239 "all Vim metadata and source blocks remain MCP text entries";
240 is_deeply JSON::XS::decode_json ($blocks->[0]{text}), {
241 changedtick => 7,
242 ranges => [
243 { range => "727,728", start => 727, end => 728 },
244 { range => "985,986", start => 985, end => 986 },
245 ],
246 }, "Vim changedtick and resolved range metadata remain available";
247 is_deeply [map $_->{text}, @{$blocks}[1..4]], [
248 "[range=727,728]",
249 "function alpha () {\n return a;\n",
250 "[range=985,986]",
251 "function beta () {\n return b;\n",
252 ], "each Vim range has visible standalone metadata and unchanged newline-terminated source";
253
254
255 sub send_tool {
256 my ($id, $name, $args) = @_;
257 print {$in} JSON::XS::encode_json ({
258 jsonrpc => "2.0", id => $id, method => "tools/call",
259 params => { name => $name, arguments => $args },
260 }), "\n" or die "send tool request: $!";
261 }
262
263 my %vim_parser;
264
265 sub vim_call {
266 my $socket = $_[0] || $valid;
267 my $parser = $vim_parser{fileno $socket} ||= JSON::XS->new->utf8;
268 for (1 .. 120) {
269 my $packet = $parser->incr_parse;
270 return $packet if $packet;
271 next unless IO::Select->new ($socket)->can_read (0.25);
272 my $n = sysread $socket, my $fragment, 8192;
273 last unless $n;
274 $parser->incr_parse ($fragment); # void context: leave complete packets for the next iteration
275 }
276 die "private Vim message timeout\n";
277 }
278
279 sub vim_reply {
280 my ($call, $success, $result, $socket) = @_;
281 $socket ||= $valid;
282 print {$socket} JSON::XS::encode_json ([0, [
283 reply => $call->[1][1], $success, $result,
284 ]]) or die "send Vim RPC reply: $!";
285 }
286
287 sub mcp_reply {
288 IO::Select->new ($out)->can_read (30) or die "MCP reply timeout\n";
289 JSON::XS::decode_json (scalar <$out>)
290 }
291
292 send_tool 184, "read-lines", {
293 target => "secondary-vim", buffer => 1, ranges => ["40,41", "8"], linenums => JSON::XS::true,
294 };
295 my $numbered_call = vim_call;
296 vim_reply $numbered_call, 1, {
297 changedtick => 7,
298 ranges => [
299 { range => "40,41", start => 40, end => 41, lines => ["", "second"] },
300 { range => "8", start => 8, end => 8, lines => ["first"] },
301 ],
302 };
303 my $numbered_reply = mcp_reply;
304 is_deeply [map $_->{text}, @{$numbered_reply->{result}{content}}[1..4]], [
305 "[range=40,41]", "40:\n41:second\n", "[range=8,8]", "8:first\n",
306 ], "Vim linenums prefixes absolute numbers for disjoint ranges and empty lines";
307
308 my $other = vim_connect ("secondary", "parallel-vim");
309 $other->autoflush (1);
310 my $other_visible;
311 for (1 .. 1000) {
312 $other_visible = grep $_ eq "parallel-vim", @{target_names ()};
313 last if $other_visible;
314 Time::HiRes::sleep (0.03);
315 }
316 ok $other_visible, "second mock editor connected for parallel state collection";
317
318 sub permission_params {
319 my ($name, $args, $state, $answer) = @_;
320 +{
321 name => $name, arguments => $args,
322 _meta => {
323 "io.modelcontextprotocol/protocolVersion" => "2026-07-28",
324 "io.modelcontextprotocol/clientCapabilities" => { elicitation => { form => {} } },
325 },
326 (defined $state ? (
327 requestState => $state, inputResponses => { permission => $answer },
328 ) : ()),
329 }
330 }
331
332 # a saved grant for one editor must not authorize another, or start a partial fan-out.
333 permission_rules (["allow", "read", "targets"], ["allow", "read", "target:secondary-vim"]);
334 my $challenge = rpc (800, "tools/call", permission_params ("state", {}));
335 is $challenge->{result}{resultType}, "input_required", "another editor requires its own read permission";
336 ok !IO::Select->new ($valid, $other)->can_read (0.1), "state starts no RPC before all editor permissions pass";
337 my $declined = rpc (801, "tools/call", permission_params (
338 "state", {}, $challenge->{result}{requestState}, { action => "decline" },
339 ));
340 ok $declined->{result}{isError}, "declining an editor permission fails the state request";
341 ok !IO::Select->new ($valid, $other)->can_read (0.1), "declined state sends no editor RPC";
342
343 $challenge = rpc (802, "tools/call", permission_params ("state", {}));
344 print {$in} JSON::XS::encode_json ({
345 jsonrpc => "2.0", id => 803, method => "tools/call",
346 params => permission_params ("state", {}, $challenge->{result}{requestState}, {
347 action => "accept", content => { decision => "allow-once" },
348 }),
349 }), "\n";
350 my $approved_first = vim_call;
351 my $approved_second = vim_call $other;
352 vim_reply $approved_first, 1, { approved => 1 };
353 vim_reply $approved_second, 1, { approved => 2 }, $other;
354 is mcp_reply ()->{id}, 803, "approved state dispatches both RPCs and completes the continuation";
355 $challenge = rpc (804, "tools/call", permission_params ("state", { target => "parallel-vim" }));
356 is $challenge->{result}{resultType}, "input_required", "one-time approval does not grant later editor reads";
357 rpc (805, "tools/call", permission_params (
358 "state", { target => "parallel-vim" }, $challenge->{result}{requestState}, { action => "decline" },
359 ));
360
361 permission_rules (["allow", "read"]);
362 my $patch_args = {
363 target => "secondary-vim", buffer => 1,
364 diff => "@@ -1 @@\n-old\n+new\n", expected_changedtick => 7,
365 };
366 my $nul_patch = rpc (1090, "tools/call", permission_params ("apply-diff", {
367 %$patch_args, diff => "@@ -1 @@\n-a\0z\n+b\0z\n",
368 }));
369 ok $nul_patch->{result}{isError} && $nul_patch->{result}{content}[0]{text} =~ /NUL/,
370 "NUL diff is rejected before permission prompting";
371 ok !IO::Select->new ($valid)->can_read (0.1), "NUL diff never reaches Vim's lossy JSON decoder";
372 my $patch_challenge = rpc (806, "tools/call", permission_params ("apply-diff", $patch_args));
373
374 is_deeply $patch_challenge->{result}{inputRequests}{permission}{params}{_meta}{"anyevent-mcp/permission"}{request}{details},
375 { "View patch" => "Editor: secondary-vim\nBuffer: 1\n\n$patch_args->{diff}" },
376 "Vim patch permission carries generic details with editor and buffer";
377 like $patch_challenge->{result}{inputRequests}{permission}{params}{message}, qr/-old\n\+new\n/,
378 "native Vim permission displays the patch";
379 ok !IO::Select->new ($valid)->can_read (0.1), "patch preview starts no editor RPC";
380 rpc (807, "tools/call", permission_params (
381 "apply-diff", $patch_args, $patch_challenge->{result}{requestState}, { action => "decline" },
382 ));
383 ok !IO::Select->new ($valid)->can_read (0.1), "declined patch starts no editor RPC";
384
385 permission_rules (["allow", "read"], ["deny", "write"]);
386 for my $case (
387 ["apply-diff", { target => "secondary-vim", buffer => 1, diff => "@@ -1 @@\n-old\n+new\n", expected_changedtick => 7 }],
388 ["substitute", { target => "secondary-vim", buffer => 1, range => "%", pattern => "x", replacement => "y", flags => "", expected_changedtick => 7 }],
389 ["undo", { target => "secondary-vim", buffer => 1, expected_changedtick => 7 }],
390 ["redo", { target => "secondary-vim", buffer => 1, expected_changedtick => 7 }],
391 ["set-cursor", { target => "secondary-vim", buffer => 1, line => 1, column => 1 }],
392 ) {
393 my $reply = rpc (++$main::permission_id + 810, "tools/call", { name => $case->[0], arguments => $case->[1] });
394 ok $reply->{result}{isError}, "$case->[0] requires write permission despite an existing read grant";
395 ok !IO::Select->new ($valid)->can_read (0.1), "$case->[0] denial precedes Vim RPC";
396 }
397 permission_rules (["deny", "read"], ["allow", "write"]);
398 for my $case (
399 ["targets", {}],
400 ["state", { target => "secondary-vim" }],
401 ["read-lines", { target => "secondary-vim", buffer => 1, ranges => ["1"] }],
402 ["grep", { target => "secondary-vim", buffers => [1], pattern => "x", mode => "word", max_results => 1 }],
403 ) {
404 my $reply = rpc (++$main::permission_id + 810, "tools/call", { name => $case->[0], arguments => $case->[1] });
405 ok $reply->{result}{isError}, "$case->[0] requires read permission despite an existing write grant";
406 ok !IO::Select->new ($valid)->can_read (0.1), "$case->[0] denial precedes Vim RPC";
407 }
408 {
409 my $http = tcp () or die "connect permission HTTP test: $!";
410 $http->autoflush (1);
411 my $body = JSON::XS::encode_json ({
412 jsonrpc => "2.0", id => 840, method => "tools/call",
413 params => { name => "targets", arguments => {} },
414 });
415 print {$http} "POST / HTTP/1.1\r\nHost: localhost\r\n",
416 "Authorization: Bearer primary\r\nContent-Type: application/json\r\n",
417 "Content-Length: ", length $body, "\r\n\r\n", $body;
418 IO::Select->new ($http)->can_read (30) or die "HTTP permission response timed out\n";
419 local $/;
420 my $wire = <$http>;
421 close $http;
422 my (undef, $json) = split /\r\n\r\n/, $wire, 2;
423 my $reply = JSON::XS::decode_json $json;
424 ok $reply->{result}{isError}, "HTTP tool calls enforce the same read denial as stdio";
425 }
426 permission_rules (["allow", "read"], ["allow", "write"]);
427
428 # read/write grants must not authorize unrestricted execution.
429 my $ex_args = { target => "secondary-vim", command => "echo 'hello' | echo 'world'\nlet g:example = 1" };
430 my $ex_challenge = rpc (850, "tools/call", permission_params ("ex", $ex_args));
431 is $ex_challenge->{result}{resultType}, "input_required", "read and write grants do not authorize exec";
432 like ($ex_challenge->{result}{inputRequests}{permission}{params}{message}, qr/\Q$ex_args->{command}\E/,
433 "exec permission prompt includes the complete command");
434 ok !IO::Select->new ($valid)->can_read (0.1), "exec waits for permission before sending Vim a command";
435 my $ex_denied = rpc (851, "tools/call", permission_params (
436 "ex", $ex_args, $ex_challenge->{result}{requestState}, { action => "decline" },
437 ));
438 ok $ex_denied->{result}{isError}, "declining exec does not execute the command";
439
440 $ex_challenge = rpc (852, "tools/call", permission_params ("ex", $ex_args));
441 my $changed_ex = rpc (853, "tools/call", permission_params (
442 "ex", { %$ex_args, command => "echo 'different'" }, $ex_challenge->{result}{requestState},
443 { action => "accept", content => { decision => "allow-once" } },
444 ));
445 ok $changed_ex->{result}{isError}, "exec continuation cannot substitute another command";
446 ok !IO::Select->new ($valid)->can_read (0.1), "changed exec continuation sends no Vim RPC";
447
448 $ex_challenge = rpc (854, "tools/call", permission_params ("ex", $ex_args));
449 print {$in} JSON::XS::encode_json ({
450 jsonrpc => "2.0", id => 855, method => "tools/call",
451 params => permission_params ("ex", $ex_args, $ex_challenge->{result}{requestState}, {
452 action => "accept", content => { decision => "allow-once" },
453 }),
454 }), "\n";
455 my $ex_call = vim_call;
456 is_deeply [@{$ex_call->[1]}[2, 3]], ["ex", $ex_args->{command}], "approved Ex text reaches Vim without filtering";
457 vim_reply $ex_call, 1, { output => "\nhello\nworld" };
458 my $ex_result = mcp_reply;
459 is_deeply JSON::XS::decode_json ($ex_result->{result}{content}[0]{text}), { output => "\nhello\nworld" },
460 "exec returns the captured Vim messages";
461 $ex_challenge = rpc (856, "tools/call", permission_params ("ex", $ex_args));
462 is $ex_challenge->{result}{resultType}, "input_required", "one-time exec permission does not authorize a later call";
463 rpc (857, "tools/call", permission_params (
464 "ex", $ex_args, $ex_challenge->{result}{requestState}, { action => "decline" },
465 ));
466
467 $ex_challenge = rpc (861, "tools/call", permission_params ("ex", $ex_args));
468 my $ex_rule = rpc (862, "tools/call", permission_params (
469 "ex", $ex_args, $ex_challenge->{result}{requestState},
470 { action => "accept", content => { decision => "allow-always" } },
471 ));
472 my $ex_scopes = $ex_rule->{result}{inputRequests}{permission}{params}{requestedSchema}{properties}{subject}{enum};
473 is scalar @$ex_scopes, 3, "Ex offers exact command, editor-wide and global persistent scopes";
474 is $ex_scopes->[0], 'ex:"secondary-vim":' . JSON::XS->new->allow_nonref->encode ($ex_args->{command}),
475 "exact scope encodes the complete command including newlines";
476 print {$in} JSON::XS::encode_json ({
477 jsonrpc => "2.0", id => 863, method => "tools/call",
478 params => permission_params ("ex", $ex_args, $ex_rule->{result}{requestState}, {
479 action => "accept", content => {
480 decision => "allow-always", sandbox_grants => "", subject => $ex_scopes->[0],
481 },
482 }),
483 }), "\n";
484 $ex_call = vim_call;
485 vim_reply $ex_call, 1, { output => "" };
486 ok !mcp_reply ()->{result}{isError}, "exact-command persistent approval executes the approved call";
487 send_tool 864, "ex", { %$ex_args, buffer => 7, expected_changedtick => 42 };
488 $ex_call = vim_call;
489 is_deeply [@{$ex_call->[1]}[2 .. 5]], ["ex", $ex_args->{command}, 7, 42],
490 "exact approval survives buffer/tick changes and forwards both guards to Vim";
491 vim_reply $ex_call, 0, "rpc/stale_buffer: expected 42, got 43";
492 ok mcp_reply ()->{result}{isError}, "stale guarded Ex execution returns a tool error";
493 for my $args (
494 { %$ex_args, command => "$ex_args->{command} " },
495 { %$ex_args, command => "echo 'different'" },
496 { %$ex_args, target => "parallel-vim" },
497 ) {
498 my $id = ++$main::permission_id + 950;
499 my $challenge = rpc ($id, "tools/call", permission_params ("ex", $args));
500 is $challenge->{result}{resultType}, "input_required", "exact approval rejects changed command text or editor";
501 rpc ($id, "tools/call", permission_params (
502 "ex", $args, $challenge->{result}{requestState}, { action => "decline" },
503 ));
504 }
505 my $missing_buffer = rpc (865, "tools/call", permission_params (
506 "ex", { %$ex_args, expected_changedtick => 42 },
507 ));
508 ok $missing_buffer->{result}{isError}, "changedtick without a buffer is rejected";
509 ok !IO::Select->new ($valid, $other)->can_read (0.1), "unapproved and malformed Ex calls send no Vim RPC";
510
511 permission_rules (["allow", "read"], ["allow", "write"], ["allow", "exec", "target:secondary-vim"]);
512 $ex_challenge = rpc (866, "tools/call", permission_params ("ex", $ex_args));
513 is $ex_challenge->{result}{resultType}, "input_required", "legacy literal target exec rule requires a fresh approval";
514 rpc (867, "tools/call", permission_params (
515 "ex", $ex_args, $ex_challenge->{result}{requestState}, { action => "decline" },
516 ));
517 permission_rules (["allow", "read"], ["allow", "write"], ["allow", "exec", $ex_scopes->[1]]);
518 send_tool 858, "ex", { target => "secondary-vim", command => "NoSuchExCommand" };
519 $ex_call = vim_call;
520 vim_reply $ex_call, 0, "Vim:E492: Not an editor command";
521 my $ex_error = mcp_reply;
522 ok $ex_error->{result}{isError} && $ex_error->{result}{content}[0]{text} =~ /E492/,
523 "Vim execution failure becomes a tool error";
524 $ex_challenge = rpc (859, "tools/call", permission_params ("ex", { %$ex_args, target => "parallel-vim" }));
525 is $ex_challenge->{result}{resultType}, "input_required", "saved exec permission remains scoped to its editor";
526 ok !IO::Select->new ($other)->can_read (0.1), "unapproved editor receives no exec RPC";
527 rpc (860, "tools/call", permission_params (
528 "ex", { %$ex_args, target => "parallel-vim" }, $ex_challenge->{result}{requestState}, { action => "decline" },
529 ));
530 for my $command ("", [], "echo\0bad") {
531 my $reply = rpc (++$main::permission_id + 900, "tools/call", {
532 name => "ex", arguments => { target => "secondary-vim", command => $command },
533 });
534 ok $reply->{result}{isError}, "malformed Ex command is rejected before RPC";
535 }
536 ok !IO::Select->new ($valid)->can_read (0.1), "invalid commands never reach Vim";
537 permission_rules (["allow", "read"], ["allow", "write"]);
538
539 send_tool 181, "state", {};
540 my $first_state_call = vim_call;
541 my $second_state_call = vim_call $other;
542 is_deeply [$first_state_call->[1][2], $second_state_call->[1][2]], ["state", "state"],
543 "state sends both editor requests before waiting for either reply";
544 my $first_state = { cwd => "/first", buffers => [] };
545 my $second_state = { cwd => "/second", buffers => [] };
546 vim_reply $second_state_call, 1, $second_state, $other;
547 vim_reply $first_state_call, 1, $first_state;
548 my $states = mcp_reply;
549 is $states->{id}, 181, "parallel state completes the original request";
550 is_deeply JSON::XS::decode_json ($states->{result}{content}[0]{text}), {
551 "secondary-vim" => $first_state,
552 "parallel-vim" => $second_state,
553 }, "state collects both editor replies under their target names";
554
555 send_tool 182, "state", {};
556 my $failed_state_call = vim_call;
557 my $late_state_call = vim_call $other;
558 vim_reply $failed_state_call, 0, "mock state error";
559 my $state_error = mcp_reply;
560 ok $state_error->{id} == 182 && $state_error->{result}{isError}
561 && $state_error->{result}{content}[0]{text} =~ /mock state error/,
562 "state fails without waiting for the other editor";
563 vim_reply $late_state_call, 1, $second_state, $other;
564
565 send_tool 183, "state", { target => "secondary-vim" };
566 my $selected_state_call = vim_call;
567 vim_reply $selected_state_call, 1, $first_state;
568 my $selected_state = mcp_reply;
569 is $selected_state->{id}, 183, "late state reply does not complete a failed request again";
570 is_deeply JSON::XS::decode_json ($selected_state->{result}{content}[0]{text}),
571 { "secondary-vim" => $first_state }, "state can select one editor";
572 close $other;
573
574 permission_rules (["allow", "read"], ["allow", "write"], ["allow", "save"]);
575 my $tool_id = 190;
576 for my $case (
577 ["grep", {
578 target => "secondary-vim", buffers => [1], pattern => "alpha",
579 mode => "word", max_results => 2,
580 }, "grep", [[1], "alpha", "word", 2]],
581 ["apply-diff", {
582 target => "secondary-vim", buffer => 1,
583 diff => "@@ -1 @@\n-old\n+new\n", expected_changedtick => 7,
584 }, "apply_patch", [1, "@@ -1,1 +1,1 @@\n-old\n+new\n", [0], 7]],
585 ["substitute", {
586 target => "secondary-vim", buffer => 1, range => "%",
587 pattern => "alpha", replacement => "beta", flags => "g",
588 expected_changedtick => 7,
589 }, "substitute", [1, "%", "alpha", "beta", "g", 7]],
590 ["undo", {
591 target => "secondary-vim", buffer => 1, expected_changedtick => 7,
592 }, "undo", [1, 7]],
593 ["redo", {
594 target => "secondary-vim", buffer => 1, expected_changedtick => 7,
595 }, "redo", [1, 7]],
596 ["save", {
597 target => "secondary-vim", buffer => 1, expected_changedtick => 7,
598 }, "update", [1, 7]],
599 ["set-cursor", {
600 target => "secondary-vim", buffer => 1, line => 2, column => 3,
601 expected_changedtick => 7,
602 }, "set_cursor", [1, 2, 3, 7]],
603 ["set-cursor", {
604 target => "secondary-vim", buffer => 1, line => 2, column => 3,
605 }, "set_cursor", [1, 2, 3]],
606 ) {
607 my ($name, $args, $command, $rpc_args) = @$case;
608 send_tool ++$tool_id, $name, $args;
609 my $call = vim_call;
610 is_deeply [@{$call->[1]}[2 .. $#{$call->[1]}]], [$command, @$rpc_args],
611 "$name forwards its arguments and guards through callback RPC";
612 is JSON::XS::encode_json ([$call->[1][3]]), "[1]", "apply-diff keeps buffer ID numeric"
613 if $name eq "apply-diff";
614 my $result = { changedtick => 8, tool => $name };
615 vim_reply $call, 1, $result;
616 my $reply = mcp_reply;
617 is $reply->{id}, $tool_id, "$name completes the original MCP request";
618 is_deeply JSON::XS::decode_json ($reply->{result}{content}[0]{text}), $result,
619 "$name preserves the JSON tool result";
620 }
621
622 permission_rules (["allow", "read"], ["allow", "write"], ["allow", "exec"]);
623 my $save_args = { target => "secondary-vim", buffer => 1, expected_changedtick => 7 };
624 my $save_challenge = rpc (1050, "tools/call", permission_params ("save", $save_args));
625 is $save_challenge->{result}{resultType}, "input_required", "editing and exec grants do not imply save permission";
626 is $save_challenge->{result}{inputRequests}{permission}{params}{_meta}{"anyevent-mcp/permission"}{request}{action},
627 "save", "save uses the separate save action";
628 ok !IO::Select->new ($valid)->can_read (0.1), "unapproved save sends no Vim RPC";
629 my $save_denied = rpc (1051, "tools/call", permission_params (
630 "save", $save_args, $save_challenge->{result}{requestState}, { action => "decline" },
631 ));
632 ok $save_denied->{result}{isError}, "declining save denies save tool";
633 permission_rules (["allow", "read"], ["allow", "save"], ["deny", "write"]);
634 send_tool 1052, "save", $save_args;
635 my $save_call = vim_call;
636 is_deeply [@{$save_call->[1]}[2 .. $#{$save_call->[1]}]], ["update", 1, 7],
637 "save grant permits the save tool without edit permission";
638 vim_reply $save_call, 1, { buffer => 1, changedtick => 7, modified => 0, filename => "/saved" };
639 is mcp_reply ()->{id}, 1052, "save returns the update result";
640 permission_rules (["allow", "read"], ["allow", "write"]);
641
642 permission_rules (["allow", "read"], ["allow", "write"], ["allow", "save"], ["allow", "exec"]);
643 my $open_args = { target => "secondary-vim", path => "/tmp/literal | % # [name]" };
644 my $open_challenge = rpc (1100, "tools/call", permission_params ("open", $open_args));
645 is $open_challenge->{result}{resultType}, "input_required", "read/edit/save/exec grants do not imply opening files";
646 my $open_request = $open_challenge->{result}{inputRequests}{permission}{params}{_meta}{"anyevent-mcp/permission"}{request};
647 is $open_request->{action}, "open", "opening files has a separate action";
648 like $open_request->{reason}, qr/\Q$open_args->{path}\E/, "open permission displays the requested pathname";
649 ok !IO::Select->new ($valid)->can_read (0.1), "unapproved open sends no Vim RPC";
650 my $open_denied = rpc (1101, "tools/call", permission_params (
651 "open", $open_args, $open_challenge->{result}{requestState}, { action => "decline" },
652 ));
653 ok $open_denied->{result}{isError}, "declining open stops the operation";
654 permission_rules (["allow", "open"], ["deny", "read"], ["deny", "write"]);
655 send_tool 1102, "open", $open_args;
656 my $open_call = vim_call;
657 is_deeply [@{$open_call->[1]}[2 .. $#{$open_call->[1]}]], ["open", $open_args->{path}],
658 "open forwards the literal pathname without requiring read/edit grants";
659 my $opened_buffer = { buffer => 4, filename => $open_args->{path}, changedtick => 2, line_count => 3, modified => 0 };
660 vim_reply $open_call, 1, $opened_buffer;
661 my $open_reply = mcp_reply;
662 is_deeply JSON::XS::decode_json ($open_reply->{result}{content}[0]{text}), $opened_buffer,
663 "open returns the loaded buffer identity and state";
664 for my $path ("", [], "bad\0path") {
665 my $reply = rpc (++$main::open_test_id + 1110, "tools/call",
666 { name => "open", arguments => { target => "secondary-vim", path => $path } });
667 ok $reply->{result}{isError}, "open rejects empty, non-text and NUL-containing paths";
668 ok !IO::Select->new ($valid)->can_read (0.1), "invalid open pathname never reaches Vim";
669 }
670 permission_rules (["allow", "read"], ["allow", "write"]);
671
672 for my $operation (qw(undo redo save)) {
673 for my $args (
674 { target => "secondary-vim", buffer => 1 },
675 { target => "secondary-vim", buffer => 0, expected_changedtick => 7 },
676 { target => "secondary-vim", buffer => 1, expected_changedtick => -1 },
677 ) {
678 my $reply = rpc (++$main::undo_test_id + 1000, "tools/call",
679 { name => $operation, arguments => $args });
680 ok $reply->{result}{isError}, "$operation rejects an invalid buffer or missing/invalid tick";
681 ok !IO::Select->new ($valid)->can_read (0.1), "$operation validation precedes Vim RPC";
682 }
683 }
684
685 my $grep_args = {
686 target => "secondary-vim", buffers => [1], pattern => "alpha",
687 mode => "word", max_results => 2,
688 };
689 send_tool 196, "grep", $grep_args;
690 my $failed_call = vim_call;
691 vim_reply $failed_call, 0, "mock Vim error";
692 my $failed_reply = mcp_reply;
693 ok $failed_reply->{id} == 196 && $failed_reply->{result}{isError}
694 && $failed_reply->{result}{content}[0]{text} =~ /mock Vim error/,
695 "failed Vim RPC becomes an MCP tool error";
696
697 send_tool 197, "grep", $grep_args;
698 my $cancelled_call = vim_call;
699 print {$in} JSON::XS::encode_json ({
700 jsonrpc => "2.0", method => "notifications/cancelled",
701 params => { requestId => 197 },
702 }), "\n" or die "cancel tool request: $!";
703 is rpc (198, "ping", {})->{id}, 198, "cancellation processed before the late Vim reply";
704 send_tool 199, "grep", $grep_args;
705 my $next_call = vim_call;
706 vim_reply $cancelled_call, 1, { cancelled => 1 };
707 vim_reply $next_call, 1, { after_cancel => 1 };
708 my $after_cancel = mcp_reply;
709 ok $after_cancel->{id} == 199 && !$after_cancel->{result}{isError}
710 && JSON::XS::decode_json ($after_cancel->{result}{content}[0]{text})->{after_cancel},
711 "late cancelled reply is ignored and the next tool call completes";
712
713 my ($invalid_ws, $invalid_headers) = upgrade ("secret-do-not-log-XYZ");
714 unlike $invalid_headers, qr{\AHTTP/1\.[01] 101\b},
715 "unknown WebSocket URL token does not upgrade";
716 close $invalid_ws;
717
718 my ($malformed_ws, $malformed_headers) = upgrade ("secondary", "/secondary/");
719 unlike $malformed_headers, qr{\AHTTP/1\.[01] 101\b},
720 "malformed WebSocket URL rejected even when it contains a valid token";
721 close $malformed_ws;
722
723 {
724 my ($sock, $headers, $pending) = upgrade ("secondary");
725 like $headers, qr{\AHTTP/1\.1 101\b},
726 "secondary token authenticates xpcse WebSocket handshake";
727 if ($headers =~ /\AHTTP\/1\.1 101\b/) {
728 my $payload = JSON::XS::encode_json (["xpcse-client", 99]);
729 my $frame = Protocol::WebSocket::Frame->new (
730 type => "text", masked => 1, buffer => $payload,
731 );
732 print {$sock} $frame->to_bytes or die "send xpcse mismatch: $!";
733 is ws_close_code ($sock, $pending), 1002,
734 "xpcse version mismatch triggers protocol-error close";
735 } else {
736 fail "xpcse version mismatch closes connection";
737 }
738 close $sock;
739 }
740
741 subtest "browser transfers" => sub {
742 plan skip_all => "extended browser transfer tests (PERL_ANYEVENT_MCP_TESTS=1)"
743 unless ($ENV{PERL_ANYEVENT_MCP_TESTS} || 0) >= 1;
744
745 # browser transfers use the same broker, with Coro unavailable.
746 sub browser {
747 my ($sock, $headers, $pending) = upgrade ("secondary");
748 $headers =~ /\AHTTP\/1\.1 101\b/ or die "browser upgrade failed\n";
749 my $frame = Protocol::WebSocket::Frame->new (max_payload_size => 100e6);
750 $frame->append ($pending);
751 my $browser = [$sock, $frame];
752 ws_message ($browser); # nick
753 ws_message ($browser); # targets
754 $browser
755 }
756
757 sub ws_message {
758 my ($browser, $timeout) = @_;
759 $timeout //= 30;
760 my ($socket, $frame) = @$browser;
761 my $deadline = Time::HiRes::time () + $timeout;
762 while (1) {
763 while (defined (my $payload = $frame->next_bytes)) {
764 next if $frame->opcode == 10;
765 return [$frame->opcode, $payload];
766 }
767 my $left = $deadline - Time::HiRes::time ();
768 return if $left <= 0 || !IO::Select->new ($socket)->can_read ($left);
769 my $n = sysread $socket, my $bytes, 65536;
770 return unless $n;
771 $frame->append ($bytes);
772 }
773 }
774
775 sub browser_send {
776 my ($browser, $message) = @_;
777 print {$browser->[0]} Protocol::WebSocket::Frame->new (
778 type => "text", masked => 1, buffer => JSON::XS::encode_json ($message),
779 )->to_bytes or die "send browser message: $!";
780 }
781
782 sub vim_send {
783 print {$valid} JSON::XS::encode_json ([0, $_[0]]) or die "send Vim message: $!";
784 }
785
786 sub browser_json {
787 my $message = ws_message $_[0];
788 $message && $message->[0] == 1 or die "expected browser JSON\n";
789 JSON::XS::decode_json $message->[1]
790 }
791
792 vim_send [session => "save-test", {
793 origin => "http://page.example:818", filename => "test.txt",
794 }];
795
796 my $browser = browser ();
797 vim_send [save => "save-test"];
798 like vim_call ()->[1][1], qr/requesting save/, "save reports its destination";
799 is browser_json ($browser)->[0], "upload", "save starts a browser upload";
800
801 require MIME::Base64;
802 my $data = "x" x (10e6 + 37);
803 vim_send [data => MIME::Base64::encode_base64 ($data, "")];
804
805 my $message = ws_message $browser or die "oversized upload chunk stalled\n";
806 is $message->[0], 2, "upload frame is binary";
807 ok $message->[1] eq $data, "one oversized Vim chunk is forwarded whole";
808 ok !IO::Select->new ($valid)->can_read (0.1), "receiving Vim data does not acknowledge browser receipt";
809
810 # further chunks wait while credit is negative; eof and a queued save may already arrive.
811 my @tail = ("first tail", "", "second tail");
812 vim_send [data => MIME::Base64::encode_base64 ($_, "")] for @tail;
813 vim_send [eof => 1];
814 vim_send [save => "save-test"];
815 like vim_call ()->[1][1], qr/requesting save/, "another save queues without acknowledging unsent data";
816 vim_send [eof => 1];
817 ok !ws_message ($browser, 0.1), "negative credit holds back the next whole chunk";
818
819 browser_send $browser, [ack => length $data];
820 is_deeply vim_call ()->[1], [ack => length $data], "browser receipt is forwarded to the originating Vim after its eof";
821 for my $tail (@tail) {
822 my $message = ws_message $browser or die "upload did not resume after acknowledgement\n";
823 is_deeply $message, [2, $tail], "acknowledgement resumes output with the original chunk boundary";
824 }
825 is_deeply browser_json ($browser), ["upload-done"], "eof follows all input chunks";
826 ok !ws_message ($browser, 0.1), "next upload waits for the browser's completion reply";
827 for my $tail (@tail) {
828 browser_send $browser, [ack => length $tail];
829 is_deeply vim_call ()->[1], [ack => length $tail], "each browser chunk acknowledgement reaches Vim";
830 }
831 browser_send $browser, ["upload-done", "200", "saved first"];
832 is_deeply vim_call ()->[1], [echo => "200 saved first"], "save completion remains separate from chunk receipt";
833 is browser_json ($browser)->[0], "upload", "completion releases the queued save";
834 is_deeply browser_json ($browser), ["upload-done"], "empty save sends eof without waiting for credit";
835 browser_send $browser, ["upload-done", "500", "upload ok, but errors:\nat line 12, broken\n\n"];
836 is_deeply vim_call ()->[1], [error => "error: at line 12, broken"], "multiline upload error retains its diagnostic";
837 is_deeply vim_call ()->[1], [goto => "save-test", 12, 1], "upload error retains its session and line";
838
839 vim_send [save => "save-test"];
840 vim_call ();
841 is browser_json ($browser)->[0], "upload", "another upload starts after an error reply";
842 vim_send [eof => 0];
843 my $aborted = ws_message $browser;
844 is $aborted->[0], 8, "failed Vim eof closes the active upload instead of posting a partial file";
845 is_deeply vim_call ()->[1], [echo => "save-test: browser disconnected"], "aborted upload releases its Vim input";
846 close $browser->[0];
847
848 # rejected saves still consume their input and leave RPC dispatch usable.
849 vim_send [save => "save-test"];
850 like vim_call ()->[1][1], qr/no origin found/, "save without a browser explains how to reconnect";
851 vim_send [data => MIME::Base64::encode_base64 ("ignored", "")];
852 vim_send [eof => 1];
853 is_deeply vim_call ()->[1], [ack => 7], "rejected save consumes subsequent data";
854
855 $browser = browser ();
856 vim_send [save => "save-test"];
857 vim_call ();
858 browser_json $browser;
859 close $browser->[0];
860 is_deeply vim_call ()->[1], [echo => "save-test: browser disconnected"], "browser disconnect releases an unfinished save";
861
862 my $leaving = vim_connect ("secondary", "leaving-vim");
863 $leaving->autoflush (1);
864 for (1 .. 1000) {
865 last if grep $_ eq "leaving-vim", @{target_names ()};
866 Time::HiRes::sleep (0.03);
867 }
868 $browser = browser ();
869 print {$leaving} JSON::XS::encode_json ([0, [session => "leaving", {
870 origin => "http://page.example:818", filename => "leaving.txt",
871 }]]), JSON::XS::encode_json ([0, [save => "leaving"]]);
872 vim_call $leaving;
873 is browser_json ($browser)->[0], "upload", "disconnect test starts an unfinished upload";
874 close $leaving;
875 is ws_message ($browser)->[0], 8, "Vim disconnect aborts its unfinished browser upload";
876 close $browser->[0];
877
878 done_testing;
879 };
880
881 send_tool 200, "grep", $grep_args;
882 my $disconnected_call = vim_call;
883 close $valid;
884 my $disconnected_reply = mcp_reply;
885 ok $disconnected_reply->{id} == 200 && $disconnected_reply->{result}{isError}
886 && $disconnected_reply->{result}{content}[0]{text} =~ /vim disconnected/,
887 "disconnect fails an outstanding callback RPC";
888 close $ready if $ready;
889 close $in;
890 my $exit;
891 for (1 .. 1200) {
892 my $w = waitpid ($pid, WNOHANG);
893 if ($w == $pid) {
894 $exit = $? >> 8;
895 last;
896 }
897 Time::HiRes::sleep (0.025);
898 }
899 if (!defined $exit) {
900 kill TERM => $pid;
901 waitpid ($pid, 0);
902 $exit = -1;
903 }
904 is $exit, 0, "broker exits after stdio EOF";
905 close $out;
906 local $/;
907 my $log = <$err> // "";
908 like $log, qr/illegal websocket connect from 127\.0\.0\.1:\d+/,
909 "rejected websocket handshake logs the numeric TCP peer and port";
910 unlike $log, qr/secret-do-not-log-XYZ|\/secondary\//,
911 "rejected URL never exposes supplied credentials in the log";
912 close $err;
913
914 done_testing;