ViewVC Help
View File | Revision Log | Show Annotations | Download File
/cvs/AnyEvent-MCP/lib/AnyEvent/MCP/Server/HTTP.pm
Revision: 1.17
Committed: Thu Oct 22 09:33:07 2026 UTC (6 days, 5 hours ago) by root
Branch: MAIN
CVS Tags: rel-0_62, rel-0_65, HEAD
Changes since 1.16: +11 -4 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     Limits incoming headers and request bodies, defaulting to 1 MiB.
125    
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     $self->accept ($_[0], %handle) if $self;
301     }, sub {
302     $self->{port} = $_[2] if $self;
303     ()
304     };
305     }
306    
307 root 1.6 sub _token_path {
308     my ($self, $token) = @_;
309 root 1.12
310 root 1.6 "/$token$self->{path}"
311 root 1.1 }
312    
313     sub url {
314 root 1.12 my ($self, $with_token) = @_;
315 root 1.1
316 root 1.12 $with_token = 1 unless defined $with_token;
317 root 1.1
318     my $host = $self->{url_host}
319     || (defined $self->{listen_host} ? $self->{listen_host} : $self->{host});
320     $host = (POSIX::uname)[1] if !length $host || $host eq "0.0.0.0" || $host eq "::";
321 root 1.9 $host =~ s/\A\[(.*)\]\z/$1/; # allow already bracketed IPv6 URL hosts
322 root 1.1
323     my $port = $self->{port} or die "HTTP port is not known\n";
324     my $scheme = $self->{url_scheme}
325     || (($self->{listen_handle} || $self->{handle} || {})->{tls} ? "https" : "http");
326 root 1.12 my $path = $with_token && $self->{tokens} && @{$self->{tokens}}
327 root 1.6 ? $self->_token_path ($self->{tokens}[0]) : $self->{path};
328 root 1.1
329 root 1.9 "$scheme://" . AnyEvent::Socket::format_hostport ($host, $port) . $path
330 root 1.1 }
331    
332     sub accept {
333     my ($self, $socket, %handle) = @_;
334    
335     delete $self->{_closed};
336     weaken $self;
337    
338     my $handle = AnyEvent::Handle->new (
339     rbuf_max => $self->{max_size} || 1024 * 1024,
340     %{$self->{handle}},
341     %handle,
342     fh => $socket,
343     on_read => undef,
344     on_error => sub {
345     $self->event (connection_error => $_[0], $_[2]) if $self;
346     $self->_close ($_[0]) if $self;
347     },
348     on_eof => sub { $self->_close ($_[0]) if $self },
349     );
350    
351     $handle->{mcp_peer} = {};
352     $self->{connections}{$handle} = $handle;
353 root 1.14 if ($self->{on_extension}) {
354     $handle->rtimeout (15);
355     $handle->push_read (chunk => 1, sub {
356     my ($handle, $first) = @_;
357     return unless $self;
358     $handle->{rbuf} = $first . $handle->{rbuf};
359    
360     if ($first eq "[") {
361     require AnyEvent::MCP::Extension;
362     AnyEvent::MCP::Extension::accept ($handle, $self->{tokens} || [], sub {
363     my ($handle, $hello) = @_;
364     return $handle->destroy unless $self;
365     delete $self->{connections}{$handle};
366     $self->{on_extension}->($self, $handle, $hello);
367     }, sub { $self->_close ($_[0]) if $self });
368     } else {
369     $handle->rtimeout (0);
370     $self->_read ($handle);
371     }
372     });
373     } else {
374     $self->_read ($handle);
375     }
376 root 1.1
377     $handle
378     }
379    
380 root 1.12 # parse a request line and HTTP fields, using the same comma-combining
381 root 1.10 # approach as AnyEvent::HTTP::_parse_hdr. The caller already bounds headers.
382     sub _parse_request_headers {
383     local $_ = $_[0] . "\r\n";
384     /\A([!#$%&'*+.^_\x60|~0-9A-Za-z-]+) [ ]+
385     ([^\x00-\x20]+) [ ]+ HTTP\/0*([0-9.]+)\r\n/gcix
386     or return;
387     my ($method, $uri, $version) = ($1, $2, $3);
388     return unless $version =~ /\A1\.[0-9]+\z/;
389     my %hdr;
390    
391     $hdr{lc $1} .= ",$2"
392     while /\G
393     ([!\#$%&'*+.^_\x60|~0-9A-Za-z-]+):
394     [\t ]*
395     ([^\x00-\x08\x0a-\x1f\x7f]*)
396     \r\n
397     /gcx;
398    
399     /\G\z/gc or return;
400    
401 root 1.12 # discard the prefix used for comma-combining repeated field values.
402 root 1.10 substr $_, 0, 1, "" for values %hdr;
403     s/[\t ]+\z// for values %hdr;
404    
405 root 1.12 # request-line fields override any colliding HTTP header names.
406 root 1.11 +{ %hdr, method => $method, uri => $uri, http_version => $version }
407 root 1.10 }
408    
409 root 1.1 sub _read {
410     my ($self, $handle) = @_;
411    
412     weaken $self;
413    
414     $handle->push_read (line => "\r\n\r\n", sub {
415     my ($handle, $header) = @_;
416 root 1.12
417 root 1.1 return unless $self;
418    
419 root 1.11 my $hdr = _parse_request_headers ($header);
420 root 1.1 $handle->{mcp_close} = $self->{connection_close}
421 root 1.11 || !$hdr || $hdr->{http_version} =~ m{\A1\.0+\z}
422     || $hdr->{connection} =~ /\bclose\b/i;
423 root 1.1
424 root 1.11 return $self->_respond ($handle, 400, "Bad Request", "") unless $hdr;
425 root 1.1
426 root 1.11 $hdr->{"content-length"} = 0 unless defined $hdr->{"content-length"};
427 root 1.1 return $self->_respond ($handle, 400, "Bad Request", "")
428 root 1.11 if exists $hdr->{"transfer-encoding"}
429     || $hdr->{"content-length"} !~ /\A[0-9]+\z/;
430 root 1.1
431     return $self->_respond ($handle, 413, "Content Too Large", "")
432 root 1.11 if $hdr->{"content-length"} > ($self->{max_size} || 1024 * 1024);
433 root 1.1
434 root 1.11 if ($hdr->{expect}) {
435 root 1.1 return $self->_respond ($handle, 417, "Expectation Failed", "")
436 root 1.11 unless lc $hdr->{expect} eq "100-continue";
437 root 1.1
438     $handle->push_write ("HTTP/1.1 100 Continue\r\n\r\n");
439     }
440    
441 root 1.11 $handle->push_read (chunk => $hdr->{"content-length"}, sub {
442 root 1.1 my ($handle, $body) = @_;
443    
444 root 1.11 $self->_request ($handle, $hdr, $body) if $self;
445 root 1.1 });
446     });
447     }
448    
449     sub _request {
450 root 1.11 my ($self, $handle, $hdr, $body) = @_;
451 root 1.1
452     delete $handle->{mcp_origin};
453    
454 root 1.15 if (my $hook = $self->{on_http_request}) {
455     weaken $self;
456     my $replied;
457     my $reply = sub {
458     return if $replied++ || !$self || $handle->{mcp_closed};
459     my ($status, $reason, $headers, $content) = @_;
460     $self->_respond ($handle, $status, $reason, $content, $headers);
461     };
462    
463     return if $hook->($self, { %$hdr, body => $body }, $reply);
464     }
465    
466 root 1.17 # Some MCP clients strip a trailing slash from the configured URL.
467     my $uri = $hdr->{uri};
468     $uri .= "/" if $self->{path} =~ m{/$} && $uri !~ m{/$};
469 root 1.6 my $token_path = $self->{tokens}
470 root 1.17 && grep $uri eq $self->_token_path ($_), @{$self->{tokens}};
471 root 1.1
472     return $self->_respond ($handle, 404, "Not Found", "")
473 root 1.17 unless $uri eq $self->{path} || $token_path;
474 root 1.1
475 root 1.11 if (exists $hdr->{origin}) {
476 root 1.1 my $wildcard = grep $_ eq "*", @{$self->{origins}};
477    
478     return $self->_respond ($handle, 403, "Forbidden", "")
479 root 1.11 unless $wildcard || grep $_ eq $hdr->{origin}, @{$self->{origins}};
480 root 1.1
481 root 1.11 $handle->{mcp_origin} = $wildcard ? "*" : $hdr->{origin};
482 root 1.1 }
483    
484 root 1.11 if ($hdr->{method} eq "OPTIONS" && @{$self->{origins}}) {
485 root 1.1 return $self->_respond ($handle, 405, "Method Not Allowed", "")
486 root 1.11 if exists $hdr->{"access-control-request-method"}
487     && $hdr->{"access-control-request-method"} ne "POST";
488 root 1.1
489     return $self->_respond ($handle, 204, "No Content", "");
490     }
491    
492     return $self->_respond ($handle, 405, "Method Not Allowed", "")
493 root 1.11 unless $hdr->{method} eq "POST";
494 root 1.1
495 root 1.6 if ($self->{tokens}) {
496     my $authenticated = $token_path;
497 root 1.11 my $authorization = $hdr->{authorization};
498 root 1.1
499 root 1.6 if ($authorization =~ /\ABearer ([^\s]+)\z/i) {
500     my $token = $1;
501     $authenticated ||= grep $_ eq $token, @{$self->{tokens}};
502 root 1.1 }
503    
504     return $self->_respond ($handle, 401, "Unauthorized", "")
505     unless $authenticated;
506     }
507    
508     return $self->_respond ($handle, 415, "Unsupported Media Type", "")
509 root 1.11 unless $hdr->{"content-type"} =~ m{\Aapplication/json(?:\s*;|\z)}i;
510 root 1.1
511     my $message = eval { JSON::XS::decode_json $body };
512    
513     if ($@) {
514     return $self->_rpc_error (sub {
515     $self->_respond ($handle, 400, "Bad Request", JSON::XS::encode_json $_[0]);
516     }, undef, -32700, "Parse error");
517     }
518    
519     my $params = ref $message eq "HASH" && ref $message->{params} eq "HASH" ? $message->{params} : {};
520     my $meta = ref $params->{_meta} eq "HASH" ? $params->{_meta} : {};
521     my $modern = exists $meta->{"io.modelcontextprotocol/protocolVersion"}
522 root 1.11 || $hdr->{"mcp-protocol-version"} ge "2026";
523 root 1.1
524     if ($modern && ref $message eq "HASH" && exists $message->{id}) {
525 root 1.11 my %expected = (
526     "mcp-protocol-version" => $meta->{"io.modelcontextprotocol/protocolVersion"},
527     "mcp-method" => $message->{method},
528 root 1.1 );
529    
530 root 1.11 $expected{"mcp-name"} = exists $params->{name} ? $params->{name} : $params->{uri}
531 root 1.1 if $message->{method} =~ /\A(?:tools\/call|prompts\/get|resources\/read)\z/;
532    
533 root 1.11 for my $name (keys %expected) {
534     my $value = $hdr->{$name};
535 root 1.1
536 root 1.11 if ($name eq "mcp-name" && $value =~ /\A=\?base64\?([A-Za-z0-9+\/=]*)\?=\z/) {
537 root 1.2 $value = eval { AnyEvent::MCP::utf8dec (MIME::Base64::decode_base64 ($1)) };
538 root 1.1 }
539    
540 root 1.11 unless (defined $value && defined $expected{$name} && $value eq $expected{$name}) {
541 root 1.1 return $self->_rpc_error (sub {
542     $self->_respond ($handle, 400, "Bad Request", JSON::XS::encode_json $_[0]);
543     }, $message->{id}, -32020, "Header mismatch: $name");
544     }
545     }
546     }
547    
548     weaken $self;
549    
550     $self->rpc ($message, sub {
551     my ($response) = @_;
552 root 1.12
553 root 1.1 return unless $self && !$handle->{mcp_closed};
554    
555     my ($status, $reason) = defined $response ? (200, "OK") : (202, "Accepted");
556    
557     if ($modern && defined $response && $response->{error}) {
558     ($status, $reason) = $response->{error}{code} == -32601
559     ? (404, "Not Found") : (400, "Bad Request");
560     }
561    
562     $self->_respond ($handle, $status, $reason, defined $response ? JSON::XS::encode_json $response : "");
563     },
564 root 1.12 peer => $handle->{mcp_peer},
565 root 1.11 protocol_version => $hdr->{"mcp-protocol-version"} || "2025-03-26",
566 root 1.1 );
567     }
568    
569     sub _respond {
570 root 1.15 my ($self, $handle, $status, $reason, $body, $headers) = @_;
571 root 1.1
572     return if $handle->{mcp_closed};
573    
574 root 1.15 $body = "" unless defined $body;
575     my $content_type = "application/json";
576     my $extra = "";
577    
578     if ($headers) {
579     ref $headers eq "HASH" or die "HTTP response headers must be a hash\n";
580     while (my ($name, $value) = each %$headers) {
581     $name =~ /\A[!#\$%&'*+.^_`|~0-9A-Za-z-]+\z/
582     && defined $value && !ref $value && $value !~ /[\r\n\0]/
583     or die "Invalid HTTP response header\n";
584     $name !~ /\A(?:content-length|connection|transfer-encoding)\z/i
585     or die "HTTP response framing headers are managed by the server\n";
586     if (lc $name eq "content-type") {
587     $content_type = $value;
588     } else {
589     $extra .= "$name: $value\r\n";
590     }
591     }
592     }
593    
594 root 1.12 my $close = $handle->{mcp_close} || $status != 200 && $status != 202;
595    
596 root 1.1 $handle->push_write (
597     "HTTP/1.1 $status $reason\r\n"
598 root 1.15 . "Content-Type: $content_type\r\n"
599     . "Content-Length: " . (length $body) . "\r\n"
600     . $extra
601 root 1.1 . (defined $handle->{mcp_origin} ? "Access-Control-Allow-Origin: $handle->{mcp_origin}\r\n"
602     . ($handle->{mcp_origin} ne "*" ? "Vary: Origin\r\n" : "") : "")
603     . ($status == 204 ? "Access-Control-Allow-Methods: POST, OPTIONS\r\n"
604     . "Access-Control-Allow-Headers: content-type,authorization,mcp-protocol-version,mcp-method,mcp-name\r\n"
605     . "Access-Control-Max-Age: 86400\r\n" : "")
606     . ($status == 405 ? "Allow: POST" . (@{$self->{origins}} ? ", OPTIONS" : "") . "\r\n" : "")
607 root 1.6 . ($status == 401 && $self->{tokens} ? "WWW-Authenticate: Bearer\r\n" : "")
608 root 1.12 . ($close ? "Connection: close\r\n" : "")
609 root 1.1 . "\r\n$body"
610     );
611    
612 root 1.12 if ($close) {
613 root 1.1 weaken $self;
614     $handle->on_drain (sub { $self->_close ($_[0]) if $self });
615     } else {
616     $self->_read ($handle);
617     }
618     }
619    
620     sub _close {
621     my ($self, $handle) = @_;
622    
623     return if $handle->{mcp_closed};
624     $handle->{mcp_closed} = 1;
625    
626     my $requests = delete $handle->{mcp_peer}{requests} || {};
627     $_->cancel ("Connection closed") for grep defined, values %$requests;
628    
629     delete $self->{connections}{$handle};
630     $handle->destroy;
631     }
632    
633     sub close {
634     my ($self) = @_;
635    
636     return if $self->{_closed}++;
637     delete $self->{listener};
638     $self->_close ($_) for values %{$self->{connections} || {}};
639     $self->event (close =>);
640     }
641    
642     sub DESTROY {
643     $_[0]->close
644     }
645    
646     1