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::Protocol carrying the wire code and data. That matters for the era probe: a modern server answers an unknown version with 400 and -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 of Pairs.

  • 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) β€” the X::MCP::Client::Protocol an unsuccessful response deserves, coded when the body says so.

Limitations

  • Modern era only. There is no initialize handshake over HTTP, and a server that only speaks 2025-11-25 over HTTP cannot be talked to.

  • No subscriptions/listen, so no long-lived notification stream. Cache freshness comes from ttlMs alone.

  • 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 the inputSchema; #| =item MUST only be applied to a primitive-typed property β€” string, #| integer or boolean, and pointedly not number; #| =item MUST only be applied to a property that is statically reachable #| from the schema root through a chain of properties keys alone. #| Nested objects are fine; items, oneOf/anyOf/allOf/not, #| if/then/else and $ref are 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);

MCP::Client v0.5.0

talk to an MCP server, in either protocol era

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

MCP::Server:ver<0.6.0+>:auth<zef:apogee>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>Cro::HTTP:ver<0.8.11+>:auth<zef:cro>:api<0>MIME::Base64:ver<1.2.5+>:auth<zef:raku-community-modules>

Test Dependencies

Provides

  • MCP::Client
  • MCP::Client::Cache
  • MCP::Client::Correlator
  • MCP::Client::Exceptions
  • MCP::Client::Leases
  • MCP::Client::Leases::Table
  • MCP::Client::Policy
  • MCP::Client::Policy::Commands
  • MCP::Client::Policy::Floor
  • MCP::Client::Policy::Grants
  • MCP::Client::Policy::Rules
  • MCP::Client::Protocol
  • MCP::Client::Reasons
  • MCP::Client::Registry
  • MCP::Client::SSE
  • MCP::Client::Transport
  • MCP::Client::Transport::HTTP
  • MCP::Client::Transport::Stdio
  • MCP::Client::UnknownKeys

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite β€” the markup and publishing tools behind this site.