NAME
Net::Async::MCP::Transport::HTTP - Streamable HTTP MCP transport via Net::Async::HTTP
VERSION
version 0.007
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).
It speaks the binding of both protocol eras Net::Async::MCP speaks, and the client tells it which one - see "Legacy sessions". In the modern revision there is no session to manage. It 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 in the modern binding this transport neither sends nor reads one.
The modern 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.
Legacy sessions
The handshake-based revisions 2025-03-26 to 2025-11-25 speak Streamable HTTP with a session, and Net::Async::MCP switches this transport to that binding when its handshake settles on one of them:
The
initializerequest goes out without a session id and without anMCP-Protocol-Version, the revision not being agreed yet. AnMcp-Session-Idon its response is the session, and travels on every later POST - requests, notifications, the answers to requests the server sent - and on theDELETEof "close". A server that sends none runs without one.Every POST after the handshake carries
MCP-Protocol-Versionwith the agreed revision, even for2025-03-26, which predates the header: a server of that revision ignores a header it does not know, and one of a later revision requires it.There is no
Mcp-Method,Mcp-NameorMcp-Param-{Name}: those are the modern revision's, so "mirrors_header_params" is false.A request the server sends on a response stream -
pingabove all - is handed to "on_request", and its answer is POSTed back.Cancelling a request sends
notifications/cancellednaming it, as these revisions want, besides closing its stream.A
404to a POST that carried a session id means the server ended the session. The request fails in categorymcp_session_expired, with the expired session id as its detail, and the session is forgotten; Net::Async::MCP then starts a new session and sends the request once more.
Out of scope: the standalone GET stream a server may offer for messages outside any request, resuming a broken stream with Last-Event-ID, and the HTTP+SSE transport of 2024-11-05, which predates Streamable HTTP - a 2024-11-05 server is reachable over stdio. A Streamable HTTP server that agrees on 2024-11-05 in its initialize answer is accepted.
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 set MCP-Protocol-Version, Mcp-Method, Mcp-Name, an Mcp-Param-{Name} or Mcp-Session-Id: a header that disagrees with the body is exactly what a conforming server answers with -32020, and a session id or protocol version of the caller's own would put a request into a session or a revision this transport is not in. A header of one of these names is dropped, whether or not the request carries one of its own, rather than sent alongside the derived one, which would be the same divergence in another shape. That holds for the legacy binding too, where Authorization and every other header travels as it does in the modern one.
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.
start_session, when true, marks the request that opens a legacy session - the initialize: it goes out without Mcp-Session-Id and MCP-Protocol-Version, and the Mcp-Session-Id of its response becomes the session, replacing any before it. A session id that is not visible ASCII, as the specification requires, fails the request rather than being sent on every later one. See "Legacy sessions".
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 "error": null next to a result is a success, as JSON-RPC 1.0 wrote it; without one it fails with MCP HTTP invalid response: a null error and no result, in a JSON body and on an event stream alike.
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, and that the server did answer. A request that got no answer at all - a connection that could not be made or broke off, a timeout - fails with the category Net::Async::HTTP gave it (http, timeout, ...), or connect where it gave none, as it does for a refused connection or an unknown host. That difference is what "initialize" in Net::Async::MCP tells a legacy server's refusal from an unreachable one by.
Cancelling the returned Future cancels the request by closing the stream it would have been answered on, which is what cancellation is over modern 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. In a legacy session that other form is the one the revision asks for, and a notifications/cancelled naming the request is POSTed as well.
A 404 to a request that carried a legacy session id fails it with MCP HTTP session SESSION not found (404 Not Found) in category mcp_session_expired, the session id being its detail, whatever the body says: the specification has a client read that status as the end of the session.
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 modern 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. In a legacy session it carries what every POST of the session does - Mcp-Session-Id and the agreed MCP-Protocol-Version - and a 404 to it ends the session the same way as for "send_request".
close
my $future = $transport->close;
Ends every subscription still running and, in a legacy session, the session itself, and returns a Future for that.
In the modern revision nothing is sent and the Future is resolved immediately. There is no session to terminate, since that revision is stateless and each request stands on its own, and a server on it 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.
In a legacy session with a session id - see "Legacy sessions" - the session is ended with a DELETE carrying it, as the specification asks a client that is done to do, and the Future resolves with its answer. A 405, which a server that does not let clients end sessions answers, and a 404, for a session already gone, both count as ended. Any other status, and a request that could not be made at all, fail the Future with MCP HTTP session SESSION could not be ended: REASON - reported, not thrown, and the session is forgotten on this side either way.
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;
True in the modern binding: it 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. False in a legacy session, which has no such headers; the other transports answer false too and are spared that request.
start_legacy_session
$transport->start_legacy_session;
Switches this transport to the binding of the legacy revisions and forgets any session and protocol version it held, ready for the initialize that opens a session - see "Legacy sessions". Called by Net::Async::MCP before every legacy handshake, the one that starts a new session after a 404 included.
set_legacy_protocol_version
$transport->set_legacy_protocol_version('2025-06-18');
The revision the legacy handshake agreed on, sent as MCP-Protocol-Version on every POST of the session from then on. Called by Net::Async::MCP as soon as the initialize answer is accepted, so the notifications/initialized carries it already.
end_legacy_session
$transport->end_legacy_session;
Switches back to the modern binding and forgets the session, without telling the server - which is what a legacy handshake that failed leaves behind. Use "close" to end a session that was established.
session_id
my $id = $transport->session_id;
The Mcp-Session-Id of the current legacy session, or undef where there is none: in the modern binding, before the handshake, after a 404 ended the session, after "close", and with a server that assigns none.
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_request
Invoked for every request the server sends on a response stream - an event with an id and a method but neither a result nor an error - with the decoded JSON-RPC request. A handler that returns a HashRef holding a result or an error has it POSTed back as the response to that request, in the session the stream belongs to; anything else leaves the request unanswered. Without a handler every such request is dropped.
Net::Async::MCP sets this to its own handler, which answers a legacy server's ping and refuses the rest - see "on_request" in Net::Async::MCP::Transport::Stdio, which works the same way.
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.