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