=head1 NAME AnyEvent::MCP::Client::HTTP - MCP over AnyEvent::HTTP =head1 SYNOPSIS my $client = AnyEvent::MCP::Client::HTTP->new ( name => "example", url => $url, bearer => $token, timeout => 30, ); =head1 DESCRIPTION Use this transport to talk to an MCP server through HTTP. The caller supplies the endpoint and, if needed, credentials; the common Client layer handles discovery and tool calls. Unlike a browser, this client does not follow redirects or silently retry requests. A redirected request could disclose credentials to another host, and retrying a tool call could perform an operation twice. =head1 METHODS =head2 $client = AnyEvent::MCP::Client::HTTP->new (%config) C is the complete endpoint URL, including any URL cookie. C supplies an Authorization bearer token. Alternatively C and C supply Basic authentication. Configuring both is an error. No authentication options means the URL alone is used. C, when supplied, loads C, C, C, C and C defaults from C<[client NAME]> in the global configuration. Explicit arguments override them, including explicitly undefined credentials. Without a name no global configuration is accessed. Options are flat. C, C, C, C, C, C, C, C and C are AnyEvent::HTTP options. C defaults to 300 seconds of inactivity. Protocol and authentication headers are supplied by this module. Redirects and automatic HTTP retries are disabled, so calls and credentials are not replayed at a different endpoint. C bounds a JSON response or an individual buffered SSE event, defaulting to 16 MiB. SSE notifications are delivered incrementally rather than after the HTTP response ends. Legacy session IDs are retained from initialization. An expired session fails outstanding requests; a later operation can initialize again. Legacy GET streams and the old separate SSE endpoint transport are not implemented. =cut package AnyEvent::MCP::Client::HTTP; use common::sense; use parent "AnyEvent::MCP::Client"; use AnyEvent::HTTP (); use JSON::XS (); use MIME::Base64 (); use Scalar::Util qw(weaken); sub new { my ($class, %kv) = @_; if (defined $kv{name}) { require AnyEvent::MCP::Config; my $settings = AnyEvent::MCP::Config->global->{section}{"client $kv{name}"} || {}; %kv = ( (map { exists $settings->{$_} ? ($_ => $settings->{$_}) : () } qw(url bearer username password timeout)), %kv, ); } die "HTTP client requires a URL\n" unless defined $kv{url}; die "Bearer and Basic authentication cannot be combined\n" if defined $kv{bearer} && (defined $kv{username} || defined $kv{password}); die "Basic authentication requires username and password\n" if defined $kv{username} != defined $kv{password}; die "Invalid Basic username\n" if $kv{username} =~ /[:\r\n]/; die "Invalid bearer token\n" if defined $kv{bearer} && $kv{bearer} !~ /\A[^\s]+\z/; $class->SUPER::new (timeout => 300, %kv) } sub _send { my ($self, $message, $done) = @_; my $key = exists $message->{id} && !exists $message->{result} && !exists $message->{error} ? $message->{id} : "send:" . ++$self->{send_id}; my $generation = $self->{generation}; my %headers = ( %{$self->{headers} || {}}, "content-type" => "application/json", accept => "application/json, text/event-stream", ); $headers{authorization} = "Bearer $self->{bearer}" if defined $self->{bearer}; $headers{authorization} = "Basic " . MIME::Base64::encode_base64 ( AnyEvent::MCP::utf8enc ("$self->{username}:$self->{password}"), "" ) if defined $self->{username}; $headers{"mcp-protocol-version"} = $self->{protocol_version} if defined $self->{protocol_version}; $headers{"mcp-session-id"} = $self->{session} if $self->{protocol_version} lt "2026" && defined $self->{session}; if ($self->{protocol_version} ge "2026" && exists $message->{method} && exists $message->{id}) { $headers{"mcp-method"} = $message->{method}; if ($message->{method} =~ /\A(?:tools\/call|prompts\/get|resources\/read)\z/) { my $name = exists $message->{params}{name} ? $message->{params}{name} : $message->{params}{uri}; $headers{"mcp-name"} = $name =~ /[^\x20-\x7e]/ ? "=?base64?" . MIME::Base64::encode_base64 (AnyEvent::MCP::utf8enc ($name), "") . "?=" : $name; } } my ($buffer, $sse, $received, $failure) = ("", 0, 0); my $limit = $self->{max_size} || 16 * 1024 * 1024; weaken $self; my $deliver = sub { my ($data) = @_; my $response = JSON::XS::decode_json $data; if (ref $response eq "HASH" && !exists $response->{method}) { die "HTTP response id mismatch\n" unless exists $message->{id} && defined $response->{id} && "$response->{id}" eq "$message->{id}"; $received = 1; } $self->_receive ($response); }; my $guard; my $ok = eval { $guard = AnyEvent::HTTP::http_request POST => $self->{url}, (map { exists $self->{$_} ? ($_ => $self->{$_}) : () } qw(timeout proxy tls_ctx handle_params sessionid on_prepare on_connect tcp_connect)), headers => \%headers, body => JSON::XS::encode_json ($message), recurse => 0, persistent => 0, on_header => sub { my ($hdr) = @_; return 0 unless $self && $self->{generation} == $generation; $sse = $hdr->{"content-type"} =~ m{\Atext/event-stream(?:\s*;|\z)}i; if ($message->{method} eq "initialize" && $hdr->{Status} >= 200 && $hdr->{Status} < 300 && defined $hdr->{"mcp-session-id"}) { $self->{session} = $hdr->{"mcp-session-id"}; } 1 }, on_body => sub { my ($chunk, $hdr) = @_; return 0 unless $self && $self->{generation} == $generation; $buffer .= $chunk; my $ok = eval { if ($sse) { while ($buffer =~ s/\A(.*?)(?:\r\n\r\n|\n\n|\r\r)//s) { my $event = $1; die "SSE event exceeds size limit\n" if length $event > $limit; my @data = $event =~ /^data:[ \t]?(.*?)(?:\r?$)/mg; $deliver->(join "\n", @data) if @data; } } die "HTTP response exceeds size limit\n" if length $buffer > $limit; 1 }; $failure = $@ unless $ok; $ok }, sub { my (undef, $hdr) = @_; return unless $self && $self->{generation} == $generation; delete $self->{http_requests}{$key}; if ($hdr->{Status} == 404 && defined $self->{session} && !$received) { return $self->close ("MCP session expired") } my $ok = eval { die $failure if $failure; if (!$sse && length $buffer) { my $response = eval { JSON::XS::decode_json $buffer }; if (ref $response eq "HASH" && ($hdr->{Status} >= 200 && $hdr->{Status} < 300 || ref $response->{error} eq "HASH")) { $deliver->($buffer); } else { die "HTTP $hdr->{Status} $hdr->{Reason}\n" } } die "HTTP $hdr->{Status} $hdr->{Reason}\n" unless $hdr->{Status} >= 200 && $hdr->{Status} < 300; die "HTTP response has no matching JSON-RPC reply\n" if exists $message->{id} && exists $message->{method} && !$received; 1 }; $done->($ok ? (1, undef) : (0, { transport => 1, message => "$@" })); }; 1 }; if ($ok) { $self->{http_requests}{$key} = $guard if $self && $guard; } else { $done->(0, { transport => 1, message => "$@" }); } } sub _abort { my ($self, $id) = @_; delete $self->{http_requests}{$id}; } sub _shutdown { my ($self) = @_; delete @$self{qw(http_requests session)}; } 1