Protocol

NAME

MCP::Client::Protocol - the client half of the MCP wire format

DESCRIPTION

MCP::Server::Protocol owns everything both ends of an MCP conversation agree on: the version constants, the JSON-RPC and 2026-07-28 error codes, the _meta key names, format-message. This module owns the half of the wire that only a client needs, and that the server module deliberately does not implement:

  • Outbound requests. build-request writes the request a server will classify into the era you asked for. A modern-era request carries the 2026-07-28 params._meta block (protocol version, and optionally client info, client capabilities and a per-request log level); a legacy request carries none of it.

  • Inbound messages. parse-inbound classifies anything a server can send us. The server's parse-message rejects a message without a method — which is every response ever sent to a client — so a client cannot use it.

  • Result shaping. normalize-result gives a legacy result the resultType a modern one would have had, so one code path can read both. decline-input-response writes the "no thank you" answer to a server-initiated input request. is-modern-error recognises the error codes only a 2026-07-28 server emits, which is how the era probe tells a modern server from an ancient one.

Import MCP::Server::Protocol alongside this module when you want the shared constants; they are not re-exported from here.

EXAMPLES

Build a request for each era and see how a server would classify it:

use MCP::Server::Protocol;   # for detect-era, MODERN-PROTOCOL-VERSION
use MCP::Client::Protocol;

my %modern = build-request(
	1, 'tools/call', { name => 'echo', arguments => { text => 'hi' } },
	era         => 'modern',
	client-info => { name => 'my-agent', version => '1.0' },
	log-level   => 'info',
);
say %modern<params><_meta>{META-PROTOCOL-VERSION};  # 2026-07-28
say detect-era(%modern);                            # modern

my %legacy = build-request(
	2, 'tools/call', { name => 'echo', arguments => { text => 'hi' } },
	era => 'legacy',
);
say %legacy<params><_meta>:exists;                  # False
say detect-era(%legacy);                            # legacy

Classify whatever came back off the pipe. Nothing here throws on bad input — a server that writes a stray line of log to stdout must not kill the read loop:

given parse-inbound($line) -> %in {
	given %in<kind> {
		when 'response' {
			%in<error>:exists
				?? $correlator.reject(%in<id>, error-for(%in<error>))
				!! $correlator.resolve(%in<id>, normalize-result(%in<result>));
		}
		when 'notification' { $notifications.emit(%in) }
		when 'request'      { answer-server-request(%in) }
		when 'invalid'      { note "dropped inbound line: %in<reason>" }
	}
}

Decline a server-initiated input request, which is what the multi round-trip loop does for every kind of input the caller has not wired a hook for. What comes back is the value half of one inputResponses entry — the client result that goes under the server's own key — because InputResponses is a map from the keys the server chose to bare client results:

my %responses;
for %result<inputRequests>.kv -> $key, %request {
	my $body = decline-input-response(%request);
	%responses{$key} = $body with $body;   # undefined means "omit this key"
}
# %responses = { github_login => { action => 'decline' } }
  • 'response' — id plus exactly one of result / error. #| =item 'notification' — method and params (< {} > when absent). #| =item 'request' — id, method, params: the server asking us #| something (sampling, elicitation, roots). #| =item 'invalid' — reason says what was wrong with it. #| #| A response's result is passed through verbatim, including JSON null; #| run it through normalize-result before reading fields off it. my sub invalid(Str:D $reason, Str:D $raw, $message? --> Hash) { my %out = kind => 'invalid', :$reason, :$raw; %out<message> = $message.Hash if $message ~~ Associative; %out; }

  • elicitation/create — < { action => 'decline' } >. ElicitResult #| defines action as accept|decline|cancel, so declining is a #| first-class answer. #| =item roots/list — < { roots => [] } >. An empty root list is a valid #| ListRootsResult and it is the true one: we expose nothing. #| =item sampling/createMessage, and any method we do not recognise — an #| undefined Hash, meaning "leave this key out of inputResponses #| altogether". CreateMessageResult requires a model, a role and #| content, so it cannot say "no"; inventing one would be a lie about #| what an LLM produced. Omission is the spec's own path: a server that #| does not get an answer it needs SHOULD ask again, and the client's #| round budget bounds how often it may. #| #| Note that a server MUST NOT ask for input the client did not declare a #| capability for, so a well-behaved server never reaches the omission branch: #| MCP::Client only declares sampling when an on-sample hook is wired. #| #| Shapes verified 2026-08-08 against #| https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr #| and the schema at #| https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/2026-07-28/schema.ts. sub decline-input-response($request --> Hash) is export { my %req = $request ~~ Associative ?? $request.Hash !! {}; my $method = %req<method> ~~ Str:D ?? %req<method> !! '';

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.