ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/t/03_simple.t
Revision: 1.14
Committed: Thu Oct 22 10:42:57 2026 UTC (8 days, 4 hours ago) by root
Content type: application/x-troff
Branch: MAIN
CVS Tags: rel-0_62, rel-0_65, rel-0_7, HEAD
Changes since 1.13: +39 -3 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 use common::sense;
2     use AnyEvent ();
3     use AnyEvent::Handle ();
4     use AnyEvent::MCP::Server::Simple;
5     use AnyEvent::MCP::Server::HTTP ();
6 root 1.8 use AnyEvent::MCP::Server::Permissions ();
7 root 1.1 use AnyEvent::MCP::Config ();
8     use File::Temp ();
9     use IPC::Open3 ();
10     use Symbol ();
11     use JSON::XS ();
12     use MIME::Base64 ();
13     use Socket;
14    
15 root 1.14 print "1..42\n";
16 root 1.1
17     my $test;
18     sub ok($$) {
19     my ($condition, $name) = @_;
20    
21     print (($condition ? "ok " : "not ok "), ++$test, " - $name\n");
22     }
23    
24     sub post {
25     my ($server, $path, $authorization) = @_;
26    
27     socketpair my $a, my $b, AF_UNIX, SOCK_STREAM, PF_UNSPEC or die $!;
28     $server->accept ($a);
29     my $client = AnyEvent::Handle->new (fh => $b, on_error => sub { die $_[2] });
30     my $cv = AnyEvent->condvar;
31 root 1.7 my $timer = AnyEvent->timer (after => 30, cb => sub { die "HTTP timeout\n" });
32 root 1.1 my $body = '{"jsonrpc":"2.0","id":1,"method":"ping"}';
33     $client->push_write (
34     "POST $path HTTP/1.1\r\nContent-Type: application/json\r\n"
35     . "Content-Length: " . length ($body) . "\r\n"
36     . (defined $authorization ? "Authorization: $authorization\r\n" : "")
37     . "\r\n$body"
38     );
39    
40     my ($header, $response);
41     $client->push_read (line => "\r\n\r\n", sub {
42     my ($handle, $text) = @_;
43     $header = $text;
44     my ($length) = $header =~ /Content-Length: ([0-9]+)/;
45     $handle->push_read (chunk => $length, sub {
46     $response = $_[1];
47     $cv->send;
48     });
49     });
50     $cv->recv;
51     $client->destroy;
52     $server->close;
53    
54     ($header, $response)
55     }
56    
57     my $server = AnyEvent::MCP::Server::HTTP->new (
58 root 1.4 port => 31740, path => "/", tokens => ["secret", "alternate"],
59 root 1.1 );
60     ok $server->url eq "http://127.0.0.1:31740/secret/", "cookie URL";
61     ok $server->url (0) eq "http://127.0.0.1:31740/", "clean URL";
62    
63     my ($header, $body) = post ($server, "/secret/");
64     ok $header =~ m{HTTP/1.1 200}, "cookie authenticates";
65     ok JSON::XS::decode_json ($body)->{id} == 1, "authenticated RPC reply";
66     ($header, $body) = post ($server, "/", "Bearer secret");
67     ok $header =~ m{HTTP/1.1 200}, "same token authenticates bearer";
68     ($header, $body) = post ($server, "/", "bEaReR secret");
69     ok $header =~ m{HTTP/1.1 200}, "authentication scheme is case insensitive";
70     ($header, $body) = post ($server, "/");
71     ok $header =~ m{HTTP/1.1 401}, "missing credential rejected";
72     ok $header =~ /WWW-Authenticate: Bearer/, "bearer challenge";
73     ($header, $body) = post ($server, "/", "Bearer wrong");
74     ok $header =~ m{HTTP/1.1 401}, "wrong bearer rejected";
75     ($header, $body) = post ($server, "/wrong/");
76     ok $header =~ m{HTTP/1.1 404}, "wrong cookie path rejected";
77    
78 root 1.4 $server = AnyEvent::MCP::Server::HTTP->new (tokens => ["first", "second"]);
79     ($header, $body) = post ($server, "/second/mcp/");
80     ok $header =~ m{HTTP/1.1 200}, "second token works in URL";
81     ($header, $body) = post ($server, "/mcp/", "Bearer first");
82     ok $header =~ m{HTTP/1.1 200}, "first token works as bearer";
83     ($header, $body) = post ($server, "/mcp/", "Basic YTpi");
84     ok $header =~ m{HTTP/1.1 401} && $header !~ /WWW-Authenticate: Basic/, "Basic authentication is not supported";
85 root 1.1
86 root 1.4 $server = AnyEvent::MCP::Server::HTTP->new (tokens => ["standalone"]);
87 root 1.1 ($header, $body) = post ($server, "/mcp/");
88 root 1.4 ok $header =~ m{HTTP/1.1 401}, "embedded server with tokens rejects unauthenticated requests";
89 root 1.5 $server = AnyEvent::MCP::Server::HTTP->new (auth => "none",
90 root 1.1 host => "::1", port => 31740, handle => { tls => "accept" },
91     );
92     ok $server->url eq "https://[::1]:31740/mcp/", "IPv6 and TLS URL";
93 root 1.6 $server->{url_host} = "[::1]";
94     ok $server->url eq "https://[::1]:31740/mcp/", "prebracketed IPv6 URL host is not bracketed twice";
95 root 1.1 $server->{url_host} = "example.net";
96     $server->{url_scheme} = "http";
97     ok $server->url eq "http://example.net:31740/mcp/", "advertised URL overrides";
98     delete $server->{url_host};
99     $server->{host} = "0.0.0.0";
100     ok $server->url eq "http://" . (POSIX::uname)[1] . ":31740/mcp/", "wildcard nodename";
101     $server->{port} = 0;
102     eval { $server->url };
103     ok $@ =~ /port is not known/, "unknown port has no URL";
104    
105     my $dir = File::Temp::tempdir (CLEANUP => 1);
106     local $ENV{XDG_CONFIG_HOME} = $dir;
107     local $ENV{PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL};
108     my $printed = "";
109     {
110     local *STDOUT;
111     open STDOUT, ">", \$printed or die $!;
112 root 1.2 my $timer;
113 root 1.14 mcp_start name => "example", server_version => 0.61, on_start => sub {
114 root 1.2 $server = $_[0];
115     $timer = AnyEvent->timer (after => 0.01, cb => sub { $server->close });
116     };
117 root 1.1 }
118     ok $server->isa ("AnyEvent::MCP::Server::HTTP"), "normal startup uses HTTP";
119 root 1.3 ok $server->{port} && $printed eq "", "HTTP startup leaves stdout empty";
120 root 1.1 my $config = AnyEvent::MCP::Config->global;
121 root 1.5 ok $server->{tokens}[0] eq $config->credential ("token", "example"),
122 root 1.4 "startup token matches derived utility";
123 root 1.14 ok $server->{server_info}{name} eq "AnyEvent::MCP", "instance name is not the server implementation name";
124     ok JSON::XS::encode_json ($server->{server_info}) =~ /"version":"0\.61"/,
125     "Simple stringifies numeric module versions";
126 root 1.1 $server->close;
127    
128     my $err = Symbol::gensym;
129     my $pid = IPC::Open3::open3 (my $in, my $out, $err, $^X, "-Ilib", "-e", '
130     use AnyEvent::MCP::Server::Simple;
131     $ENV{XDG_CONFIG_HOME} = $ARGV[0];
132     $ENV{PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL} = "stdio";
133 root 1.2 mcp_start;
134 root 1.1 ', "$dir/stdio");
135     print {$in} "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"ping\"}\n";
136     $in->flush;
137 root 1.2 close $in;
138 root 1.1 local $/;
139     my $output = <$out>;
140     my $errors = <$err>;
141     waitpid $pid, 0;
142     ok !$? && !$errors, "multibroker startup succeeds";
143     ok eval { JSON::XS::decode_json ($output)->{id} == 42 }, "stdio output contains only RPC";
144     ok !-e "$dir/stdio", "stdio does not access global configuration";
145 root 1.2
146     $err = Symbol::gensym;
147 root 1.12 $pid = do {
148     local $ENV{PERL_ANYEVENT_LOG} = ""; # inspect the normal activity log.
149     IPC::Open3::open3 ($in, $out, $err, $^X, "-Ilib", "-e", '
150 root 1.2 use AnyEvent::MCP::Server::Simple;
151     my $timer;
152     mcp_tool delayed => "delayed", {}, sub {
153     my ($req) = @_;
154     $timer = AnyEvent->timer (after => 0.03, cb => sub {
155     $req->reply ({ content => [{ type => "text", text => "x" x 200000 }] });
156     });
157     };
158     mcp_stdio;
159     print STDERR "stopped\\n";
160 root 1.12 ');
161     };
162 root 1.2 print {$in} JSON::XS::encode_json ({
163     jsonrpc => "2.0", id => 1, method => "initialize", params => {
164     protocolVersion => "2025-11-25", clientInfo => {}, capabilities => {},
165     },
166     }), "\n";
167     print {$in} JSON::XS::encode_json ({
168     jsonrpc => "2.0", id => 2, method => "tools/call", params => { name => "delayed" },
169     }), "\n";
170     close $in;
171     $output = <$out>;
172     $errors = <$err>;
173     waitpid $pid, 0;
174 root 1.11 ok !$? && $errors =~ /\bdelayed\b.*\nstopped\n\z/s,
175     "mcp_stdio logs tool activity and returns after input EOF";
176 root 1.2 my @messages = map JSON::XS::decode_json ($_), split /\n/, $output;
177     ok @messages == 2 && $messages[1]{id} == 2
178     && length $messages[1]{result}{content}[0]{text} == 200000,
179     "EOF waits for an asynchronous reply and drains large output";
180    
181     my ($started, $closed, $timer);
182 root 1.14 mcp_http auth => "none", port => 0, name => "local-instance",
183     server_name => "advertised", server_version => 1.2,
184     on_start => sub {
185     $server = $_[0];
186     $timer = AnyEvent->timer (after => 0.01, cb => sub { $server->close });
187     };
188     ok $server->{server_info}{name} eq "advertised",
189     "Simple server name is independent of instance name";
190     ok JSON::XS::encode_json ($server->{server_info}) =~ /"version":"1\.2"/,
191     "flat Simple version is sent as a JSON string";
192     my $raw = { name => "raw", version => "7" };
193     mcp_http auth => "none", port => 0, name => "local-instance",
194     server_info => $raw,
195     on_start => sub {
196     $server = $_[0];
197     $timer = AnyEvent->timer (after => 0.01, cb => sub { $server->close });
198     };
199     ok $server->{server_info} == $raw,
200     "explicit raw server_info is passed through unchanged";
201     eval { mcp_http auth => "none", port => 0,
202     server_name => "advertised", server_info => $raw };
203     ok $@ =~ /Cannot combine server_info with server_name/,
204     "Simple rejects mixing raw and flat identity settings";
205    
206     mcp_http auth => "none", port => 0, name => "another-instance",
207     on_start => sub {
208     $server = $_[0];
209     $timer = AnyEvent->timer (after => 0.01, cb => sub { $server->close });
210     };
211     ok $server->{server_info}{name} eq "AnyEvent::MCP",
212     "Simple defaults to the library implementation name";
213     ok JSON::XS::encode_json ($server->{server_info}) =~ /"version":"\Q$AnyEvent::MCP::VERSION\E"/,
214     "Simple defaults to the library version as a JSON string";
215    
216 root 1.5 mcp_http auth => "none", port => 0, on_start => sub {
217 root 1.2 my ($server) = @_;
218     ++$started;
219     $timer = AnyEvent->timer (after => 0.01, cb => sub { $server->close });
220     }, on_close => sub { ++$closed };
221     ok $started == 1 && $closed == 1, "mcp_http blocks through startup and returns on close";
222    
223 root 1.5 eval { mcp_http auth => "none", port => 0, on_start => sub { die "startup failed\n" } };
224 root 1.2 ok $@ =~ /startup failed/, "startup hook exception closes the server and propagates";
225 root 1.8
226     {
227 root 1.9 local @ARGV = ("--auto-deny=*,write", "--auto-allow=read_lines,read",
228     "--auto-deny=*,*,*");
229 root 1.13 my $options = mcp_getoptions ({ name => "auto-test" });
230     ok JSON::XS::encode_json ($options->{auto_rules}) eq JSON::XS::encode_json ([
231 root 1.9 ["deny", "*,write"], ["allow", "read_lines,read"], ["deny", "*,*,*"],
232 root 1.10 ]), "CLI collects repeatable allow/deny rules in original order";
233 root 1.8
234     my $perm = AnyEvent::MCP::Server::Permissions->new (
235     rules => [{ decision => "deny", action => "^" }],
236     );
237     my $timer;
238 root 1.13 mcp_http %$options, auth => "none", port => 0, permissions => $perm,
239 root 1.8 on_start => sub {
240     my ($server) = @_;
241     $timer = AnyEvent->timer (after => 0.01, cb => sub { $server->close });
242     };
243 root 1.9 my $call = { action => "read", sandbox_grants => [], reason => "test" };
244 root 1.10 ok $perm->check ($call, "read-lines")->{decision} eq "allow",
245 root 1.9 "Simple starter installs ordered allow rule";
246 root 1.10 ok $perm->check ({ %$call, action => "write" }, "read-lines")->{decision} eq "deny",
247 root 1.9 "earlier auto-deny takes precedence over later allow";
248 root 1.10 ok $perm->check ($call, "exec")->{decision} eq "deny",
249 root 1.9 "later wildcard deny catches unmatched tools";
250 root 1.8
251     my $created;
252 root 1.9 mcp_http auth => "none", port => 0, auto_rules => [["deny", "*"]],
253 root 1.8 on_start => sub {
254     my ($server) = @_;
255     $created = $server->{permissions};
256     $timer = AnyEvent->timer (after => 0.01, cb => sub { $server->close });
257     };
258 root 1.9 ok $created && $created->check ($call, "anything")->{decision} eq "deny",
259 root 1.8 "Simple starter creates a permission engine for automatic denial";
260     }