ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/t/16_vim_auth.t
Revision: 1.7
Committed: Wed Oct 14 02:42:53 2026 UTC (3 days, 7 hours ago) by root
Content type: application/x-troff
Branch: MAIN
Changes since 1.6: +106 -2 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.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 $listener = IO::Socket::INET->new (
20     LocalAddr => "127.0.0.1", LocalPort => 0, Proto => "tcp", Listen => 1,
21     ) or die "allocate TCP test port: $!";
22     my $port = $listener->sockport;
23     close $listener;
24    
25     my $err = Symbol::gensym;
26 root 1.2 local $ENV{PERL_ANYEVENT_VERBOSE} = 6; # include notice-level rejection logs
27 root 1.1 my $pid = IPC::Open3::open3 (my $in, my $out, $err, $^X, "-Ilib",
28     "bin/mcp-vim", "--stdio", "--bind", "127.0.0.1:$port",
29     "--token", "primary", "--token", "secondary");
30     my $old = select $in;
31     $| = 1;
32     select $old;
33    
34     sub tcp {
35     IO::Socket::INET->new (
36     PeerAddr => "127.0.0.1", PeerPort => $port,
37     Proto => "tcp", Timeout => 1,
38     )
39     }
40    
41     my $ready;
42     for (1 .. 60) {
43     $ready = tcp ();
44     last if $ready;
45     Time::HiRes::sleep (0.03);
46     }
47     ok $ready, "mcp-vim listens on TCP with two static tokens";
48 root 1.7 # leave this connection silent while the other protocols are exercised.
49 root 1.1
50     sub rpc {
51     my ($id, $method, $params) = @_;
52     print {$in} JSON::XS::encode_json ({
53     jsonrpc => "2.0", id => $id, method => $method, params => $params,
54     }), "\n" or die "send JSONL request: $!";
55     IO::Select->new ($out)->can_read (3) or die "MCP stdio response timed out";
56     my $line = <$out> // die "unexpected MCP stdio EOF";
57     JSON::XS::decode_json $line
58     }
59    
60     sub target_names {
61     my $r = rpc (++$main::request_id, "tools/call",
62 root 1.3 { name => "targets", arguments => {} });
63 root 1.1 return [] unless $r->{result}{content}[0]{text};
64     JSON::XS::decode_json $r->{result}{content}[0]{text}
65     }
66    
67     sub vim_connect {
68     my ($token, $target) = @_;
69     my $sock = tcp () or die "connect private Vim TCP: $!";
70     my $hello = JSON::XS::encode_json ([0, [
71     "mcp-vim-connect", 1, 0, $token, $target, "test-$target",
72     ]]);
73     print {$sock} $hello or die "send private Vim hello: $!";
74     $sock
75     }
76    
77     sub upgrade {
78 root 1.2 my ($token, $path) = @_;
79 root 1.1 my $sock = tcp () or die "connect browser WS TCP: $!";
80     $sock->autoflush (1);
81 root 1.2 $path //= "/$token/http://page.example:818";
82     my $req = "GET $path HTTP/1.1\r\n"
83 root 1.1 . "Host: 127.0.0.1:$port\r\n"
84     . "Upgrade: websocket\r\nConnection: Upgrade\r\n"
85     . "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
86     . "Sec-WebSocket-Version: 13\r\n"
87     . "Origin: http://page.example:818\r\n\r\n";
88     print {$sock} $req or die "send WebSocket upgrade: $!";
89    
90     my $bytes = "";
91     my $deadline = Time::HiRes::time () + 2;
92     while ($bytes !~ /\r\n\r\n/ && Time::HiRes::time () < $deadline) {
93     last unless IO::Select->new ($sock)->can_read (0.2);
94     my $n = sysread $sock, my $chunk, 4096;
95     last unless $n;
96     $bytes .= $chunk;
97     }
98    
99     my ($headers, $frames) = split /\r\n\r\n/, $bytes, 2;
100     ($sock, $headers // "", $frames // "")
101     }
102    
103     sub ws_close_code {
104     my ($sock, $initial) = @_;
105     my $frame = Protocol::WebSocket::Frame->new;
106     $frame->append ($initial) if length $initial;
107     my $deadline = Time::HiRes::time () + 3;
108     while (Time::HiRes::time () < $deadline) {
109     while (defined (my $payload = $frame->next_bytes)) {
110     return unpack ("n", $payload) if $frame->opcode == 8 && length ($payload) >= 2;
111     }
112     next unless IO::Select->new ($sock)->can_read (0.2);
113     my $n = sysread $sock, my $bytes, 8192;
114     last unless $n;
115     $frame->append ($bytes);
116     }
117     undef
118     }
119    
120     my $init = rpc (1, "initialize", {
121     protocolVersion => "2025-11-25", capabilities => {},
122     clientInfo => { name => "auth-test", version => "0" },
123     });
124     ok !$init->{error} && $init->{result}{serverInfo},
125 root 1.7 "stdio control channel initializes while a TCP peer sends no first byte";
126 root 1.4 is $init->{result}{serverInfo}{name}, "mcp-vim", "stdio reports mcp-vim as its identity";
127     like $init->{result}{instructions}, qr/live Vim editors and buffers/,
128     "stdio gives MCP clients live-buffer guidance";
129     unlike JSON::XS::encode_json ($init->{result}), qr/xpcse/i,
130     "stdio initialization does not expose the legacy browser name";
131    
132     my $discover = rpc (2, "server/discover", {});
133     is $discover->{result}{instructions}, $init->{result}{instructions},
134     "discovery gives the same Vim instructions as initialize";
135    
136     {
137     my $http = tcp () or die "connect MCP HTTP: $!";
138     my $body = JSON::XS::encode_json ({
139     jsonrpc => "2.0", id => 3, method => "initialize",
140     params => {
141     protocolVersion => "2025-11-25", capabilities => {},
142     clientInfo => { name => "auth-test", version => "0" },
143     },
144     });
145     print {$http} "POST / HTTP/1.1\r\n",
146     "Host: 127.0.0.1:$port\r\n",
147     "Authorization: Bearer primary\r\n",
148     "Content-Type: application/json\r\n",
149     "Content-Length: ", length $body, "\r\n\r\n", $body
150     or die "send MCP HTTP request: $!";
151     local $/;
152     my $response = <$http> // "";
153     close $http;
154     my ($headers, $json) = split /\r\n\r\n/, $response, 2;
155     like $headers, qr{\AHTTP/1\.1 200\b}, "HTTP initialization succeeds";
156     my $result = eval { JSON::XS::decode_json $json };
157     is $result->{result}{serverInfo}{name}, "mcp-vim", "HTTP reports mcp-vim as its identity";
158     is $result->{result}{instructions}, $init->{result}{instructions},
159     "HTTP gives the same Vim instructions as stdio";
160     }
161 root 1.1
162     my $bad = vim_connect ("unknown", "bad-vim");
163     my $before = target_names ();
164     ok !grep ($_ eq "bad-vim", @$before), "unknown private Vim token cannot register a target";
165     close $bad;
166    
167     my $valid = vim_connect ("secondary", "secondary-vim");
168     my $visible;
169     for (1 .. 35) {
170     my $names = target_names ();
171     $visible = grep $_ eq "secondary-vim", @$names;
172     last if $visible;
173     Time::HiRes::sleep (0.03);
174     }
175     ok $visible, "secondary token authenticates private Vim handshake";
176    
177 root 1.6 # A private Vim RPC supplying disjoint ranges must yield a separate
178     # [range=...] metadata text entry and an unchanged lines text entry for each.
179 root 1.5 print {$in} JSON::XS::encode_json ({
180     jsonrpc => "2.0", id => 180, method => "tools/call",
181     params => { name => "read-lines", arguments => {
182     target => "secondary-vim", buffer => 1, ranges => ["727,728", "985,986"],
183     } },
184     }), "\n" or die "send Vim read-lines request: $!";
185    
186     my $wire = "";
187     my $vim_call;
188     for (1 .. 15) {
189     IO::Select->new ($valid)->can_read (0.3)
190     or next;
191     my $n = sysread $valid, my $fragment, 8192;
192     last unless $n;
193     $wire .= $fragment;
194     $vim_call = eval { JSON::XS::decode_json $wire };
195     last if $vim_call;
196     }
197     ok $vim_call && $vim_call->[1][0] eq "call"
198     && $vim_call->[1][2] eq "read_lines",
199     "read-lines delegates disjoint ranges to the private Vim RPC";
200     if ($vim_call) {
201     $valid->autoflush (1);
202     print {$valid} JSON::XS::encode_json ([0, [
203     reply => $vim_call->[1][1], 1, {
204     changedtick => 7,
205     ranges => [
206     { range => "727,728", start => 727, end => 728, lines => ["function alpha () {", " return a;"] },
207     { range => "985,986", start => 985, end => 986, lines => ["function beta () {", " return b;"] },
208     ],
209     },
210     ]]) or die "send mock Vim RPC reply: $!";
211     }
212     IO::Select->new ($out)->can_read (5) or die "read-lines MCP reply timeout";
213     my $lines_reply = JSON::XS::decode_json (scalar <$out>);
214     my $blocks = $lines_reply->{result}{content} || [];
215 root 1.6 is @{$blocks}, 5, "Vim returns initial metadata plus two text entries per requested range";
216     is_deeply [map $_->{type}, @$blocks], [("text") x 5],
217     "all Vim metadata and source blocks remain MCP text entries";
218 root 1.5 is_deeply JSON::XS::decode_json ($blocks->[0]{text}), {
219     changedtick => 7,
220     ranges => [
221     { range => "727,728", start => 727, end => 728 },
222     { range => "985,986", start => 985, end => 986 },
223     ],
224     }, "Vim changedtick and resolved range metadata remain available";
225 root 1.6 is_deeply [map $_->{text}, @{$blocks}[1..4]], [
226     "[range=727,728]",
227     "function alpha () {\n return a;\n",
228     "[range=985,986]",
229     "function beta () {\n return b;\n",
230     ], "each Vim range has visible standalone metadata and unchanged newline-terminated source";
231 root 1.5
232    
233 root 1.7 sub send_tool {
234     my ($id, $name, $args) = @_;
235     print {$in} JSON::XS::encode_json ({
236     jsonrpc => "2.0", id => $id, method => "tools/call",
237     params => { name => $name, arguments => $args },
238     }), "\n" or die "send tool request: $!";
239     }
240    
241     sub vim_call {
242     my $wire = "";
243     for (1 .. 20) {
244     next unless IO::Select->new ($valid)->can_read (0.25);
245     my $n = sysread $valid, my $fragment, 8192;
246     last unless $n;
247     $wire .= $fragment;
248     my $packet = eval { JSON::XS::decode_json $wire };
249     return $packet if $packet;
250     }
251     die "private Vim RPC timeout\n";
252     }
253    
254     sub vim_reply {
255     my ($call, $success, $result) = @_;
256     print {$valid} JSON::XS::encode_json ([0, [
257     reply => $call->[1][1], $success, $result,
258     ]]) or die "send Vim RPC reply: $!";
259     }
260    
261     sub mcp_reply {
262     IO::Select->new ($out)->can_read (5) or die "MCP reply timeout\n";
263     JSON::XS::decode_json (scalar <$out>)
264     }
265    
266     my $tool_id = 190;
267     for my $case (
268     ["grep", {
269     target => "secondary-vim", buffers => [1], pattern => "alpha",
270     mode => "word", max_results => 2,
271     }, "grep", [[1], "alpha", "word", 2]],
272     ["apply-diff", {
273     target => "secondary-vim", buffer => 1,
274     diff => "@@ -1 @@\n-old\n+new\n", expected_changedtick => 7,
275     }, "apply_patch", [1, "@@ -1,1 +1,1 @@\n-old\n+new\n", [0], 7]],
276     ["substitute", {
277     target => "secondary-vim", buffer => 1, range => "%",
278     pattern => "alpha", replacement => "beta", flags => "g",
279     expected_changedtick => 7,
280     }, "substitute", [1, "%", "alpha", "beta", "g", 7]],
281     ["set-cursor", {
282     target => "secondary-vim", buffer => 1, line => 2, column => 3,
283     expected_changedtick => 7,
284     }, "set_cursor", [1, 2, 3, 7]],
285     ["set-cursor", {
286     target => "secondary-vim", buffer => 1, line => 2, column => 3,
287     }, "set_cursor", [1, 2, 3]],
288     ) {
289     my ($name, $args, $command, $rpc_args) = @$case;
290     send_tool ++$tool_id, $name, $args;
291     my $call = vim_call;
292     is_deeply [@{$call->[1]}[2 .. $#{$call->[1]}]], [$command, @$rpc_args],
293     "$name forwards its arguments and guards through callback RPC";
294     my $result = { changedtick => 8, tool => $name };
295     vim_reply $call, 1, $result;
296     my $reply = mcp_reply;
297     is $reply->{id}, $tool_id, "$name completes the original MCP request";
298     is_deeply JSON::XS::decode_json ($reply->{result}{content}[0]{text}), $result,
299     "$name preserves the JSON tool result";
300     }
301    
302     my $grep_args = {
303     target => "secondary-vim", buffers => [1], pattern => "alpha",
304     mode => "word", max_results => 2,
305     };
306     send_tool 196, "grep", $grep_args;
307     my $failed_call = vim_call;
308     vim_reply $failed_call, 0, "mock Vim error";
309     my $failed_reply = mcp_reply;
310     ok $failed_reply->{id} == 196 && $failed_reply->{result}{isError}
311     && $failed_reply->{result}{content}[0]{text} =~ /mock Vim error/,
312     "failed Vim RPC becomes an MCP tool error";
313    
314     send_tool 197, "grep", $grep_args;
315     my $cancelled_call = vim_call;
316     print {$in} JSON::XS::encode_json ({
317     jsonrpc => "2.0", method => "notifications/cancelled",
318     params => { requestId => 197 },
319     }), "\n" or die "cancel tool request: $!";
320     is rpc (198, "ping", {})->{id}, 198, "cancellation processed before the late Vim reply";
321     send_tool 199, "grep", $grep_args;
322     my $next_call = vim_call;
323     vim_reply $cancelled_call, 1, { cancelled => 1 };
324     vim_reply $next_call, 1, { after_cancel => 1 };
325     my $after_cancel = mcp_reply;
326     ok $after_cancel->{id} == 199 && !$after_cancel->{result}{isError}
327     && JSON::XS::decode_json ($after_cancel->{result}{content}[0]{text})->{after_cancel},
328     "late cancelled reply is ignored and the next tool call completes";
329    
330 root 1.2 my ($invalid_ws, $invalid_headers) = upgrade ("secret-do-not-log-XYZ");
331 root 1.1 unlike $invalid_headers, qr{\AHTTP/1\.[01] 101\b},
332     "unknown WebSocket URL token does not upgrade";
333     close $invalid_ws;
334    
335 root 1.2 my ($malformed_ws, $malformed_headers) = upgrade ("secondary", "/secondary/");
336     unlike $malformed_headers, qr{\AHTTP/1\.[01] 101\b},
337     "malformed WebSocket URL rejected even when it contains a valid token";
338     close $malformed_ws;
339    
340 root 1.1 for my $kind (qw(xpcse-client ypcse-client)) {
341     my ($sock, $headers, $pending) = upgrade ("secondary");
342     like $headers, qr{\AHTTP/1\.1 101\b},
343     "secondary token authenticates $kind WebSocket handshake";
344     if ($headers =~ /\AHTTP\/1\.1 101\b/) {
345     my $payload = JSON::XS::encode_json ([$kind, 99]);
346     my $frame = Protocol::WebSocket::Frame->new (
347     type => "text", masked => 1, buffer => $payload,
348     );
349     print {$sock} $frame->to_bytes or die "send $kind mismatch: $!";
350     is ws_close_code ($sock, $pending), 1002,
351     "$kind version mismatch triggers protocol-error close";
352     } else {
353     fail "$kind version mismatch closes connection";
354     }
355     close $sock;
356     }
357    
358 root 1.7 send_tool 200, "grep", $grep_args;
359     my $disconnected_call = vim_call;
360 root 1.1 close $valid;
361 root 1.7 my $disconnected_reply = mcp_reply;
362     ok $disconnected_reply->{id} == 200 && $disconnected_reply->{result}{isError}
363     && $disconnected_reply->{result}{content}[0]{text} =~ /vim disconnected/,
364     "disconnect fails an outstanding callback RPC";
365     close $ready if $ready;
366 root 1.1 close $in;
367     my $exit;
368     for (1 .. 120) {
369     my $w = waitpid ($pid, WNOHANG);
370     if ($w == $pid) {
371     $exit = $? >> 8;
372     last;
373     }
374     Time::HiRes::sleep (0.025);
375     }
376     if (!defined $exit) {
377     kill TERM => $pid;
378     waitpid ($pid, 0);
379     $exit = -1;
380     }
381     is $exit, 0, "broker exits after stdio EOF";
382     close $out;
383 root 1.2 local $/;
384     my $log = <$err> // "";
385     like $log, qr/illegal websocket connect from 127\.0\.0\.1:\d+/,
386     "rejected websocket handshake logs the numeric TCP peer and port";
387     unlike $log, qr/secret-do-not-log-XYZ|\/secondary\//,
388     "rejected URL never exposes supplied credentials in the log";
389 root 1.1 close $err;
390    
391     done_testing;