ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/cvsroot/AnyEvent-MCP/lib/AnyEvent/MCP/Server/HTTP.pm
Revision: 1.19
Committed: Sun Nov 1 22:07:43 2026 UTC (45 hours, 28 minutes ago) by root
Branch: MAIN
Changes since 1.18: +35 -27 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.6 =item tokens => [$token, ...]
141 root 1.1
142 root 1.6 Ordered tokens accepted either as C</TOKEN> before the configured
143     C<path> or in an C<Authorization: Bearer TOKEN> header. The first token
144 root 1.8 is used in advertised URLs. Token strings must be URL-safe and nonempty.
145    
146     =item auth => "tokens" | "none"
147    
148     Select authentication mode. C<tokens> is the default and requires at least
149     one token; C<none> explicitly disables HTTP authentication and cannot be
150     combined with tokens. Other values are rejected. Browser origins are
151     not a replacement for authentication.
152 root 1.1
153     =item name => $name
154    
155 root 1.6 Identifies the service. Use L<AnyEvent::MCP::Config/http_settings>
156     to resolve named listener configuration before constructing this transport.
157 root 1.1
158     =item url_host => $hostname
159    
160     Overrides the advertised hostname, for example when listening on a wildcard
161     address. Otherwise a wildcard uses the nodename from C<uname>.
162    
163     =item url_scheme => $scheme
164    
165     Overrides the advertised scheme; otherwise TLS handle options select
166     C<https>, and plain handles select C<http>.
167    
168     =back
169    
170 root 1.6 Every configured token authenticates in either the URL or the Authorization
171 root 1.8 header. Without tokens, the constructor requires C<auth =E<gt> "none">.
172 root 1.6
173 root 1.13 =item $url = $server->url ($with_token)
174 root 1.6
175     Returns the advertised URL with the actual listening port. C<$with_token>
176     defaults to true, including the first configured token. Pass false for
177     the clean URL to use with an Authorization bearer token.
178 root 1.1
179     The port must be known. Wildcard hostname resolution and reverse-proxy
180     routing remain the caller's responsibility.
181    
182 root 1.13 =item $server->listen ($host, $port, %handle)
183 root 1.1
184     Starts a listener, replacing any previous listener. Omitted arguments use
185     the configured host and port. The actual bound port is stored in
186     C<< $server->{port} >>, including when requesting an ephemeral port.
187    
188     C<%handle> overrides the configured C<handle> options for connections
189     accepted by this listener.
190    
191 root 1.13 =item $handle = $server->accept ($socket, %handle)
192 root 1.1
193     Starts handling an already accepted socket. The server takes ownership of
194     C<$socket>. No listener is needed.
195    
196     C<%handle> overrides the configured C<handle> options for this connection.
197    
198 root 1.13 =item $server->close
199 root 1.1
200     Stops listening, closes accepted sockets and cancels outstanding requests.
201     The same object can subsequently listen or accept again.
202    
203 root 1.13 =back
204    
205 root 1.1 =head1 EVENTS
206    
207 root 1.13 =over
208    
209 root 1.14 =item on_extension => $callback->($server, $handle, $hello)
210    
211     Accept raw extension connections on the HTTP listener. A first byte of C<[>
212     selects a bounded, newline-terminated JSON hello whose first two fields are
213     the protocol name and an accepted listener token. Authenticate before invoking
214     this callback, which validates the protocol-specific fields and takes ownership
215     of the handle, preserving buffered input. Throw an exception to reject it.
216     HTTP connections continue through the normal parser. This dispatch examines
217     decrypted bytes when TLS is configured, but the built-in reverse connector
218     currently supports only plain C<http://> endpoints.
219    
220 root 1.15 =item on_http_request => $callback->($server, $request, $reply)
221    
222     Called for every complete, syntactically valid HTTP request before MCP routing
223     or authentication. C<$request> is a hash containing the parsed HTTP fields
224     (including C<method>, C<uri>, and C<http_version>) and a C<body> field holding
225     the raw request body. Return false to use the normal MCP handling, or true to
226     handle the request yourself.
227    
228     C<$reply-E<gt>($status, $reason, \%headers, $body)> sends a response, either
229     immediately or asynchronously after the callback returns. C<%headers> is
230     optional; C<Content-Type> defaults to C<application/json>, and the server
231     generates C<Content-Length> and manages persistent connections. The body is
232     an octet string. Reply at most once, and return true when taking ownership
233     of the request, even if the response will be sent later. The next request on
234     the same connection is not read until that response is sent.
235    
236     The hook runs B<before> MCP authentication and CORS checks. Application
237     endpoints must perform their own authentication and browser request checks.
238     It does not replace or bypass the normal authentication of unhandled MCP
239     requests. Malformed or oversized requests are rejected before the hook.
240    
241 root 1.13 =item on_connection_error => $callback->($server, $handle, $message)
242 root 1.1
243     Called for socket errors before closing the connection.
244    
245 root 1.13 =item on_close => $callback->($server)
246 root 1.1
247     Called once after C<close> stops the listener and closes all connections.
248     Calling C<listen> or C<accept> again starts a new lifetime.
249    
250 root 1.13 =back
251    
252 root 1.1 =cut
253    
254     package AnyEvent::MCP::Server::HTTP;
255    
256     use common::sense;
257     use parent "AnyEvent::MCP::Server";
258     use AnyEvent::Handle ();
259     use AnyEvent::Socket ();
260     use JSON::XS ();
261     use Scalar::Util qw(weaken);
262     use POSIX ();
263    
264     sub new {
265     my ($class, %kv) = @_;
266 root 1.12
267 root 1.6 !grep (exists $kv{$_}, qw(cookie bearer users))
268     or die "Legacy HTTP authentication options are unsupported; use tokens\n";
269 root 1.1
270 root 1.8 $kv{auth} //= "tokens";
271     if ($kv{auth} eq "tokens") {
272 root 1.6 ref $kv{tokens} eq "ARRAY" && @{$kv{tokens}}
273     or die "HTTP server requires nonempty tokens\n";
274     /\A[A-Za-z0-9_.~-]+\z/ or die "Invalid HTTP server token\n"
275     for @{$kv{tokens}};
276 root 1.8 } elsif ($kv{auth} eq "none") {
277     !exists $kv{tokens}
278     or die "Unauthenticated HTTP server must not configure tokens\n";
279     } else {
280     die "Unsupported HTTP authentication type '$kv{auth}'\n";
281 root 1.1 }
282    
283 root 1.4 $class->SUPER::new (host => "127.0.0.1", port => 0, path => "/mcp/", origins => ["*"], %kv)
284 root 1.1 }
285    
286     sub listen {
287     my ($self, $host, $port, %handle) = @_;
288    
289     delete $self->{_closed};
290     $host = $self->{host} unless defined $host;
291     $port = $self->{port} unless defined $port;
292    
293     $self->{listen_host} = $host;
294     $self->{listen_handle} = { %{$self->{handle} || {}}, %handle };
295    
296     delete $self->{listener};
297     weaken $self;
298    
299     $self->{listener} = AnyEvent::Socket::tcp_server $host, $port, sub {
300 root 1.19 return unless $self;
301     my ($socket, $host, $port) = @_;
302     my $handle = $self->accept ($socket, %handle);
303     $handle->{mcp_remote} = AnyEvent::Socket::format_hostport ($host, $port);
304 root 1.1 }, sub {
305     $self->{port} = $_[2] if $self;
306     ()
307     };
308     }
309    
310 root 1.6 sub _token_path {
311     my ($self, $token) = @_;
312 root 1.12
313 root 1.6 "/$token$self->{path}"
314 root 1.1 }
315    
316     sub url {
317 root 1.12 my ($self, $with_token) = @_;
318 root 1.1
319 root 1.12 $with_token = 1 unless defined $with_token;
320 root 1.1
321     my $host = $self->{url_host}
322     || (defined $self->{listen_host} ? $self->{listen_host} : $self->{host});
323     $host = (POSIX::uname)[1] if !length $host || $host eq "0.0.0.0" || $host eq "::";
324 root 1.9 $host =~ s/\A\[(.*)\]\z/$1/; # allow already bracketed IPv6 URL hosts
325 root 1.1
326     my $port = $self->{port} or die "HTTP port is not known\n";
327     my $scheme = $self->{url_scheme}
328     || (($self->{listen_handle} || $self->{handle} || {})->{tls} ? "https" : "http");
329 root 1.12 my $path = $with_token && $self->{tokens} && @{$self->{tokens}}
330 root 1.6 ? $self->_token_path ($self->{tokens}[0]) : $self->{path};
331 root 1.1
332 root 1.9 "$scheme://" . AnyEvent::Socket::format_hostport ($host, $port) . $path
333 root 1.1 }
334    
335     sub accept {
336     my ($self, $socket, %handle) = @_;
337    
338     delete $self->{_closed};
339     weaken $self;
340    
341     my $handle = AnyEvent::Handle->new (
342 root 1.18 rbuf_max => $self->{max_size} || 8 * 1024 * 1024,
343 root 1.1 %{$self->{handle}},
344     %handle,
345     fh => $socket,
346     on_read => undef,
347     on_error => sub {
348     $self->event (connection_error => $_[0], $_[2]) if $self;
349     $self->_close ($_[0]) if $self;
350     },
351     on_eof => sub { $self->_close ($_[0]) if $self },
352     );
353    
354     $handle->{mcp_peer} = {};
355     $self->{connections}{$handle} = $handle;
356 root 1.14 if ($self->{on_extension}) {
357     $handle->rtimeout (15);
358     $handle->push_read (chunk => 1, sub {
359     my ($handle, $first) = @_;
360     return unless $self;
361     $handle->{rbuf} = $first . $handle->{rbuf};
362    
363     if ($first eq "[") {
364     require AnyEvent::MCP::Extension;
365     AnyEvent::MCP::Extension::accept ($handle, $self->{tokens} || [], sub {
366     my ($handle, $hello) = @_;
367     return $handle->destroy unless $self;
368     delete $self->{connections}{$handle};
369     $self->{on_extension}->($self, $handle, $hello);
370     }, sub { $self->_close ($_[0]) if $self });
371     } else {
372     $handle->rtimeout (0);
373     $self->_read ($handle);
374     }
375     });
376     } else {
377     $self->_read ($handle);
378     }
379 root 1.1
380     $handle
381     }
382    
383 root 1.12 # parse a request line and HTTP fields, using the same comma-combining
384 root 1.10 # approach as AnyEvent::HTTP::_parse_hdr. The caller already bounds headers.
385     sub _parse_request_headers {
386     local $_ = $_[0] . "\r\n";
387     /\A([!#$%&'*+.^_\x60|~0-9A-Za-z-]+) [ ]+
388     ([^\x00-\x20]+) [ ]+ HTTP\/0*([0-9.]+)\r\n/gcix
389     or return;
390     my ($method, $uri, $version) = ($1, $2, $3);
391     return unless $version =~ /\A1\.[0-9]+\z/;
392     my %hdr;
393    
394     $hdr{lc $1} .= ",$2"
395     while /\G
396     ([!\#$%&'*+.^_\x60|~0-9A-Za-z-]+):
397     [\t ]*
398     ([^\x00-\x08\x0a-\x1f\x7f]*)
399     \r\n
400     /gcx;
401    
402     /\G\z/gc or return;
403    
404 root 1.12 # discard the prefix used for comma-combining repeated field values.
405 root 1.10 substr $_, 0, 1, "" for values %hdr;
406     s/[\t ]+\z// for values %hdr;
407    
408 root 1.12 # request-line fields override any colliding HTTP header names.
409 root 1.11 +{ %hdr, method => $method, uri => $uri, http_version => $version }
410 root 1.10 }
411    
412 root 1.1 sub _read {
413     my ($self, $handle) = @_;
414    
415     weaken $self;
416    
417 root 1.19 $handle->on_read (sub {
418     my ($handle) = @_;
419    
420     $handle->on_read (undef);
421     $handle->push_read (line => "\r\n\r\n", sub {
422     my ($handle, $header) = @_;
423 root 1.12
424 root 1.19 return unless $self;
425 root 1.1
426 root 1.19 my $hdr = _parse_request_headers ($header);
427     $handle->{mcp_close} = $self->{connection_close}
428     || !$hdr || $hdr->{http_version} =~ m{\A1\.0+\z}
429     || $hdr->{connection} =~ /\bclose\b/i;
430    
431     return $self->_respond ($handle, 400, "Bad Request", "") unless $hdr;
432    
433     $hdr->{"content-length"} = 0 unless defined $hdr->{"content-length"};
434     return $self->_respond ($handle, 400, "Bad Request", "")
435     if exists $hdr->{"transfer-encoding"}
436     || $hdr->{"content-length"} !~ /\A[0-9]+\z/;
437    
438     return $self->_respond ($handle, 413, "Content Too Large", "")
439     if $hdr->{"content-length"} > ($self->{max_size} || 8 * 1024 * 1024);
440    
441     if ($hdr->{expect}) {
442     return $self->_respond ($handle, 417, "Expectation Failed", "")
443     unless lc $hdr->{expect} eq "100-continue";
444 root 1.1
445 root 1.19 $handle->push_write ("HTTP/1.1 100 Continue\r\n\r\n");
446     }
447 root 1.1
448 root 1.19 $handle->push_read (chunk => $hdr->{"content-length"}, sub {
449     my ($handle, $body) = @_;
450 root 1.1
451 root 1.19 $self->_request ($handle, $hdr, $body) if $self;
452     });
453 root 1.1 });
454     });
455     }
456    
457     sub _request {
458 root 1.11 my ($self, $handle, $hdr, $body) = @_;
459 root 1.1
460     delete $handle->{mcp_origin};
461    
462 root 1.15 if (my $hook = $self->{on_http_request}) {
463     weaken $self;
464     my $replied;
465     my $reply = sub {
466     return if $replied++ || !$self || $handle->{mcp_closed};
467     my ($status, $reason, $headers, $content) = @_;
468     $self->_respond ($handle, $status, $reason, $content, $headers);
469     };
470    
471     return if $hook->($self, { %$hdr, body => $body }, $reply);
472     }
473    
474 root 1.17 # Some MCP clients strip a trailing slash from the configured URL.
475     my $uri = $hdr->{uri};
476     $uri .= "/" if $self->{path} =~ m{/$} && $uri !~ m{/$};
477 root 1.6 my $token_path = $self->{tokens}
478 root 1.17 && grep $uri eq $self->_token_path ($_), @{$self->{tokens}};
479 root 1.1
480     return $self->_respond ($handle, 404, "Not Found", "")
481 root 1.17 unless $uri eq $self->{path} || $token_path;
482 root 1.1
483 root 1.11 if (exists $hdr->{origin}) {
484 root 1.1 my $wildcard = grep $_ eq "*", @{$self->{origins}};
485    
486     return $self->_respond ($handle, 403, "Forbidden", "")
487 root 1.11 unless $wildcard || grep $_ eq $hdr->{origin}, @{$self->{origins}};
488 root 1.1
489 root 1.11 $handle->{mcp_origin} = $wildcard ? "*" : $hdr->{origin};
490 root 1.1 }
491    
492 root 1.11 if ($hdr->{method} eq "OPTIONS" && @{$self->{origins}}) {
493 root 1.1 return $self->_respond ($handle, 405, "Method Not Allowed", "")
494 root 1.11 if exists $hdr->{"access-control-request-method"}
495     && $hdr->{"access-control-request-method"} ne "POST";
496 root 1.1
497     return $self->_respond ($handle, 204, "No Content", "");
498     }
499    
500     return $self->_respond ($handle, 405, "Method Not Allowed", "")
501 root 1.11 unless $hdr->{method} eq "POST";
502 root 1.1
503 root 1.6 if ($self->{tokens}) {
504     my $authenticated = $token_path;
505 root 1.11 my $authorization = $hdr->{authorization};
506 root 1.1
507 root 1.6 if ($authorization =~ /\ABearer ([^\s]+)\z/i) {
508     my $token = $1;
509     $authenticated ||= grep $_ eq $token, @{$self->{tokens}};
510 root 1.1 }
511    
512     return $self->_respond ($handle, 401, "Unauthorized", "")
513     unless $authenticated;
514     }
515    
516     return $self->_respond ($handle, 415, "Unsupported Media Type", "")
517 root 1.11 unless $hdr->{"content-type"} =~ m{\Aapplication/json(?:\s*;|\z)}i;
518 root 1.1
519     my $message = eval { JSON::XS::decode_json $body };
520    
521     if ($@) {
522     return $self->_rpc_error (sub {
523     $self->_respond ($handle, 400, "Bad Request", JSON::XS::encode_json $_[0]);
524     }, undef, -32700, "Parse error");
525     }
526    
527     my $params = ref $message eq "HASH" && ref $message->{params} eq "HASH" ? $message->{params} : {};
528     my $meta = ref $params->{_meta} eq "HASH" ? $params->{_meta} : {};
529     my $modern = exists $meta->{"io.modelcontextprotocol/protocolVersion"}
530 root 1.11 || $hdr->{"mcp-protocol-version"} ge "2026";
531 root 1.1
532     if ($modern && ref $message eq "HASH" && exists $message->{id}) {
533 root 1.11 my %expected = (
534     "mcp-protocol-version" => $meta->{"io.modelcontextprotocol/protocolVersion"},
535     "mcp-method" => $message->{method},
536 root 1.1 );
537    
538 root 1.11 $expected{"mcp-name"} = exists $params->{name} ? $params->{name} : $params->{uri}
539 root 1.1 if $message->{method} =~ /\A(?:tools\/call|prompts\/get|resources\/read)\z/;
540    
541 root 1.11 for my $name (keys %expected) {
542     my $value = $hdr->{$name};
543 root 1.1
544 root 1.11 if ($name eq "mcp-name" && $value =~ /\A=\?base64\?([A-Za-z0-9+\/=]*)\?=\z/) {
545 root 1.2 $value = eval { AnyEvent::MCP::utf8dec (MIME::Base64::decode_base64 ($1)) };
546 root 1.1 }
547    
548 root 1.11 unless (defined $value && defined $expected{$name} && $value eq $expected{$name}) {
549 root 1.1 return $self->_rpc_error (sub {
550     $self->_respond ($handle, 400, "Bad Request", JSON::XS::encode_json $_[0]);
551     }, $message->{id}, -32020, "Header mismatch: $name");
552     }
553     }
554     }
555    
556     weaken $self;
557    
558     $self->rpc ($message, sub {
559     my ($response) = @_;
560 root 1.12
561 root 1.1 return unless $self && !$handle->{mcp_closed};
562    
563     my ($status, $reason) = defined $response ? (200, "OK") : (202, "Accepted");
564    
565     if ($modern && defined $response && $response->{error}) {
566     ($status, $reason) = $response->{error}{code} == -32601
567     ? (404, "Not Found") : (400, "Bad Request");
568     }
569    
570     $self->_respond ($handle, $status, $reason, defined $response ? JSON::XS::encode_json $response : "");
571     },
572 root 1.12 peer => $handle->{mcp_peer},
573 root 1.11 protocol_version => $hdr->{"mcp-protocol-version"} || "2025-03-26",
574 root 1.1 );
575     }
576    
577     sub _respond {
578 root 1.15 my ($self, $handle, $status, $reason, $body, $headers) = @_;
579 root 1.1
580     return if $handle->{mcp_closed};
581    
582 root 1.15 $body = "" unless defined $body;
583     my $content_type = "application/json";
584     my $extra = "";
585    
586     if ($headers) {
587     ref $headers eq "HASH" or die "HTTP response headers must be a hash\n";
588     while (my ($name, $value) = each %$headers) {
589     $name =~ /\A[!#\$%&'*+.^_`|~0-9A-Za-z-]+\z/
590     && defined $value && !ref $value && $value !~ /[\r\n\0]/
591     or die "Invalid HTTP response header\n";
592     $name !~ /\A(?:content-length|connection|transfer-encoding)\z/i
593     or die "HTTP response framing headers are managed by the server\n";
594     if (lc $name eq "content-type") {
595     $content_type = $value;
596     } else {
597     $extra .= "$name: $value\r\n";
598     }
599     }
600     }
601    
602 root 1.12 my $close = $handle->{mcp_close} || $status != 200 && $status != 202;
603    
604 root 1.1 $handle->push_write (
605     "HTTP/1.1 $status $reason\r\n"
606 root 1.15 . "Content-Type: $content_type\r\n"
607     . "Content-Length: " . (length $body) . "\r\n"
608     . $extra
609 root 1.1 . (defined $handle->{mcp_origin} ? "Access-Control-Allow-Origin: $handle->{mcp_origin}\r\n"
610     . ($handle->{mcp_origin} ne "*" ? "Vary: Origin\r\n" : "") : "")
611     . ($status == 204 ? "Access-Control-Allow-Methods: POST, OPTIONS\r\n"
612     . "Access-Control-Allow-Headers: content-type,authorization,mcp-protocol-version,mcp-method,mcp-name\r\n"
613     . "Access-Control-Max-Age: 86400\r\n" : "")
614     . ($status == 405 ? "Allow: POST" . (@{$self->{origins}} ? ", OPTIONS" : "") . "\r\n" : "")
615 root 1.6 . ($status == 401 && $self->{tokens} ? "WWW-Authenticate: Bearer\r\n" : "")
616 root 1.12 . ($close ? "Connection: close\r\n" : "")
617 root 1.1 . "\r\n$body"
618     );
619    
620 root 1.12 if ($close) {
621 root 1.1 weaken $self;
622     $handle->on_drain (sub { $self->_close ($_[0]) if $self });
623     } else {
624     $self->_read ($handle);
625     }
626     }
627    
628     sub _close {
629     my ($self, $handle) = @_;
630    
631     return if $handle->{mcp_closed};
632     $handle->{mcp_closed} = 1;
633    
634     my $requests = delete $handle->{mcp_peer}{requests} || {};
635     $_->cancel ("Connection closed") for grep defined, values %$requests;
636    
637     delete $self->{connections}{$handle};
638     $handle->destroy;
639     }
640    
641     sub close {
642     my ($self) = @_;
643    
644     return if $self->{_closed}++;
645     delete $self->{listener};
646     $self->_close ($_) for values %{$self->{connections} || {}};
647     $self->event (close =>);
648     }
649    
650     sub DESTROY {
651     $_[0]->close
652     }
653    
654     1