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 (3 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

# Content
1 =head1 NAME
2
3 AnyEvent::MCP::Server::HTTP - an optional MCP HTTP listener and socket handler
4
5 =head1 DESCRIPTION
6
7 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.
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
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
25 C<Mcp-Param-*> headers from C<x-mcp-header> tool schema annotations are not
26 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
31 =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 =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 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
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 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 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 =head1 METHODS
94
95 =over
96
97 =item $server = AnyEvent::MCP::Server::HTTP->new (%config)
98
99 Construction does not start a listener. In addition to Server options,
100 the following configuration options are supported:
101
102 =over
103
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 The MCP endpoint path, defaulting to C</mcp/>. Bearer authentication uses
115 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
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 Limits incoming headers and request bodies, defaulting to 8 MiB.
129
130 =item origins => [$origin...]
131
132 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 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 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
148 =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 =item tokens => [$token, ...]
162
163 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 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
174 =item name => $name
175
176 Identifies the service. Use L<AnyEvent::MCP::Config/http_settings>
177 to resolve named listener configuration before constructing this transport.
178
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 Overrides the advertised scheme; otherwise TLS credentials or handle options
187 select C<https>, and plain handles select C<http>.
188
189 =back
190
191 Every configured token authenticates in either the URL or the Authorization
192 header. Without tokens, the constructor requires C<auth =E<gt> "none">.
193
194 =item $url = $server->url ($with_token)
195
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
200 The port must be known. Wildcard hostname resolution and reverse-proxy
201 routing remain the caller's responsibility.
202
203 =item $server->listen ($host, $port, %handle)
204
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 =item $handle = $server->accept ($socket, %handle)
213
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 =item $server->close
220
221 Stops listening, closes accepted sockets and cancels outstanding requests.
222 The same object can subsequently listen or accept again.
223
224 =back
225
226 =head1 EVENTS
227
228 =over
229
230 =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 =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 =item on_connection_error => $callback->($server, $handle, $message)
263
264 Called for socket errors before closing the connection.
265
266 =item on_close => $callback->($server)
267
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 =back
272
273 =cut
274
275 package AnyEvent::MCP::Server::HTTP;
276
277 use common::sense;
278 use parent "AnyEvent::MCP::Server";
279 use AnyEvent::Log ();
280 use AnyEvent::Handle ();
281 use AnyEvent::Socket ();
282 use JSON::XS ();
283 use MIME::Base64 ();
284 use Scalar::Util qw(weaken);
285 use POSIX ();
286
287 sub new {
288 my ($class, %kv) = @_;
289
290 !grep (exists $kv{$_}, qw(cookie bearer users))
291 or die "Legacy HTTP authentication options are unsupported; use tokens\n";
292
293 $kv{auth} //= "tokens";
294 if ($kv{auth} eq "tokens") {
295 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 } 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 }
305
306 $class->SUPER::new (host => "127.0.0.1", port => 0, path => "/mcp/", origins => ["*"], %kv)
307 }
308
309 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 sub listen {
324 my ($self, $host, $port, %handle) = @_;
325
326 $self->_tls_context;
327 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 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 }, sub {
343 $self->{port} = $_[2] if $self;
344 ()
345 };
346 }
347
348 sub _token_path {
349 my ($self, $token) = @_;
350
351 "/$token$self->{path}"
352 }
353
354 sub url {
355 my ($self, $with_token) = @_;
356
357 $with_token = 1 unless defined $with_token;
358
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 $host =~ s/\A\[(.*)\]\z/$1/; # allow already bracketed IPv6 URL hosts
363
364 my $port = $self->{port} or die "HTTP port is not known\n";
365 my $scheme = $self->{url_scheme}
366 || (defined $self->{tls_cert} || defined $self->{tls_key}
367 || ($self->{listen_handle} || $self->{handle} || {})->{tls} ? "https" : "http");
368 my $path = $with_token && $self->{tokens} && @{$self->{tokens}}
369 ? $self->_token_path ($self->{tokens}[0]) : $self->{path};
370
371 "$scheme://" . AnyEvent::Socket::format_hostport ($host, $port) . $path
372 }
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 rbuf_max => $self->{max_size} || 8 * 1024 * 1024,
382 rtimeout => 15,
383 %{$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 $handle->{mcp_read_timeout} = $handle->{rtimeout};
395 $handle->{mcp_peer} = {};
396 $self->{connections}{$handle} = $handle;
397 $handle->push_read (tls_autostart => $self->_tls_context, "accept")
398 if (defined $self->{tls_cert} || defined $self->{tls_key}) && !$handle->{tls};
399
400 if ($self->{on_extension}) {
401 $handle->on_read (sub {
402 my ($handle) = @_;
403
404 $handle->on_read (undef);
405 return unless $self;
406
407 if (substr ($handle->{rbuf}, 0, 1) eq "[") {
408 require AnyEvent::MCP::Extension;
409 AnyEvent::MCP::Extension::accept ($handle, $self->{connect_tokens} || $self->{tokens} || [], sub {
410 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 $self->_read ($handle, 1);
417 }
418 });
419 } else {
420 $self->_read ($handle);
421 }
422
423 $handle
424 }
425
426 # parse a request line and HTTP fields, using the same comma-combining
427 # 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 # discard the prefix used for comma-combining repeated field values.
448 substr $_, 0, 1, "" for values %hdr;
449 s/[\t ]+\z// for values %hdr;
450
451 # request-line fields override any colliding HTTP header names.
452 +{ %hdr, method => $method, uri => $uri, http_version => $version }
453 }
454
455 # 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 sub _read {
475 my ($self, $handle, $immediate) = @_;
476
477 weaken $self;
478
479 $handle->rtimeout_reset;
480 $handle->rtimeout ($handle->{mcp_read_timeout});
481 if ($immediate) {
482 $handle->push_read (line => "\r\n\r\n", sub {
483 my ($handle, $header) = @_;
484
485 return unless $self;
486
487 my $hdr = _parse_request_headers ($header);
488 $handle->{mcp_method} = $hdr ? substr ($hdr->{method}, 0, 32) : "-";
489 $handle->{mcp_uri} = $hdr ? $self->_log_uri ($hdr->{uri}) : undef;
490 $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
508 $handle->push_write ("HTTP/1.1 100 Continue\r\n\r\n");
509 }
510
511 $handle->push_read (chunk => $hdr->{"content-length"}, sub {
512 my ($handle, $body) = @_;
513
514 $handle->rtimeout (0);
515 $self->_request ($handle, $hdr, $body) if $self;
516 });
517 });
518 } else {
519 $handle->on_read (sub {
520 my ($handle) = @_;
521 $handle->on_read (undef);
522 $self->_read ($handle, 1) if $self;
523 });
524 }
525 }
526
527 sub _request {
528 my ($self, $handle, $hdr, $body) = @_;
529
530 delete $handle->{mcp_origin};
531
532 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 # 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 # some MCP clients strip a trailing slash from the configured URL.
549 my $uri = $hdr->{uri};
550 $uri .= "/" if $self->{path} =~ m{/$} && $uri !~ m{/$};
551 my ($path_token) = $self->{tokens}
552 ? grep $uri eq $self->_token_path ($_), @{$self->{tokens}} : ();
553 my $token_path = defined $path_token;
554
555 return $self->_respond ($handle, 404, "Not Found", "")
556 unless $uri eq $self->{path} || $token_path;
557
558 if (exists $hdr->{origin}) {
559 my $wildcard = grep $_ eq "*", @{$self->{origins}};
560
561 return $self->_respond ($handle, 403, "Forbidden", "")
562 unless $wildcard || grep $_ eq $hdr->{origin}, @{$self->{origins}};
563
564 $handle->{mcp_origin} = $wildcard ? "*" : $hdr->{origin};
565 }
566
567 if ($hdr->{method} eq "OPTIONS" && @{$self->{origins}}) {
568 return $self->_respond ($handle, 405, "Method Not Allowed", "")
569 if exists $hdr->{"access-control-request-method"}
570 && $hdr->{"access-control-request-method"} ne "POST";
571
572 return $self->_respond ($handle, 204, "No Content", "");
573 }
574
575 return $self->_respond ($handle, 405, "Method Not Allowed", "")
576 unless $hdr->{method} eq "POST";
577
578 my $authenticated = $path_token;
579 if ($self->{tokens}) {
580 my $authorization = $hdr->{authorization};
581
582 if ($authorization =~ /\ABearer ([^\s]+)\z/i) {
583 my $token = $1;
584 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 }
590
591 return $self->_respond ($handle, 401, "Unauthorized", "")
592 unless defined $authenticated;
593 }
594
595 return $self->_respond ($handle, 415, "Unsupported Media Type", "")
596 unless $hdr->{"content-type"} =~ m{\Aapplication/json(?:\s*;|\z)}i;
597
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 || $hdr->{"mcp-protocol-version"} ge "2026";
610
611 if ($modern && ref $message eq "HASH" && exists $message->{id}) {
612 my %expected = (
613 "mcp-protocol-version" => $meta->{"io.modelcontextprotocol/protocolVersion"},
614 "mcp-method" => $message->{method},
615 );
616
617 $expected{"mcp-name"} = exists $params->{name} ? $params->{name} : $params->{uri}
618 if $message->{method} =~ /\A(?:tools\/call|prompts\/get|resources\/read)\z/;
619
620 for my $name (keys %expected) {
621 my $value = $hdr->{$name};
622
623 if ($name eq "mcp-name" && $value =~ /\A=\?base64\?([A-Za-z0-9+\/=]*)\?=\z/) {
624 $value = eval { AnyEvent::MCP::utf8dec (MIME::Base64::decode_base64 ($1)) };
625 }
626
627 unless (defined $value && defined $expected{$name} && $value eq $expected{$name}) {
628 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 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 $self->rpc ($message, sub {
661 my ($response) = @_;
662
663 return unless $self && !$handle->{mcp_closed};
664
665 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 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 peer => $handle->{mcp_peer},
682 user => defined $authenticated ? $self->{token_users}{$authenticated} : undef,
683 protocol_version => $hdr->{"mcp-protocol-version"} || "2025-03-26",
684 (($hdr->{accept} || "") =~ m{\btext/event-stream\b}i
685 ? (on_notify => sub { $sse->($_[1]) }) : ()),
686 );
687 }
688
689 sub _respond {
690 my ($self, $handle, $status, $reason, $body, $headers) = @_;
691
692 return if $handle->{mcp_closed};
693
694 $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 if ($status >= 400) {
715 my $level = $status == 404 ? "trace" : "warn";
716 AE::log $level => "MCP HTTP"
717 . (defined $handle->{mcp_remote} ? " remote=$handle->{mcp_remote}" : "")
718 . ": " . ($handle->{mcp_method} // "-") . " $status"
719 . (defined $handle->{mcp_uri} ? " uri=$handle->{mcp_uri}" : "");
720 }
721
722 my $close = $handle->{mcp_close} || $status != 200 && $status != 202;
723
724 $handle->push_write (
725 "HTTP/1.1 $status $reason\r\n"
726 . "Content-Type: $content_type\r\n"
727 . "Content-Length: " . (length $body) . "\r\n"
728 . $extra
729 . (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 . ($status == 401 && $self->{tokens} ? "WWW-Authenticate: Bearer\r\n" : "")
736 . ($close ? "Connection: close\r\n" : "")
737 . "\r\n$body"
738 );
739
740 if ($close) {
741 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