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-requestwrites the request a server will classify into the era you asked for. A modern-era request carries the 2026-07-28params._metablock (protocol version, and optionally client info, client capabilities and a per-request log level); a legacy request carries none of it.Inbound messages.
parse-inboundclassifies anything a server can send us. The server'sparse-messagerejects a message without amethodā which is every response ever sent to a client ā so a client cannot use it.Result shaping.
normalize-resultgives a legacy result theresultTypea modern one would have had, so one code path can read both.decline-input-responsewrites the "no thank you" answer to a server-initiated input request.is-modern-errorrecognises 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' ā
idplus exactly one ofresult/error. #| =item 'notification' āmethodandparams(< {} >when absent). #| =item 'request' āid,method,params: the server asking us #| something (sampling, elicitation, roots). #| =item 'invalid' āreasonsays what was wrong with it. #| #| A response'sresultis passed through verbatim, including JSONnull; #| run it throughnormalize-resultbefore 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#| definesactionasaccept|decline|cancel, so declining is a #| first-class answer. #| =itemroots/listā< { roots => [] }>. An empty root list is a valid #|ListRootsResultand it is the true one: we expose nothing. #| =itemsampling/createMessage, and any method we do not recognise ā an #| undefined Hash, meaning "leave this key out ofinputResponses#| altogether".CreateMessageResultrequires 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 declaressamplingwhen anon-samplehook 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> !! '';