NAME
Net::Async::MCP::Transport::HTTP - Streamable HTTP MCP transport via Net::Async::HTTP
VERSION
version 0.004
SYNOPSIS
# Usually created automatically by Net::Async::MCP
use IO::Async::Loop;
use Net::Async::MCP;
my $loop = IO::Async::Loop->new;
my $mcp = Net::Async::MCP->new(
url => 'https://example.com/mcp',
);
$loop->add($mcp);
DESCRIPTION
Net::Async::MCP::Transport::HTTP communicates with a remote MCP server over HTTP using the Streamable HTTP transport defined in the MCP specification (2026-07-28). Requests are sent as HTTP POST with JSON-RPC bodies, and responses may arrive as either application/json or text/event-stream (Server-Sent Events).
There is no session to manage. The current revision is stateless: it dropped protocol sessions and the Mcp-Session-Id header entirely, and every request describes itself through its own _meta. A conforming server must ignore that header and never mint or echo a session ID, so this transport neither sends nor reads one.
The revision mirrors a request's metadata into HTTP headers so that intermediaries can route on it without parsing the body: MCP-Protocol-Version, Mcp-Method, and for the three methods with a name-ish parameter (tools/call, prompts/get, resources/read) also Mcp-Name. The body stays the truth; this transport derives the headers from it rather than from any state of its own, because a conforming server compares the two and rejects a missing or diverging header with -32020 (HEADER_MISMATCH).
The same holds for tool arguments annotated with x-mcp-header in a tool's input schema, which travel as Mcp-Param-{Name} alongside a tools/call. This transport does not go looking for them: which arguments are annotated follows from the tool's schema, so "call_tool" in Net::Async::MCP resolves them and hands the finished name/value pairs to "send_request", which encodes them like any other header. A server rejects a tools/call that passes an annotated argument without its header just as it rejects a header for an argument the call did not pass.
A response is read off the stream it arrives on rather than waited for at the end of it, which is what lets a subscription work at all: that request is answered by a notification and its stream then runs for as long as the subscription does. See "Subscriptions".
This transport is selected automatically by Net::Async::MCP when constructed with a url argument.
new
my $transport = Net::Async::MCP::Transport::HTTP->new(
url => 'https://example.com/mcp',
headers => { Authorization => "Bearer $token" },
);
Constructs a new HTTP transport. url is required and names the MCP endpoint every request is POSTed to. Usually not called directly: Net::Async::MCP builds this transport itself and passes the same arguments through, so a caller configures them there.
headers is a HashRef of headers added to every POST - the place for everything the protocol does not describe, an Authorization: Bearer ... for a server behind OAuth above all. They go on the request underneath the headers this transport derives from the body, so a caller can add its own but cannot replace MCP-Protocol-Version, Mcp-Method, Mcp-Name or an Mcp-Param-{Name}: a header that disagrees with the body is exactly what a conforming server answers with -32020. A colliding header is dropped rather than sent alongside the derived one, which would be the same divergence in another shape.
timeout and stall_timeout are handed to the underlying Net::Async::HTTP, in seconds. stall_timeout defaults to 60 and is the only one with a default: it fires when a request spends that long without a single byte moving in either direction, which is the hung connection a client cannot otherwise notice, and it does not touch a request that is still making progress. timeout, the wall-clock limit on a whole request, deliberately has no default - an MCP tools/call may legitimately run for minutes, so a default here would break working setups rather than protect them, and only a caller that knows its own upper bound can pick one.
Pass stall_timeout as 0 (or undef) to switch the stall timeout off. timeout is off unless set, and has to stay undef to stay off: a timeout of 0 is a real limit of zero seconds that fails every request immediately.
send_request
my $future = $transport->send_request($method, \%params);
my $future = $transport->send_request($method, \%params,
header_params => [ { name => 'Region', value => 'europe-west1' } ]);
my $future = $transport->send_request('subscriptions/listen', \%params,
resolve_on_notification => 'notifications/subscriptions/acknowledged');
Sends a JSON-RPC request as an HTTP POST to the MCP endpoint. The request includes Accept: application/json, text/event-stream to support both direct JSON responses and SSE streams, plus the metadata headers derived from the body as described above.
Optional trailing name/value options carry hints the body cannot express.
header_params is an ArrayRef of hashrefs with name and value, one per tool argument annotated with x-mcp-header, which this transport sends as Mcp-Param-{Name}. The value arrives formatted the way the server compares it - "call_tool" in Net::Async::MCP resolves it from the tool's input schema - and this transport only encodes it for the wire.
resolve_on_notification names the notification method that answers this request in place of a response, which is how a subscriptions/listen is answered and the only request that works this way today. The client says so rather than this transport working it out from the method it was handed: which requests are answered by a notification is MCP semantics, and a transport deciding for itself would be guessing where it can be told. See "Subscriptions" for the whole shape.
Returns a Future that resolves to the result value from the JSON-RPC response. Handles both application/json and text/event-stream response content types.
An application/json answer is read whole, as there is nothing to read before it is complete. A text/event-stream is read as it arrives instead, because a server may send notifications/progress and notifications/message on the stream of a long running request before the response it answers with: an event carrying no id is such a notification and is delivered to "on_notification" the moment it lands, an event with an id and a result or error is the response and settles the Future. A stream that ends without one fails it with MCP HTTP no JSON-RPC response in SSE stream.
The response settles the Future where it stands in the stream, not where the stream ends. Nothing obliges a server to close the stream behind its response, and one that holds it open would otherwise hold the caller until the stall timeout gave up on a stream with nothing left to say. What the stream carries afterwards is delivered to "on_notification" as before, and the end of it changes nothing about an answer already given.
If the server answers with a non-2xx status, a JSON-RPC error in the body wins over the status: MCP servers render errors such as METHOD_NOT_FOUND with a 404 and a rejected _meta with a 400, so the future fails with that MCP error $code: $message. A non-2xx without a JSON-RPC error body fails with the HTTP status line.
An error member that is not a JSON-RPC error object - a bare string, as MCP::Server's own HTTP transport renders its refusals, or whatever shape a gateway in between invents - fails the Future as well, carrying the text the body held.
A JSON-RPC error fails the Future with more than its message, wherever in the body or the stream it was found. Future's failure convention is ( $message, $category, @details ), so the failure reads ( "MCP error $code: $message", 'mcp', $error ): in scalar context ->failure is the message and nothing has changed, and in list context the raw JSON-RPC error object comes with it.
my ( $message, $category, $error ) = $future->failure;
if (($category // '') eq 'mcp') {
my $code = $error->{code}; # -32601, -32602, ...
my $supported = $error->{data}{supported};
}
The mcp category marks a genuine JSON-RPC error from the server and nothing else. The failures around it - the HTTP status line, an unreadable or unexpected body, the foreign error shape above, and a stream that ended without a response - carry their message alone, so a caller that finds no category knows there is no server error object behind it.
Cancelling the returned Future cancels the request by closing the stream it would have been answered on, which is what cancellation is over Streamable HTTP: this binding defines no notifications/cancelled, so nothing is sent - see "send_request" in Net::Async::MCP::Transport::Stdio for the other form.
Removing the transport from its IO::Async::Loop while the request is still pending fails it with MCP HTTP transport left the loop before the request was answered and closes its stream. The HTTP client leaves the loop with the transport that owns it, so no answer can reach this client any more, and a Future that would wait for one forever is better ended with the reason it will not arrive.
Subscriptions
A subscriptions/listen is the one request a server never answers with a JSON-RPC response. It opens an event stream, writes notifications/subscriptions/acknowledged as the first message on it, and then holds the stream open to carry the notifications that were subscribed to for as long as the subscription lasts. A client waiting for a response waits for something the specification does not have the server send.
So the request names what does answer it:
my $params = await $transport->send_request('subscriptions/listen',
{ notifications => { toolsListChanged => 1 } },
resolve_on_notification => 'notifications/subscriptions/acknowledged');
my $id = $params->{_meta}{'io.modelcontextprotocol/subscriptionId'};
The Future resolves with the acknowledgement's params: the subscription id in _meta, and under notifications the types the server actually honoured, which is not necessarily everything that was asked for. The acknowledgement is the answer and so is not also delivered to "on_notification"; everything the stream carries after it is.
The stream then stays open and this transport holds it, filed under the subscription id. "stop_subscription" ends it, and so does "close" for every one still running. Two subscriptions run on two streams and are stopped one at a time.
Three things go wrong loudly rather than quietly:
A stream that ends before the acknowledgement fails the Future with
MCP HTTP stream ended before the subscription was acknowledgedrather than with the missing-response message, which would name a message the server was never going to send.An acknowledgement without a subscription id fails it with
MCP HTTP subscription acknowledged without a subscription idand closes the stream. Such a subscription could be neither stopped nor closed, so handing it over would hand over a stream with no way of ending it.A server that refuses the method answers with a JSON-RPC error rather than a stream, and that fails the Future like any other error.
resolve_on_notificationsays what settles the request when the stream carries it, not that nothing else can.
What is not reported is the reason a subscription ended. The stream can end in more than one way - the server closing it, the connection failing underneath it - and they all end the subscription with them, reported alike through "on_subscription_end". "stop_subscription" is the one end this transport causes itself, and that one is not reported at all.
send_notification
my $future = $transport->send_notification($method, \%params);
Sends a JSON-RPC notification (no id field, no response expected) as an HTTP POST. The server typically responds with HTTP 202 Accepted. Returns a Future that resolves once the HTTP request completes with a 2xx status, whether or not it carries a body: a notification has no answer this client would read.
A non-2xx status fails the returned Future, with the same precedence as on the request path: a JSON-RPC error in the body wins over the status, and only a body without one falls back to the HTTP status line. Such an error carries the mcp category and the raw error object like any other, as described under "send_request".
The revision defines no header requirements for notification POSTs, so a notification carries whatever its body supports and nothing more: always Mcp-Method, and MCP-Protocol-Version only when the notification has an _meta to take it from.
close
my $future = $transport->close;
Ends every subscription still running and returns an immediately resolved Future.
Nothing is sent. There is no session to terminate, since the current revision is stateless and each request stands on its own, and a server on this revision answers DELETE on the MCP endpoint with 405 Method Not Allowed. What there is to end are the streams "send_request" opened for a subscription: those are the one thing this transport holds that outlives the request that started it, and closing the stream is what unsubscribes.
Requests still in flight are left alone. Their caller holds a Future and is waiting for an answer that may well still arrive, and a close is not a reason to take it away.
stop_subscription
my $stopped = $transport->stop_subscription($subscription_id);
Ends the subscription of that id by closing the stream it runs on, and returns true if there was one to end. Closing the stream is what unsubscribing is in this revision: there is no request that cancels a subscription, and no acknowledgement of one - a server drops the subscription when its stream finishes.
The $subscription_id is the one the acknowledgement carried, which "send_request" hands back as part of the value a subscription request resolves with:
my $params = await $transport->send_request('subscriptions/listen',
{ notifications => { toolsListChanged => 1 } },
resolve_on_notification => 'notifications/subscriptions/acknowledged');
my $id = $params->{_meta}{'io.modelcontextprotocol/subscriptionId'};
Returns false for an id this transport is not running a subscription under, which is the same answer an id that already ended gets: a subscription is forgotten as soon as its stream is over, however it ended. That makes this the way to ask whether one is still running. The prompt way is "on_subscription_end", which fires the moment a stream ends on its own - the server closing it, the connection failing - because the request's Future was settled by the acknowledgement long before and cannot report it. A stop this transport caused itself, through this method or "close", is not an end on_subscription_end reports.
is_alive
my $alive = $transport->is_alive;
Always true: the transport holds no connection between requests, so a dead endpoint only shows up when a request is actually made. Used by "ping" in Net::Async::MCP for its transport-level liveness check.
mirrors_header_params
my $mirrors = $transport->mirrors_header_params;
Always true: this binding mirrors tool arguments annotated with x-mcp-header into Mcp-Param-{Name} headers, so "call_tool" in Net::Async::MCP has to resolve them from the tool's input schema before calling "send_request" - and is worth fetching a tool list for when it does not know the schema yet. The other transports answer false and are spared that request.
on_notification
my $transport = Net::Async::MCP::Transport::HTTP->new(
url => 'https://example.com/mcp',
on_notification => sub {
my ( $transport, $notification ) = @_;
warn "$notification->{method}\n";
},
);
Invoked for every server-initiated notification that arrives on the response stream of a request, with the decoded JSON-RPC notification as it stood on the wire - method and, where the notification has any, params. The notifications/progress of a running tools/call is what a caller usually waits for here, and it is only worth anything while the call is still running, which is why it is an event and not part of the Future the call resolves with.
Set through new or configure like any IO::Async::Notifier event, or by a subclass implementing a method of this name. Notifications are dropped while nothing handles them: a server sends them whether or not this client asked, and there is nothing sensible to do with one no caller wants.
on_subscription_end
my $transport = Net::Async::MCP::Transport::HTTP->new(
url => 'https://example.com/mcp',
on_subscription_end => sub {
my ( $transport, $subscription_id ) = @_;
},
);
Invoked when a subscription's stream ends on its own, with the subscription id as its second argument - the same id the acknowledgement handed back and "stop_subscription" takes. That is the one handle a caller has on a running subscription, so it is also the one thing worth telling it that ended.
A subscription is answered by its acknowledgement, and the Future of the request that opened it is settled there and then; the stream then runs for as long as the subscription does. So the only way a subscription can come to the caller's attention again is its end - and there are two kinds. An end this transport causes itself, through "stop_subscription" or "close", happens because the caller asked for it and is not reported. An end that comes from the server's side - the server closing the stream, the connection failing underneath it, a gateway giving up on it - reaches a caller holding nothing but an already-settled Future, and that is the end this event reports, the moment the stream ends. The two are told apart by whether the subscription was still registered when its stream ended; "stop_subscription" and "close" deregister before they close, so only an end that came from outside arrives with it standing.
Set through new or configure like any IO::Async::Notifier event, or by a subclass implementing a method of this name. This is the only transport that can fire it: it is the only one with a stream a subscription runs on - the InProcess and Stdio transports cannot carry a subscription at all.
SEE ALSO
Net::Async::MCP - Main client module that uses this transport
Net::Async::MCP::Transport::InProcess - Alternative transport for in-process Perl servers
Net::Async::MCP::Transport::Stdio - Alternative transport for external subprocesses
Net::Async::HTTP - HTTP client used internally
https://modelcontextprotocol.io/specification/2026-07-28/basic/transports - MCP Streamable HTTP transport specification
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-net-async-mcp/issues.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.