ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/t/04_client.t
Revision: 1.1
Committed: Wed Oct 7 11:00:00 2026 UTC (3 weeks, 6 days ago) by root
Content type: application/x-troff
Branch: MAIN
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::Socket ();
5     use AnyEvent::MCP::Client::HTTP ();
6     use AnyEvent::MCP::Client::JSONL ();
7     use AnyEvent::MCP::Server::HTTP ();
8     use AnyEvent::MCP::Server::JSONL ();
9     use AnyEvent::MCP::Server::Toolset ();
10     use AnyEvent::MCP::Config ();
11     use JSON::XS ();
12     use File::Temp ();
13     use Socket;
14     use Scalar::Util qw(weaken);
15    
16     print "1..59\n";
17    
18     my $test;
19     sub ok($$) {
20     my ($condition, $name) = @_;
21     print (($condition ? "ok " : "not ok "), ++$test, " - $name\n");
22     }
23    
24     sub wait_for {
25     my ($start) = @_;
26     my $cv = AnyEvent->condvar;
27     my $timer = AnyEvent->timer (after => 3, cb => sub { die "Client test timeout\n" });
28     $start->(sub { $cv->send ([@_]) });
29     @{$cv->recv}
30     }
31    
32     my $dir = File::Temp::tempdir (CLEANUP => 1);
33     local $ENV{XDG_CONFIG_HOME} = "$dir/config";
34     my $toolset = AnyEvent::MCP::Server::Toolset->new;
35     my $pending;
36     $toolset->tool (echo => "echo", {}, sub { $_[0]->result ($_[1]) });
37     $toolset->tool (slow => "slow", {}, sub { $pending = $_[0] });
38     $toolset->tool (fail => "fail", {}, sub { $_[0]->error ("tool failure") });
39    
40     my $server = AnyEvent::MCP::Server::HTTP->new (
41     toolsets => [$toolset], cookie => "secret", bearer => "secret",
42     );
43     $server->listen;
44     my $client = AnyEvent::MCP::Client::HTTP->new (url => $server->url (0), bearer => "secret");
45     ok !-e "$dir/config", "unnamed server and client read no config";
46    
47     my ($success, $result) = wait_for (sub { $client->call (echo => { text => "hello" }, $_[0]) });
48     ok $success && $result->{structuredContent}{text} eq "hello", "modern HTTP tool call";
49     ok $client->{protocol_version} eq "2026-07-28", "modern discovery";
50     ok $client->{server_info}{name} eq "AnyEvent::MCP", "server identity";
51     ok ref $client->{server_capabilities}{tools} eq "HASH", "server capabilities";
52     ($success, $result) = wait_for (sub { $client->tools ($_[0]) });
53     ok $success && @$result == 3, "list tools";
54     ($success, $result) = wait_for (sub { $client->call (fail => {}, $_[0]) });
55     ok $success && $result->{isError}, "tool failure remains successful RPC";
56     ($success, $result) = wait_for (sub { $client->rpc ("unknown", {}, $_[0]) });
57     ok !$success && $result->{code} == -32601, "HTTP protocol error preserved";
58     ($success, $result) = wait_for (sub { $client->notify ("notifications/initialized", {}, $_[0]) });
59     ok $success, "HTTP notification";
60     $client->close;
61    
62     $client = AnyEvent::MCP::Client::HTTP->new (url => $server->url);
63     ($success, $result) = wait_for (sub { $client->call (echo => {}, $_[0]) });
64     ok $success, "URL cookie without header auth";
65     $client->close;
66     $client = AnyEvent::MCP::Client::HTTP->new (url => $server->url (0), bearer => "wrong");
67     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
68     ok !$success && $result->{transport} && $result->{message} =~ /401/, "wrong bearer fails";
69     $client->close;
70     $server->close;
71    
72     $server = AnyEvent::MCP::Server::HTTP->new (toolsets => [$toolset], users => { alice => "a:b" });
73     $server->listen;
74     $client = AnyEvent::MCP::Client::HTTP->new (url => $server->url, username => "alice", password => "a:b");
75     ($success, $result) = wait_for (sub { $client->call (echo => {}, $_[0]) });
76     ok $success, "Basic client auth";
77     $client->close;
78     $server->close;
79     eval { AnyEvent::MCP::Client::HTTP->new (url => "http://localhost/", bearer => "token", username => "alice", password => "pw") };
80     ok $@ =~ /cannot be combined/, "ambiguous auth rejected";
81     eval { AnyEvent::MCP::Client::HTTP->new (url => "http://localhost/", username => "alice") };
82     ok $@ =~ /requires username and password/, "incomplete Basic credentials rejected";
83    
84     $server = AnyEvent::MCP::Server::HTTP->new (toolsets => [$toolset], versions => ["2025-06-18"]);
85     $server->listen;
86     $client = AnyEvent::MCP::Client::HTTP->new (url => $server->url);
87     ($success, $result) = wait_for (sub { $client->call (echo => { value => 42 }, $_[0]) });
88     ok $success && $result->{structuredContent}{value} == 42, "legacy fallback tool call";
89     ok $client->{protocol_version} eq "2025-06-18", "negotiated legacy version";
90     ok $client->{server_info}{name} eq "AnyEvent::MCP", "legacy wire identity preserved";
91     $client->close;
92     $server->close;
93    
94     my $calls = 0;
95     my @input;
96     $server = AnyEvent::MCP::Server::HTTP->new (on_tools_call => sub {
97     my ($server, $req, $args) = @_;
98     ++$calls;
99     if (exists $req->{params}{inputResponses}) {
100     ok $req->{params}{requestState} eq "opaque", "request state echoed";
101     ok $req->{params}{inputResponses}{question}{content}{answer} == 42, "input answer echoed";
102     ok $args->{original} eq "keep", "original tool arguments preserved";
103     $req->result ({ answer => 42 });
104     } else {
105     $req->reply ({
106     resultType => "input_required", requestState => "opaque",
107     inputRequests => { question => {
108     method => "elicitation/create", params => { message => "question" },
109     } },
110     });
111     }
112     });
113     $server->listen;
114     $client = AnyEvent::MCP::Client::HTTP->new (
115     url => $server->url, client_capabilities => { elicitation => { form => {} } },
116     on_request => sub {
117     my ($client, $req) = @_;
118     push @input, $req;
119     $req->reply ({ action => "accept", content => { answer => 42 } });
120     },
121     );
122     ($success, $result) = wait_for (sub { $client->call (question => { original => "keep" }, $_[0]) });
123     ok $success && $result->{structuredContent}{answer} == 42, "modern input continuation";
124     ok $calls == 2 && @input == 1 && $input[0]{finished}, "input handled once";
125     ($success, $result) = wait_for (sub {
126     $client->rpc ("tools/call", { name => "question", arguments => {} }, $_[0]);
127     });
128     ok $success && $result->{resultType} eq "input_required" && @input == 1, "raw RPC preserves challenge";
129     $client->close;
130     $server->close;
131     @input = ();
132    
133     my $page = 0;
134     $server = AnyEvent::MCP::Server::HTTP->new (on_tools_list => sub {
135     $_[1]->reply ({ tools => [{ name => ++$page }], ($page == 1 ? (nextCursor => "second") : ()) });
136     });
137     $server->listen;
138     $client = AnyEvent::MCP::Client::HTTP->new (url => $server->url);
139     ($success, $result) = wait_for (sub { $client->tools ($_[0]) });
140     ok $success && @$result == 2 && $result->[1]{name} == 2, "tool pagination";
141     $server->{on_tools_list} = sub { $_[1]->reply ({ tools => [], nextCursor => "repeat" }) };
142     ($success, $result) = wait_for (sub { $client->tools ($_[0]) });
143     ok !$success && $result->{message} =~ /Repeated/, "pagination loop rejected";
144     $client->close;
145     $server->close;
146    
147     socketpair my $a, my $b, AF_UNIX, SOCK_STREAM, PF_UNSPEC or die $!;
148     $server = AnyEvent::MCP::Server::JSONL->new (toolsets => [$toolset]);
149     $server->accept ($a);
150     $client = AnyEvent::MCP::Client::JSONL->new;
151     $client->attach ($b);
152     my @order;
153     ($success, $result) = wait_for (sub {
154     my $done = $_[0];
155     $client->call (slow => {}, sub { push @order, "slow"; $done->(@_) });
156     $client->rpc ("ping", {}, sub { push @order, "ping"; $pending->reply ({}); undef $pending });
157     });
158     ok $success && join (",", @order) eq "ping,slow", "JSONL replies can be out of order";
159    
160     my ($id, $cancelled, $completions);
161     $id = $client->call (slow => {}, sub { ++$completions; $cancelled = $_[1] });
162     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
163     $client->cancel ($id);
164     ok $completions == 1 && $cancelled->{cancelled}, "local cancellation once";
165     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
166     ok $pending->{cancelled}, "wire cancellation reaches server";
167     undef $pending;
168     $client->cancel ($id);
169     ok $completions == 1, "repeated cancellation ignored";
170    
171     $id = $client->call (slow => {}, sub { ++$completions; $cancelled = $_[1] });
172     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
173     $client->close;
174     ok $completions == 2 && $cancelled->{transport}, "close fails outstanding call";
175     $server->close;
176     undef $pending;
177    
178     $client = AnyEvent::MCP::Client::JSONL->new (
179     command => [$^X, "-Ilib", "-e", '
180     use AnyEvent::MCP::Server::Simple;
181     mcp_tool echo => "echo", {}, sub { $_[0]->result ($_[1]) };
182     my $server = mcp_start;
183     AnyEvent->condvar->recv;
184     '],
185     env => { PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL => "stdio" },
186     );
187     ($success, $result) = wait_for (sub { $client->call (echo => { subprocess => 1 }, $_[0]) });
188     ok $success && $result->{structuredContent}{subprocess}, "direct subprocess command";
189     my $pid = $client->{pid};
190     $client->close;
191     ok !$client->{pid}, "close detaches child";
192     ($success, $result) = wait_for (sub { $client->call (echo => { restarted => 1 }, $_[0]) });
193     ok $success && $result->{structuredContent}{restarted} && $client->{pid} != $pid, "new call restarts child";
194     $client->close;
195    
196     # A real streaming response: progress arrives before the final event.
197     my ($sse_handle, $release, $listener, $port, $ended, $progress);
198     $listener = AnyEvent::Socket::tcp_server "127.0.0.1", 0, sub {
199     $sse_handle = AnyEvent::Handle->new (fh => $_[0], on_error => sub { die $_[2] });
200     $sse_handle->push_read (line => "\r\n\r\n", sub {
201     my ($handle, $header) = @_;
202     my ($length) = $header =~ /Content-Length: ([0-9]+)/i;
203     $handle->push_read (chunk => $length, sub {
204     my $msg = JSON::XS::decode_json $_[1];
205     my $first = "data: " . JSON::XS::encode_json ({
206     jsonrpc => "2.0", method => "notifications/progress", params => { progress => 1 },
207     }) . "\r\n\r\n";
208     my $last = "data: " . JSON::XS::encode_json ({
209     jsonrpc => "2.0", id => $msg->{id}, result => { resultType => "complete", streamed => 1 },
210     }) . "\r\n\r\n";
211     $handle->push_write (
212     "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n"
213     . "Content-Length: " . (length ($first) + length ($last)) . "\r\n\r\n"
214     . substr ($first, 0, 8)
215     );
216     $release = AnyEvent->timer (after => 0.02, cb => sub {
217     $handle->push_write (substr ($first, 8));
218     $release = AnyEvent->timer (after => 0.02, cb => sub {
219     $ended = 1;
220     $handle->push_write ($last);
221     });
222     });
223     });
224     });
225     }, sub { $port = $_[2]; () };
226     $client = AnyEvent::MCP::Client::HTTP->new (
227     url => "http://127.0.0.1:$port/", on_notification => sub {
228     $progress = $_[1] eq "notifications/progress" && !$ended;
229     },
230     );
231     $client->{connected} = 1;
232     $client->{protocol_version} = "2026-07-28";
233     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
234     ok $progress, "fragmented SSE progress delivered while streaming";
235     ok $success && $result->{streamed}, "SSE matching final response";
236     $client->close;
237     $sse_handle->destroy;
238     undef $release;
239     undef $listener;
240    
241     my $config = AnyEvent::MCP::Config->global;
242     $config->{section}{"client example"} = { url => "http://example.invalid/", bearer => "configured", timeout => 7 };
243     $client = AnyEvent::MCP::Client::HTTP->new (name => "example", timeout => 9);
244     ok $client->{url} eq "http://example.invalid/" && $client->{bearer} eq "configured", "named client defaults";
245     ok $client->{timeout} == 9, "explicit client option wins";
246     $client->close;
247     $client = AnyEvent::MCP::Client::HTTP->new (name => "example", bearer => undef);
248     ok !defined $client->{bearer}, "explicit undef disables named auth";
249     $client->close;
250    
251     $config->{section}{"server example"} = { path => "/named/", bearer => "configured", users => "alice,bob" };
252     $server = AnyEvent::MCP::Server::HTTP->new (name => "example", port => 31740);
253     ok $server->{path} eq "/named/" && $server->{bearer} eq "configured", "named server defaults";
254     ok keys (%{$server->{users}}) == 2 && $server->{users}{alice} eq $config->credential ("basic", "example", "alice"), "derived allowlisted users";
255     ok !exists $server->{users}{eve}, "derivable unlisted user not allowed";
256     $server->close;
257    
258     my $req = AnyEvent::MCP::Client::Request->new (client => $client, on_reply => sub {});
259     $req->reply ({});
260     eval { $req->reply ({}) };
261     ok $@ =~ /already finished/, "duplicate input reply dies";
262     my $dismissed = 0;
263     $req = AnyEvent::MCP::Client::Request->new (client => $client, on_cancel => sub { ++$dismissed }, on_reply => sub {});
264     $req->cancel;
265     $req->cancel;
266     ok $req->{cancelled} && $dismissed == 1, "cancel dismisses input once";
267    
268     # Pending operations must not keep the client alive through its own callbacks.
269     socketpair $a, $b, AF_UNIX, SOCK_STREAM, PF_UNSPEC or die $!;
270     $server = AnyEvent::MCP::Server::JSONL->new (toolsets => [$toolset]);
271     $server->accept ($a);
272     $client = AnyEvent::MCP::Client::JSONL->new;
273     $client->attach ($b);
274     my $released;
275     $client->call (slow => {}, sub { $released = $_[1] });
276     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
277     my $weak = $client;
278     weaken $weak;
279     undef $client;
280     ok !defined $weak && $released->{transport}, "dropping pending client releases it and fails callback";
281     $server->close;
282     undef $pending;
283    
284     socketpair $a, $b, AF_UNIX, SOCK_STREAM, PF_UNSPEC or die $!;
285     $server = AnyEvent::MCP::Server::JSONL->new (toolsets => [$toolset]);
286     $server->accept ($a);
287     $client = AnyEvent::MCP::Client::JSONL->new (request_timeout => 0.02);
288     $client->attach ($b);
289     ($success, $result) = wait_for (sub { $client->call (slow => {}, $_[0]) });
290     ok !$success && $result->{cancelled} && $result->{message} =~ /timed out/, "overall request deadline";
291     $client->close;
292     $server->close;
293     undef $pending;
294    
295     # Keep an asynchronous input prompt open, then cancel its originating call.
296     my $prompt;
297     $server = AnyEvent::MCP::Server::HTTP->new (on_tools_call => sub {
298     $_[1]->reply ({
299     resultType => "input_required", requestState => "opaque",
300     inputRequests => { q => { method => "elicitation/create", params => {} } },
301     });
302     });
303     $server->listen;
304     $client = AnyEvent::MCP::Client::HTTP->new (url => $server->url);
305     ($success, $result) = wait_for (sub { $client->call (question => {}, $_[0]) });
306     ok !$success && $result->{message} =~ /without a client handler/, "no implicit input approval";
307     my $prompt_ready;
308     my $dismiss = 0;
309     $client->{on_request} = sub {
310     $prompt = $_[1];
311     $prompt->{on_cancel} = sub { ++$dismiss };
312     $prompt_ready->();
313     };
314     ($success, $result) = wait_for (sub {
315     $prompt_ready = $_[0];
316     $id = $client->call (question => {}, sub { $cancelled = $_[1] });
317     });
318     $client->cancel ($id);
319     ok $prompt->{cancelled} && $dismiss == 1 && $cancelled->{cancelled}, "cancelling call dismisses asynchronous prompt";
320     $client->close;
321     $server->close;
322     undef $prompt;
323    
324     # Modern tool-name headers must encode non-ASCII names.
325     my $unicode = "echo-" . chr 0x3bb;
326     $server = AnyEvent::MCP::Server::HTTP->new (on_tools_call => sub {
327     $_[1]->result ({ name => $_[1]{tool} });
328     });
329     $server->listen;
330     $client = AnyEvent::MCP::Client::HTTP->new (url => $server->url);
331     ($success, $result) = wait_for (sub { $client->call ($unicode, {}, $_[0]) });
332     ok $success && $result->{structuredContent}{name} eq $unicode, "Unicode Mcp-Name header";
333     $client->close;
334     $server->close;
335    
336     # Two asynchronous answers can arrive in any order; continuation happens once.
337     my $rounds = 0;
338     $server = AnyEvent::MCP::Server::HTTP->new (on_tools_call => sub {
339     my ($server, $req) = @_;
340     ++$rounds;
341     if ($req->{params}{inputResponses}) {
342     $req->result ({ inputs => $req->{params}{inputResponses} });
343     } else {
344     $req->reply ({
345     resultType => "input_required", inputRequests => {
346     a => { method => "elicitation/create", params => {} },
347     b => { method => "elicitation/create", params => {} },
348     },
349     });
350     }
351     });
352     $server->listen;
353     my %prompts;
354     $client = AnyEvent::MCP::Client::HTTP->new (
355     url => $server->url, on_request => sub {
356     $prompts{$_[1]{id}} = $_[1];
357     if (keys %prompts == 2) {
358     $prompts{b}->reply ({ action => "decline" });
359     $prompts{a}->reply ({ action => "accept" });
360     }
361     },
362     );
363     ($success, $result) = wait_for (sub { $client->call (question => {}, $_[0]) });
364     ok $success && $rounds == 2, "multiple input answers produce one continuation";
365     ok $result->{structuredContent}{inputs}{a}{action} eq "accept"
366     && $result->{structuredContent}{inputs}{b}{action} eq "decline", "input keys and decisions preserved";
367     $client->close;
368     $server->close;
369     %prompts = ();
370    
371     # Configure users from a name, then use the derived Basic password end-to-end.
372     $server = AnyEvent::MCP::Server::HTTP->new (name => "example", users => ["alice"]);
373     $server->listen;
374     $client = AnyEvent::MCP::Client::HTTP->new (
375     url => $server->url, username => "alice",
376     password => $config->credential ("basic", "example", "alice"),
377     );
378     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
379     ok $success, "named derived Basic user authenticates";
380     $client->close;
381     $client = AnyEvent::MCP::Client::HTTP->new (
382     url => $server->url, username => "eve",
383     password => $config->credential ("basic", "example", "eve"),
384     );
385     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
386     ok !$success && $result->{message} =~ /401/, "unlisted derived Basic user rejected";
387     $client->close;
388     $server->close;
389    
390     my $exit_cv = AnyEvent->condvar;
391     my $reap_wait = AnyEvent->timer (after => 0.02, cb => sub { $exit_cv->send });
392     $exit_cv->recv;
393     ok waitpid ($pid, 1) == -1, "subprocess child was reaped";
394     eval { AnyEvent::MCP::Client::HTTP->new (url => "http://localhost/", bearer => "bad\nheader") };
395     ok $@ =~ /Invalid bearer/, "credential header injection rejected";
396    
397     # A legacy session ID belongs to initialization and must accompany later POSTs.
398     my (@http_handles, $initializations, $session_header);
399     $listener = AnyEvent::Socket::tcp_server "127.0.0.1", 0, sub {
400     my $handle = AnyEvent::Handle->new (
401     fh => $_[0], on_error => sub { $_[0]->destroy }, on_eof => sub { $_[0]->destroy },
402     );
403     push @http_handles, $handle;
404     $handle->push_read (line => "\r\n\r\n", sub {
405     my ($handle, $header) = @_;
406     my ($length) = $header =~ /Content-Length: ([0-9]+)/i;
407     $handle->push_read (chunk => $length, sub {
408     my $msg = JSON::XS::decode_json $_[1];
409     my ($status, $extra, $body) = ("202 Accepted", "", "");
410     if ($msg->{method} eq "initialize") {
411     ++$initializations;
412     $status = "200 OK";
413     $extra = "Mcp-Session-Id: test-session\r\n";
414     $body = JSON::XS::encode_json ({
415     jsonrpc => "2.0", id => $msg->{id}, result => {
416     protocolVersion => "2025-06-18", capabilities => {},
417     serverInfo => { name => "legacy", version => "0" },
418     },
419     });
420     } elsif ($msg->{method} eq "ping") {
421     $session_header = $header =~ /Mcp-Session-Id: test-session/i;
422     $status = "404 Not Found";
423     }
424     $handle->push_write (
425     "HTTP/1.1 $status\r\nContent-Type: application/json\r\n"
426     . $extra . "Content-Length: " . length ($body) . "\r\nConnection: close\r\n\r\n$body"
427     );
428     });
429     });
430     }, sub { $port = $_[2]; () };
431     $client = AnyEvent::MCP::Client::HTTP->new (
432     url => "http://127.0.0.1:$port/", versions => ["2025-06-18"],
433     );
434     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
435     ok !$success && $result->{message} =~ /session expired/ && !$client->{connected}, "session expiry invalidates client";
436     ok $session_header && $initializations == 1, "session header retained without retry";
437     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
438     ok !$success && $initializations == 2, "later operation creates a fresh session";
439     $client->close;
440     $_->destroy for @http_handles;
441     @http_handles = ();
442     undef $listener;
443    
444     my $sent = 0;
445     $listener = AnyEvent::Socket::tcp_server "127.0.0.1", 0, sub {
446     my $handle = AnyEvent::Handle->new (
447     fh => $_[0], on_error => sub { $_[0]->destroy }, on_eof => sub { $_[0]->destroy },
448     );
449     push @http_handles, $handle;
450     $handle->push_read (line => "\r\n\r\n", sub {
451     ++$sent;
452     $_[0]->destroy;
453     });
454     }, sub { $port = $_[2]; () };
455     $client = AnyEvent::MCP::Client::HTTP->new (url => "http://127.0.0.1:$port/");
456     $client->{connected} = 1;
457     $client->{protocol_version} = "2026-07-28";
458     ($success, $result) = wait_for (sub { $client->call (change => {}, $_[0]) });
459     ok !$success && $result->{transport} && $sent == 1, "broken HTTP tool call is not replayed";
460     $client->close;
461     $_->destroy for @http_handles;
462     @http_handles = ();
463     undef $listener;
464    
465     my ($redirect_target, $redirect_port, $redirected);
466     $redirect_target = AnyEvent::Socket::tcp_server "127.0.0.1", 0, sub {
467     ++$redirected;
468     close $_[0];
469     }, sub { $redirect_port = $_[2]; () };
470     $listener = AnyEvent::Socket::tcp_server "127.0.0.1", 0, sub {
471     my $handle = AnyEvent::Handle->new (
472     fh => $_[0], on_error => sub { $_[0]->destroy }, on_eof => sub { $_[0]->destroy },
473     );
474     push @http_handles, $handle;
475     $handle->push_read (line => "\r\n\r\n", sub {
476     $_[0]->push_write (
477     "HTTP/1.1 302 Found\r\nContent-Length: 0\r\nConnection: close\r\n"
478     . "Location: http://127.0.0.1:$redirect_port/\r\n\r\n"
479     );
480     });
481     }, sub { $port = $_[2]; () };
482     $client = AnyEvent::MCP::Client::HTTP->new (url => "http://127.0.0.1:$port/", bearer => "secret");
483     $client->{connected} = 1;
484     $client->{protocol_version} = "2026-07-28";
485     ($success, $result) = wait_for (sub { $client->rpc ("ping", {}, $_[0]) });
486     ok !$success && !$redirected && $result->{message} =~ /302/, "redirect does not forward credentials";
487     $client->close;
488     $_->destroy for @http_handles;
489     undef $listener;
490     undef $redirect_target;