ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/lib/AnyEvent/MCP/Server/HTTP.pm
Revision: 1.20
Committed: Sun Nov 1 23:30:55 2026 UTC (14 hours, 18 minutes ago) by root
Branch: MAIN
CVS Tags: HEAD
Changes since 1.19: +50 -14 lines
Log Message:
*** empty log message ***

File Contents

# User Rev Content
1 root 1.1 =head1 NAME
2    
3     AnyEvent::MCP::Server::HTTP - an optional MCP HTTP listener and socket handler
4    
5     =head1 DESCRIPTION
6    
7 root 1.3 A subclass of L<AnyEvent::MCP::Server> that handles HTTP POST requests.
8     It can open its own listener, or you can give it sockets accepted elsewhere.
9     If your application already parses HTTP, you can pass decoded requests to
10     L<AnyEvent::MCP::Server> instead of running another HTTP implementation.
11    
12     Requests sharing one connection are dispatched in sequence, preserving their
13     HTTP response order; requests on other connections are independent. This
14     implementation returns JSON and drops notifications. It does not support
15     SSE or subscriptions.
16    
17     Request bodies need C<Content-Length>: chunked transfer encoding is not
18     supported. The server does not assign protocol sessions. Legacy clients
19     use the 2025 request/response subset.
20 root 1.1
21 root 1.10 C<Mcp-Param-*> headers from C<x-mcp-header> tool schema annotations are not
22 root 1.13 currently validated against the body. The built-in protocol headers
23     C<Mcp-Protocol-Version>, C<Mcp-Method> and, where applicable, C<Mcp-Name>
24     must agree with modern request metadata and parameters; a mismatch is
25     rejected. Custom schema-derived headers do not receive this check.
26 root 1.10
27 root 1.16 =head1 FIRST USE
28    
29     For an application that already registers tools in
30     L<AnyEvent::MCP::Server::Toolset>, start an HTTP listener:
31    
32     use AnyEvent;
33     use AnyEvent::MCP::Server::HTTP;
34    
35     my $server = AnyEvent::MCP::Server::HTTP->new (
36     toolsets => [$tools], tokens => [$secret],
37     host => "127.0.0.1", port => 31740,
38     );
39     $server->listen;
40     print $server->url, "\n";
41     AE::cv->recv;
42    
43     C<$tools> is a Toolset and C<$secret> is a previously generated private
44     URL-safe token. Do not use a sample or hardcoded token in a real service.
45     This object handles sockets and HTTP; L<AnyEvent::MCP::Server> dispatches
46     requests, while the Toolset runs tool callbacks. For a standalone program
47     with automatic configuration and startup, prefer
48     L<AnyEvent::MCP::Server::Simple>.
49    
50 root 1.7 =head1 AUTHENTICATION
51    
52     Unlike L<AnyEvent::MCP::Server::Simple/mcp_start>, this low-level object
53     does not load global configuration or derive credentials. Supply C<tokens>
54     explicitly, for example:
55    
56     my $server = AnyEvent::MCP::Server::HTTP->new (
57     host => "127.0.0.1",
58     port => 31740,
59     path => "/mcp/",
60     tokens => ["a-long-random-secret", "another-long-random-secret"],
61     );
62     $server->listen;
63    
64     Each token authorizes the same requests. For either token, a client may
65     POST to C</TOKEN/mcp/> without an Authorization header, or POST to
66     C</mcp/> with C<Authorization: Bearer TOKEN>. URL credentials are path
67     components, B<not> HTTP cookies. C<url> advertises the first token;
68 root 1.17 C<url (0)> returns the clean endpoint. A trailing slash may be omitted
69     from either request path when the configured endpoint path ends in one;
70     this accommodates clients that strip it from a configured URL.
71 root 1.7
72     The accepted tokens are static, URL-safe strings. HTTP Basic and OAuth
73     access-token validation are not implemented here. There is no automatic
74     fallback to the master-secret-derived token: that belongs to C<mcp_start>
75     or L<AnyEvent::MCP::Config/http_settings>.
76    
77 root 1.8 Authentication defaults to C<tokens>, and construction B<fails> unless a
78     nonempty array of valid tokens is provided. To deliberately delegate
79     authentication to a trusted embedding layer (for example, a reverse proxy
80     that rejects unauthorized requests before they reach MCP), specify
81     C<auth =E<gt> "none"> instead, B<without> C<tokens>. This is an explicit
82     opt-out, not a permissive default. The embedding layer then owns the
83     entire authentication boundary.
84 root 1.7 The C<origins> setting filters browser origins; it is not authentication.
85     URLs can leak through logs and histories, and HTTP alone does not encrypt
86     tokens. Use TLS when credentials cross an untrusted network. A local address
87     is a sensible default, but not a credential.
88    
89 root 1.1 =head1 METHODS
90    
91 root 1.13 =over
92    
93     =item $server = AnyEvent::MCP::Server::HTTP->new (%config)
94 root 1.1
95     Construction does not start a listener. In addition to Server options,
96     the following configuration options are supported:
97    
98 root 1.3 =over
99 root 1.1
100     =item host => $host
101    
102     The listening address, defaulting to C<127.0.0.1>.
103    
104     =item port => $port
105    
106     The listening port, defaulting to zero for an automatically assigned port.
107    
108     =item path => $path
109    
110 root 1.6 The MCP endpoint path, defaulting to C</mcp/>. Bearer authentication uses
111 root 1.17 C<$path> directly; URL tokens prefix it with C</TOKEN>. If this path ends
112     in C</>, requests without that final slash are also accepted. Other
113     path differences are not normalized.
114 root 1.1
115     =item connection_close => $boolean
116    
117     When true, disables persistent connections, sends C<Connection: close> and
118     closes the socket after draining each response. Defaults to false.
119     This is useful when an existing listener routes sockets by their first
120     request and cannot route subsequent requests independently.
121    
122     =item max_size => $bytes
123    
124 root 1.18 Limits incoming headers and request bodies, defaulting to 8 MiB.
125 root 1.1
126     =item origins => [$origin...]
127    
128 root 1.4 Allowed Origin header values. Defaults to C<["*"]>, permitting browser requests
129     from any origin. An empty array rejects requests with an Origin header, while
130     requests without one are accepted. Allowed origins receive CORS response headers,
131     and nonempty origin lists enable OPTIONS preflight for POST. Preflight does not
132 root 1.1 require authentication; the subsequent POST still does.
133    
134     =item handle => { %handle }
135    
136     AnyEvent::Handle constructor options, such as C<tls>, C<tls_ctx>, C<timeout>
137     or C<on_starttls>. The socket and the read/error/EOF handlers belong to this
138     class.
139    
140 root 1.20 =item tls_cert => $file
141    
142     Serve HTTP and HTTPS opportunistically on the same port. FILE contains
143     a PEM certificate chain and optionally its private key. The TLS context
144     is loaded once when listening starts. TLS needs L<Net::SSLeay>.
145    
146     =item tls_key => $file
147    
148     PEM private key for C<tls_cert>. If only one TLS option is supplied, its
149     file must contain both certificate and private key. On accepted connections,
150     C<< $handle->{tls} >> identifies encrypted traffic. Plain HTTP remains
151     available; TLS is not required of clients.
152    
153 root 1.6 =item tokens => [$token, ...]
154 root 1.1
155 root 1.6 Ordered tokens accepted either as C</TOKEN> before the configured
156     C<path> or in an C<Authorization: Bearer TOKEN> header. The first token
157 root 1.8 is used in advertised URLs. Token strings must be URL-safe and nonempty.
158    
159     =item auth => "tokens" | "none"
160    
161     Select authentication mode. C<tokens> is the default and requires at least
162     one token; C<none> explicitly disables HTTP authentication and cannot be
163     combined with tokens. Other values are rejected. Browser origins are
164     not a replacement for authentication.
165 root 1.1
166     =item name => $name
167    
168 root 1.6 Identifies the service. Use L<AnyEvent::MCP::Config/http_settings>
169     to resolve named listener configuration before constructing this transport.
170 root 1.1
171     =item url_host => $hostname
172    
173     Overrides the advertised hostname, for example when listening on a wildcard
174     address. Otherwise a wildcard uses the nodename from C<uname>.
175    
176     =item url_scheme => $scheme
177    
178 root 1.20 Overrides the advertised scheme; otherwise TLS credentials or handle options
179     select C<https>, and plain handles select C<http>.
180 root 1.1
181     =back
182    
183 root 1.6 Every configured token authenticates in either the URL or the Authorization
184 root 1.8 header. Without tokens, the constructor requires C<auth =E<gt> "none">.
185 root 1.6
186 root 1.13 =item $url = $server->url ($with_token)
187 root 1.6
188     Returns the advertised URL with the actual listening port. C<$with_token>
189     defaults to true, including the first configured token. Pass false for
190     the clean URL to use with an Authorization bearer token.
191 root 1.1
192     The port must be known. Wildcard hostname resolution and reverse-proxy
193     routing remain the caller's responsibility.
194    
195 root 1.13 =item $server->listen ($host, $port, %handle)
196 root 1.1
197     Starts a listener, replacing any previous listener. Omitted arguments use
198     the configured host and port. The actual bound port is stored in
199     C<< $server->{port} >>, including when requesting an ephemeral port.
200    
201     C<%handle> overrides the configured C<handle> options for connections
202     accepted by this listener.
203    
204 root 1.13 =item $handle = $server->accept ($socket, %handle)
205 root 1.1
206     Starts handling an already accepted socket. The server takes ownership of
207     C<$socket>. No listener is needed.
208    
209     C<%handle> overrides the configured C<handle> options for this connection.
210    
211 root 1.13 =item $server->close
212 root 1.1
213     Stops listening, closes accepted sockets and cancels outstanding requests.
214     The same object can subsequently listen or accept again.
215    
216 root 1.13 =back
217    
218 root 1.1 =head1 EVENTS
219    
220 root 1.13 =over
221    
222 root 1.14 =item on_extension => $callback->($server, $handle, $hello)
223    
224     Accept raw extension connections on the HTTP listener. A first byte of C<[>
225     selects a bounded, newline-terminated JSON hello whose first two fields are
226     the protocol name and an accepted listener token. Authenticate before invoking
227     this callback, which validates the protocol-specific fields and takes ownership
228     of the handle, preserving buffered input. Throw an exception to reject it.
229     HTTP connections continue through the normal parser. This dispatch examines
230     decrypted bytes when TLS is configured, but the built-in reverse connector
231     currently supports only plain C<http://> endpoints.
232    
233 root 1.15 =item on_http_request => $callback->($server, $request, $reply)
234    
235     Called for every complete, syntactically valid HTTP request before MCP routing
236     or authentication. C<$request> is a hash containing the parsed HTTP fields
237     (including C<method>, C<uri>, and C<http_version>) and a C<body> field holding
238     the raw request body. Return false to use the normal MCP handling, or true to
239     handle the request yourself.
240    
241     C<$reply-E<gt>($status, $reason, \%headers, $body)> sends a response, either
242     immediately or asynchronously after the callback returns. C<%headers> is
243     optional; C<Content-Type> defaults to C<application/json>, and the server
244     generates C<Content-Length> and manages persistent connections. The body is
245     an octet string. Reply at most once, and return true when taking ownership
246     of the request, even if the response will be sent later. The next request on
247     the same connection is not read until that response is sent.
248    
249     The hook runs B<before> MCP authentication and CORS checks. Application
250     endpoints must perform their own authentication and browser request checks.
251     It does not replace or bypass the normal authentication of unhandled MCP
252     requests. Malformed or oversized requests are rejected before the hook.
253    
254 root 1.13 =item on_connection_error => $callback->($server, $handle, $message)
255 root 1.1
256     Called for socket errors before closing the connection.
257    
258 root 1.13 =item on_close => $callback->($server)
259 root 1.1
260     Called once after C<close> stops the listener and closes all connections.
261     Calling C<listen> or C<accept> again starts a new lifetime.
262    
263 root 1.13 =back
264    
265 root 1.1 =cut
266    
267     package AnyEvent::MCP::Server::HTTP;
268    
269     use common::sense;
270     use parent "AnyEvent::MCP::Server";
271     use AnyEvent::Handle ();
272     use AnyEvent::Socket ();
273     use JSON::XS ();
274     use Scalar::Util qw(weaken);
275     use POSIX ();
276    
277     sub new {
278     my ($class, %kv) = @_;
279 root 1.12
280 root 1.6 !grep (exists $kv{$_}, qw(cookie bearer users))
281     or die "Legacy HTTP authentication options are unsupported; use tokens\n";
282 root 1.1
283 root 1.8 $kv{auth} //= "tokens";
284     if ($kv{auth} eq "tokens") {
285 root 1.6 ref $kv{tokens} eq "ARRAY" && @{$kv{tokens}}
286     or die "HTTP server requires nonempty tokens\n";
287     /\A[A-Za-z0-9_.~-]+\z/ or die "Invalid HTTP server token\n"
288     for @{$kv{tokens}};
289 root 1.8 } elsif ($kv{auth} eq "none") {
290     !exists $kv{tokens}
291     or die "Unauthenticated HTTP server must not configure tokens\n";
292     } else {
293     die "Unsupported HTTP authentication type '$kv{auth}'\n";
294 root 1.1 }
295    
296 root 1.4 $class->SUPER::new (host => "127.0.0.1", port => 0, path => "/mcp/", origins => ["*"], %kv)
297 root 1.1 }
298    
299 root 1.20 sub _tls_context {
300     my ($self) = @_;
301    
302     return unless defined $self->{tls_cert} || defined $self->{tls_key};
303    
304     $self->{_tls_context} ||= do {
305     require AnyEvent::TLS;
306     AnyEvent::TLS->new (
307     cert_file => $self->{tls_cert} // $self->{tls_key},
308     (defined $self->{tls_key} ? (key_file => $self->{tls_key}) : ()),
309     )
310     }
311     }
312    
313 root 1.1 sub listen {
314     my ($self, $host, $port, %handle) = @_;
315    
316 root 1.20 $self->_tls_context;
317 root 1.1 delete $self->{_closed};
318     $host = $self->{host} unless defined $host;
319     $port = $self->{port} unless defined $port;
320    
321     $self->{listen_host} = $host;
322     $self->{listen_handle} = { %{$self->{handle} || {}}, %handle };
323    
324     delete $self->{listener};
325     weaken $self;
326    
327     $self->{listener} = AnyEvent::Socket::tcp_server $host, $port, sub {
328 root 1.19 return unless $self;
329     my ($socket, $host, $port) = @_;
330     my $handle = $self->accept ($socket, %handle);
331     $handle->{mcp_remote} = AnyEvent::Socket::format_hostport ($host, $port);
332 root 1.1 }, sub {
333     $self->{port} = $_[2] if $self;
334     ()
335     };
336     }
337    
338 root 1.6 sub _token_path {
339     my ($self, $token) = @_;
340 root 1.12
341 root 1.6 "/$token$self->{path}"
342 root 1.1 }
343    
344     sub url {
345 root 1.12 my ($self, $with_token) = @_;
346 root 1.1
347 root 1.12 $with_token = 1 unless defined $with_token;
348 root 1.1
349     my $host = $self->{url_host}
350     || (defined $self->{listen_host} ? $self->{listen_host} : $self->{host});
351     $host = (POSIX::uname)[1] if !length $host || $host eq "0.0.0.0" || $host eq "::";
352 root 1.9 $host =~ s/\A\[(.*)\]\z/$1/; # allow already bracketed IPv6 URL hosts
353 root 1.1
354     my $port = $self->{port} or die "HTTP port is not known\n";
355     my $scheme = $self->{url_scheme}
356 root 1.20 || (defined $self->{tls_cert} || defined $self->{tls_key}
357     || ($self->{listen_handle} || $self->{handle} || {})->{tls} ? "https" : "http");
358 root 1.12 my $path = $with_token && $self->{tokens} && @{$self->{tokens}}
359 root 1.6 ? $self->_token_path ($self->{tokens}[0]) : $self->{path};
360 root 1.1
361 root 1.9 "$scheme://" . AnyEvent::Socket::format_hostport ($host, $port) . $path
362 root 1.1 }
363    
364     sub accept {
365     my ($self, $socket, %handle) = @_;
366    
367     delete $self->{_closed};
368     weaken $self;
369    
370     my $handle = AnyEvent::Handle->new (
371 root 1.18 rbuf_max => $self->{max_size} || 8 * 1024 * 1024,
372 root 1.1 %{$self->{handle}},
373     %handle,
374     fh => $socket,
375     on_read => undef,
376     on_error => sub {
377     $self->event (connection_error => $_[0], $_[2]) if $self;
378     $self->_close ($_[0]) if $self;
379     },
380     on_eof => sub { $self->_close ($_[0]) if $self },
381     );
382    
383     $handle->{mcp_peer} = {};
384     $self->{connections}{$handle} = $handle;
385 root 1.20 $handle->push_read (tls_autostart => $self->_tls_context, "accept")
386     if (defined $self->{tls_cert} || defined $self->{tls_key}) && !$handle->{tls};
387    
388 root 1.14 if ($self->{on_extension}) {
389     $handle->rtimeout (15);
390 root 1.20 $handle->on_read (sub {
391     my ($handle) = @_;
392    
393     $handle->on_read (undef);
394 root 1.14 return unless $self;
395    
396 root 1.20 if (substr ($handle->{rbuf}, 0, 1) eq "[") {
397 root 1.14 require AnyEvent::MCP::Extension;
398     AnyEvent::MCP::Extension::accept ($handle, $self->{tokens} || [], sub {
399     my ($handle, $hello) = @_;
400     return $handle->destroy unless $self;
401     delete $self->{connections}{$handle};
402     $self->{on_extension}->($self, $handle, $hello);
403     }, sub { $self->_close ($_[0]) if $self });
404     } else {
405     $handle->rtimeout (0);
406 root 1.20 $self->_read ($handle, 1);
407 root 1.14 }
408     });
409     } else {
410     $self->_read ($handle);
411     }
412 root 1.1
413     $handle
414     }
415    
416 root 1.12 # parse a request line and HTTP fields, using the same comma-combining
417 root 1.10 # approach as AnyEvent::HTTP::_parse_hdr. The caller already bounds headers.
418     sub _parse_request_headers {
419     local $_ = $_[0] . "\r\n";
420     /\A([!#$%&'*+.^_\x60|~0-9A-Za-z-]+) [ ]+
421     ([^\x00-\x20]+) [ ]+ HTTP\/0*([0-9.]+)\r\n/gcix
422     or return;
423     my ($method, $uri, $version) = ($1, $2, $3);
424     return unless $version =~ /\A1\.[0-9]+\z/;
425     my %hdr;
426    
427     $hdr{lc $1} .= ",$2"
428     while /\G
429     ([!\#$%&'*+.^_\x60|~0-9A-Za-z-]+):
430     [\t ]*
431     ([^\x00-\x08\x0a-\x1f\x7f]*)
432     \r\n
433     /gcx;
434    
435     /\G\z/gc or return;
436    
437 root 1.12 # discard the prefix used for comma-combining repeated field values.
438 root 1.10 substr $_, 0, 1, "" for values %hdr;
439     s/[\t ]+\z// for values %hdr;
440    
441 root 1.12 # request-line fields override any colliding HTTP header names.
442 root 1.11 +{ %hdr, method => $method, uri => $uri, http_version => $version }
443 root 1.10 }
444    
445 root 1.1 sub _read {
446 root 1.20 my ($self, $handle, $immediate) = @_;
447 root 1.1
448     weaken $self;
449    
450 root 1.20 if ($immediate) {
451 root 1.19 $handle->push_read (line => "\r\n\r\n", sub {
452     my ($handle, $header) = @_;
453 root 1.12
454 root 1.19 return unless $self;
455 root 1.1
456 root 1.19 my $hdr = _parse_request_headers ($header);
457     $handle->{mcp_close} = $self->{connection_close}
458     || !$hdr || $hdr->{http_version} =~ m{\A1\.0+\z}
459     || $hdr->{connection} =~ /\bclose\b/i;
460    
461     return $self->_respond ($handle, 400, "Bad Request", "") unless $hdr;
462    
463     $hdr->{"content-length"} = 0 unless defined $hdr->{"content-length"};
464     return $self->_respond ($handle, 400, "Bad Request", "")
465     if exists $hdr->{"transfer-encoding"}
466     || $hdr->{"content-length"} !~ /\A[0-9]+\z/;
467    
468     return $self->_respond ($handle, 413, "Content Too Large", "")
469     if $hdr->{"content-length"} > ($self->{max_size} || 8 * 1024 * 1024);
470    
471     if ($hdr->{expect}) {
472     return $self->_respond ($handle, 417, "Expectation Failed", "")
473     unless lc $hdr->{expect} eq "100-continue";
474 root 1.1
475 root 1.19 $handle->push_write ("HTTP/1.1 100 Continue\r\n\r\n");
476     }
477 root 1.1
478 root 1.19 $handle->push_read (chunk => $hdr->{"content-length"}, sub {
479     my ($handle, $body) = @_;
480 root 1.1
481 root 1.19 $self->_request ($handle, $hdr, $body) if $self;
482     });
483 root 1.1 });
484 root 1.20 } else {
485     $handle->on_read (sub {
486     my ($handle) = @_;
487     $handle->on_read (undef);
488     $self->_read ($handle, 1) if $self;
489     });
490     }
491 root 1.1 }
492    
493     sub _request {
494 root 1.11 my ($self, $handle, $hdr, $body) = @_;
495 root 1.1
496     delete $handle->{mcp_origin};
497    
498 root 1.15 if (my $hook = $self->{on_http_request}) {
499     weaken $self;
500     my $replied;
501     my $reply = sub {
502     return if $replied++ || !$self || $handle->{mcp_closed};
503     my ($status, $reason, $headers, $content) = @_;
504     $self->_respond ($handle, $status, $reason, $content, $headers);
505     };
506    
507     return if $hook->($self, { %$hdr, body => $body }, $reply);
508     }
509    
510 root 1.17 # Some MCP clients strip a trailing slash from the configured URL.
511     my $uri = $hdr->{uri};
512     $uri .= "/" if $self->{path} =~ m{/$} && $uri !~ m{/$};
513 root 1.6 my $token_path = $self->{tokens}
514 root 1.17 && grep $uri eq $self->_token_path ($_), @{$self->{tokens}};
515 root 1.1
516     return $self->_respond ($handle, 404, "Not Found", "")
517 root 1.17 unless $uri eq $self->{path} || $token_path;
518 root 1.1
519 root 1.11 if (exists $hdr->{origin}) {
520 root 1.1 my $wildcard = grep $_ eq "*", @{$self->{origins}};
521    
522     return $self->_respond ($handle, 403, "Forbidden", "")
523 root 1.11 unless $wildcard || grep $_ eq $hdr->{origin}, @{$self->{origins}};
524 root 1.1
525 root 1.11 $handle->{mcp_origin} = $wildcard ? "*" : $hdr->{origin};
526 root 1.1 }
527    
528 root 1.11 if ($hdr->{method} eq "OPTIONS" && @{$self->{origins}}) {
529 root 1.1 return $self->_respond ($handle, 405, "Method Not Allowed", "")
530 root 1.11 if exists $hdr->{"access-control-request-method"}
531     && $hdr->{"access-control-request-method"} ne "POST";
532 root 1.1
533     return $self->_respond ($handle, 204, "No Content", "");
534     }
535    
536     return $self->_respond ($handle, 405, "Method Not Allowed", "")
537 root 1.11 unless $hdr->{method} eq "POST";
538 root 1.1
539 root 1.6 if ($self->{tokens}) {
540     my $authenticated = $token_path;
541 root 1.11 my $authorization = $hdr->{authorization};
542 root 1.1
543 root 1.6 if ($authorization =~ /\ABearer ([^\s]+)\z/i) {
544     my $token = $1;
545     $authenticated ||= grep $_ eq $token, @{$self->{tokens}};
546 root 1.1 }
547    
548     return $self->_respond ($handle, 401, "Unauthorized", "")
549     unless $authenticated;
550     }
551    
552     return $self->_respond ($handle, 415, "Unsupported Media Type", "")
553 root 1.11 unless $hdr->{"content-type"} =~ m{\Aapplication/json(?:\s*;|\z)}i;
554 root 1.1
555     my $message = eval { JSON::XS::decode_json $body };
556    
557     if ($@) {
558     return $self->_rpc_error (sub {
559     $self->_respond ($handle, 400, "Bad Request", JSON::XS::encode_json $_[0]);
560     }, undef, -32700, "Parse error");
561     }
562    
563     my $params = ref $message eq "HASH" && ref $message->{params} eq "HASH" ? $message->{params} : {};
564     my $meta = ref $params->{_meta} eq "HASH" ? $params->{_meta} : {};
565     my $modern = exists $meta->{"io.modelcontextprotocol/protocolVersion"}
566 root 1.11 || $hdr->{"mcp-protocol-version"} ge "2026";
567 root 1.1
568     if ($modern && ref $message eq "HASH" && exists $message->{id}) {
569 root 1.11 my %expected = (
570     "mcp-protocol-version" => $meta->{"io.modelcontextprotocol/protocolVersion"},
571     "mcp-method" => $message->{method},
572 root 1.1 );
573    
574 root 1.11 $expected{"mcp-name"} = exists $params->{name} ? $params->{name} : $params->{uri}
575 root 1.1 if $message->{method} =~ /\A(?:tools\/call|prompts\/get|resources\/read)\z/;
576    
577 root 1.11 for my $name (keys %expected) {
578     my $value = $hdr->{$name};
579 root 1.1
580 root 1.11 if ($name eq "mcp-name" && $value =~ /\A=\?base64\?([A-Za-z0-9+\/=]*)\?=\z/) {
581 root 1.2 $value = eval { AnyEvent::MCP::utf8dec (MIME::Base64::decode_base64 ($1)) };
582 root 1.1 }
583    
584 root 1.11 unless (defined $value && defined $expected{$name} && $value eq $expected{$name}) {
585 root 1.1 return $self->_rpc_error (sub {
586     $self->_respond ($handle, 400, "Bad Request", JSON::XS::encode_json $_[0]);
587     }, $message->{id}, -32020, "Header mismatch: $name");
588     }
589     }
590     }
591    
592     weaken $self;
593    
594     $self->rpc ($message, sub {
595     my ($response) = @_;
596 root 1.12
597 root 1.1 return unless $self && !$handle->{mcp_closed};
598    
599     my ($status, $reason) = defined $response ? (200, "OK") : (202, "Accepted");
600    
601     if ($modern && defined $response && $response->{error}) {
602     ($status, $reason) = $response->{error}{code} == -32601
603     ? (404, "Not Found") : (400, "Bad Request");
604     }
605    
606     $self->_respond ($handle, $status, $reason, defined $response ? JSON::XS::encode_json $response : "");
607     },
608 root 1.12 peer => $handle->{mcp_peer},
609 root 1.11 protocol_version => $hdr->{"mcp-protocol-version"} || "2025-03-26",
610 root 1.1 );
611     }
612    
613     sub _respond {
614 root 1.15 my ($self, $handle, $status, $reason, $body, $headers) = @_;
615 root 1.1
616     return if $handle->{mcp_closed};
617    
618 root 1.15 $body = "" unless defined $body;
619     my $content_type = "application/json";
620     my $extra = "";
621    
622     if ($headers) {
623     ref $headers eq "HASH" or die "HTTP response headers must be a hash\n";
624     while (my ($name, $value) = each %$headers) {
625     $name =~ /\A[!#\$%&'*+.^_`|~0-9A-Za-z-]+\z/
626     && defined $value && !ref $value && $value !~ /[\r\n\0]/
627     or die "Invalid HTTP response header\n";
628     $name !~ /\A(?:content-length|connection|transfer-encoding)\z/i
629     or die "HTTP response framing headers are managed by the server\n";
630     if (lc $name eq "content-type") {
631     $content_type = $value;
632     } else {
633     $extra .= "$name: $value\r\n";
634     }
635     }
636     }
637    
638 root 1.12 my $close = $handle->{mcp_close} || $status != 200 && $status != 202;
639    
640 root 1.1 $handle->push_write (
641     "HTTP/1.1 $status $reason\r\n"
642 root 1.15 . "Content-Type: $content_type\r\n"
643     . "Content-Length: " . (length $body) . "\r\n"
644     . $extra
645 root 1.1 . (defined $handle->{mcp_origin} ? "Access-Control-Allow-Origin: $handle->{mcp_origin}\r\n"
646     . ($handle->{mcp_origin} ne "*" ? "Vary: Origin\r\n" : "") : "")
647     . ($status == 204 ? "Access-Control-Allow-Methods: POST, OPTIONS\r\n"
648     . "Access-Control-Allow-Headers: content-type,authorization,mcp-protocol-version,mcp-method,mcp-name\r\n"
649     . "Access-Control-Max-Age: 86400\r\n" : "")
650     . ($status == 405 ? "Allow: POST" . (@{$self->{origins}} ? ", OPTIONS" : "") . "\r\n" : "")
651 root 1.6 . ($status == 401 && $self->{tokens} ? "WWW-Authenticate: Bearer\r\n" : "")
652 root 1.12 . ($close ? "Connection: close\r\n" : "")
653 root 1.1 . "\r\n$body"
654     );
655    
656 root 1.12 if ($close) {
657 root 1.1 weaken $self;
658     $handle->on_drain (sub { $self->_close ($_[0]) if $self });
659     } else {
660     $self->_read ($handle);
661     }
662     }
663    
664     sub _close {
665     my ($self, $handle) = @_;
666    
667     return if $handle->{mcp_closed};
668     $handle->{mcp_closed} = 1;
669    
670     my $requests = delete $handle->{mcp_peer}{requests} || {};
671     $_->cancel ("Connection closed") for grep defined, values %$requests;
672    
673     delete $self->{connections}{$handle};
674     $handle->destroy;
675     }
676    
677     sub close {
678     my ($self) = @_;
679    
680     return if $self->{_closed}++;
681     delete $self->{listener};
682     $self->_close ($_) for values %{$self->{connections} || {}};
683     $self->event (close =>);
684     }
685    
686     sub DESTROY {
687     $_[0]->close
688     }
689    
690     1