NAME

Net::Async::MCP::Server::Transport::StreamableHTTP - Streamable HTTP transport for Net::Async::MCP::Server

VERSION

version 0.003

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 from params._meta.io.modelcontextprotocol/protocolVersion in the body.

  • Mcp-Method — mirrored from method.

  • Mcp-Name — for tools/call, resources/read, prompts/get, mirrored from params.name (or params.uri for resources/read).

  • Mcp-Param-{Name} — for any tool argument annotated with x-mcp-header: "{Name}" in the tool's inputSchema, mirrored from the matching arguments value.

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.