HTTP
NAME
MCP::Client::Transport::HTTP - Streamable HTTP transport for MCP::Client
SYNOPSIS
use MCP::Client;
my $mcp = MCP::Client.connect-http(
url => 'http://127.0.0.1:8080/mcp',
headers => { Authorization => 'Bearer s3cret' },
);
say $mcp.list-tools.map(*<name>).join(', ');
say $mcp.call-tool('greet', { name => 'Ada' })<content>[0]<text>;
$mcp.close;
DESCRIPTION
The 2026-07-28 Streamable HTTP transport, and only that one: a single POST
endpoint, no sessions, no GET stream, no resumability, and every request
self-describing through its params._meta. The legacy HTTP session transport
(Mcp-Session-Id, Last-Event-ID, server-initiated requests on a stream) is
deliberately not implemented β see #Limitations.
MCP::Client loads this module on demand, so a stdio client never pays for
Cro's load time. You rarely construct it yourself; connect-http does it for
you. When you do, every attribute below is available.
What one request looks like
Each JSON-RPC message is its own POST. The body is the message; a handful of
its fields are mirrored into headers so that load balancers and gateways can
route without parsing JSON:
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: execute_sql
Mcp-Param-Region: us-west1
MCP-Protocol-Version is taken from the outbound body's _meta, never
guessed, because a server MUST reject the request when the two disagree.
Mcp-Name carries params.name for tools/call and prompts/get, and
params.uri for resources/read. Mcp-Param-* is the x-mcp-header
feature described below.
The answer is one of four things:
200
application/jsonβ the body is the JSON-RPC response.200
text/event-streamβ notifications for this request arrive as SSE events, then the response, which ends the stream. Notifications go to&on-notification; the response settles the promise.202 β the acknowledgement of a notification (
notify). Seeing one in answer to a request is a protocol error.400 / 404 / anything else β the body is parsed for a JSON-RPC error and surfaced as an
X::MCP::Client::Protocolcarrying the wirecodeanddata. That matters for the era probe: a modern server answers an unknown version with400and-32022, which is evidence about the server rather than a dead end.
x-mcp-header
A server may ask for a tool argument to be copied into a header by putting an
x-mcp-header annotation on that argument's schema. Supporting it is a client
MUST, so this transport does β without MCP::Client having to know:
tools/list responses pass through here on their way up, and their
annotations are captured as they go.
Annotations are validated on the way through, and a tool whose annotations
break any of the rules is excluded from the tools/list result handed to
the caller (with a warning through &.on-warn), so one malformed tool cannot
take the rest of the catalog with it.
# What the transport does for you when the server publishes this:
{
name => 'execute_sql',
inputSchema => {
type => 'object',
properties => {
region => { type => 'string', 'x-mcp-header' => 'Region' },
query => { type => 'string' },
},
},
}
# ... a later call-tool('execute_sql', { region => 'us-west1', ... }) sends
# Mcp-Param-Region: us-west1
Values that cannot travel as a plain ASCII header value are carried in the base64 sentinel format, which the server decodes before comparing them to the body:
use MCP::Client::Transport::HTTP;
say header-encode('us-west1'); # us-west1
say header-encode('Hello, δΈη'); # =?base64?SGVsbG8sIOS4lueVjA==?=
say header-encode(' padded '); # =?base64?IHBhZGRlZCA=?=
say header-encode('=?base64?x?='); # =?base64?PT9iYXNlNjQ/eD89?=
Cancellation
Closing the response stream is the only cancellation signal this protocol has,
so that is exactly what :$cancelled does: the response is cancelled, the
connection goes away, the server sees the hang-up, and the promise breaks with
X::MCP::Client::Cancelled.
my $stop = Promise.new;
my $answer = $mcp.call-tool-async('slow', {}, cancelled => $stop);
$stop.keep(True); # the server is told by the disconnect itself
Testing seams
The pieces that decide what goes on the wire are plain subs, exported so they can be tested (and reused) without a server:
header-encode($value)β the base64 sentinel rule.request-headers(%msg, :%annotations, :$protocol-version)β every header one message implies, as a list ofPairs.tool-header-annotations(%tool)β< { valid, reason, headers } >for one tool definition.response-kind($status, $content-type)βsse|json|accepted|error.error-for-status($status, $body)β theX::MCP::Client::Protocolan unsuccessful response deserves, coded when the body says so.
Limitations
Modern era only. There is no
initializehandshake over HTTP, and a server that only speaks2025-11-25over HTTP cannot be talked to.No
subscriptions/listen, so no long-lived notification stream. Cache freshness comes fromttlMsalone.No OAuth. Static credentials go in
%.headers.HTTP/1.1 by default, on purpose (see
$.http-version).
MUST NOT be empty; #| =item MUST match HTTP field-name token syntax (
1*tchar, RFC 9110 Β§5.1), #| which also rules out the CR and LF a header injection would need; #| =item MUST be case-insensitively unique within theinputSchema; #| =item MUST only be applied to a primitive-typed property βstring, #|integerorboolean, and pointedly notnumber; #| =item MUST only be applied to a property that is statically reachable #| from the schema root through a chain ofpropertieskeys alone. #| Nested objects are fine;items,oneOf/anyOf/allOf/not, #|if/then/elseand$refare not, and "an x-mcp-header #| annotation anywhere else makes the annotation β and thus the tool #| definition β invalid". sub tool-header-annotations(%tool --> Hash:D) is export { my @found; collect-annotations(%tool<inputSchema>, (), True, @found);