NAME
Net::Async::MCP::Server::Transport::StreamableHTTP - Streamable HTTP transport for Net::Async::MCP::Server
VERSION
version 0.002
SYNOPSIS
use IO::Async::Loop;
use Net::Async::MCP::Server;
use Net::Async::MCP::Server::Transport::StreamableHTTP;
my $loop = IO::Async::Loop->new;
my $server = Net::Async::MCP::Server->new(name => 'my-server');
$loop->add($server);
my $http = Net::Async::MCP::Server::Transport::StreamableHTTP->new(
server => $server,
host => '127.0.0.1',
port => 8080,
path => '/mcp',
origin_allow => [ 'https://app.example.com' ],
);
$loop->add($http);
# Primary entry point: a PSGI app you can mount under any PSGI server
# (including an async-capable one on the same loop) or a reverse proxy.
my $app = $http->to_app;
# ...or, for a quick blocking local bring-up, the convenience wrapper:
$http->listen;
DESCRIPTION
Net::Async::MCP::Server::Transport::StreamableHTTP is the Streamable HTTP transport for MCP 2026-07-28. It exposes a single HTTP POST endpoint per the spec, validates the request metadata headers against the JSON-RPC body, and answers each request with either an application/json response or a text/event-stream stream.
The transport is a thin wrapper around "handle" in Net::Async::MCP::Server: the PSGI handler decodes the envelope and hands the request to handle(). The spec-mandated header checks run first, but only for requests that carry an id — a notification (no id) skips them entirely and goes straight to handle(), since the 2026-07-28 spec does not define header requirements for notification POSTs. Method implementations live in Net::Async::MCP::Server; this module is the HTTP layer and nothing else.
PROTOCOL
A conforming client POSTs every JSON-RPC request or notification to a single endpoint (the MCP endpoint, by default /mcp). Headers it MUST set:
MCP-Protocol-Version— the protocol version, mirrored fromparams._meta.io.modelcontextprotocol/protocolVersionin the body.Mcp-Method— mirrored frommethod.Mcp-Name— fortools/call,resources/read,prompts/get, mirrored fromparams.name(orparams.uriforresources/read).Mcp-Param-{Name}— for any tool argument annotated withx-mcp-header: "{Name}"in the tool'sinputSchema, mirrored from the matchingargumentsvalue.
The server looks the called tool up in its registry ("find_tool" in Net::Async::MCP::Server) and enforces the annotations: when an annotated property holds a non-null value in arguments, the matching Mcp-Param-{Name} header MUST be present, or the request is rejected with -32020. Annotations are found only along chains of properties keys (nested objects included); a property reached through items, oneOf/anyOf/allOf/not, if/then/else or $ref is not statically addressable and is ignored. The header value is compared by the property's declared type: a string compares as a string, an integer or number numerically ("42" equals "42.0"), a boolean as true/false. A call to an unknown tool is not schema-checked and gets the usual -32602 from handle().
Any divergence between header value and body value is rejected with HTTP 400 and JSON-RPC error -32020 HeaderMismatch.
The body MUST be a single JSON-RPC request or notification, Content-Type: application/json. The client MUST include Accept: application/json, text/event-stream so the server can pick the matching response mode.
server
my $server = $transport->server;
Returns the Net::Async::MCP::Server instance this transport feeds.
host
my $host = $transport->host;
Returns the interface to bind on. Defaults to 127.0.0.1. A non-loopback bind emits a warning from "listen" unless allow_external_bind is passed.
port
my $port = $transport->port;
Returns the TCP port the transport binds to. Defaults to 8080.
path
my $path = $transport->path;
Returns the URL path of the MCP endpoint. Defaults to /mcp.
origin_allow
my @allow = @{ $transport->origin_allow };
Returns the ArrayRef of origin URLs the transport permits.
Security: an empty list — the default — means allow any Origin. The MCP Streamable HTTP spec requires the server to validate the Origin header to prevent DNS-rebinding attacks, in which a page on an attacker's origin drives a victim's browser into a server bound on their own machine. With the default in force a cross-origin browser request against a loopback bind is not rejected, so set origin_allow to your known web origins on any deployment a browser can reach. Once the list is non-empty it is the whole allowlist: a request whose Origin header is not in it gets an HTTP 403, while a request that sends no Origin header at all is still allowed.
to_app
my $app = $transport->to_app;
my $res = $app->($env);
Returns the PSGI application coderef, and is the primary way to run this transport. Mount it under any PSGI server — an async-capable one driven by the same IO::Async loop, or a plain one behind a reverse proxy in production — and the whole request path, application/json and SSE alike, runs through it. It is also what Plack::Test drives in the test suite and what "listen" runs internally.
Unlike "listen", to_app brings no accept loop of its own and so does not block the event loop: the server hosting the app owns how requests are accepted.
listen
$http->listen;
A convenience wrapper around "to_app" for a quick local bring-up: it binds the configured host:port with HTTP::Server::PSGI and runs the app under that server's own accept loop. That accept loop is an ordinary blocking one, so listen blocks both the caller and the IO::Async loop — unlike "handle_requests" in Net::Async::MCP::Server::Transport::Stdio, which reads through the loop and leaves other notifiers running, nothing else in this loop gets a turn while listen is up. To share the loop, run "to_app" under an async-capable PSGI server instead; a native IO::Async::Listener bring-up is left to a future ticket.
A non-loopback bind is warned about, since exposing an MCP server on a public interface is almost always accidental and unauthenticated; pass allow_external_bind to silence the warning when you really mean it.
SEE ALSO
Net::Async::MCP::Server, Net::Async::MCP::Server::Transport::Stdio, HTTP::Server::PSGI, Plack::Test, IO::Async::Notifier.
https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http — the protocol this transport speaks.
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-net-async-mcp-server/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.