Client
NAME
MCP::Client - talk to an MCP server, in either protocol era
SYNOPSIS
use MCP::Client;
my $mcp = MCP::Client.connect-stdio(
command => 'my-mcp-server',
args => ['--stdio'],
client-name => 'my-agent',
on-elicit => -> %request { ask-the-user(%request<params>) },
);
say $mcp.era; # modern | legacy -- probed on first use
say $mcp.server-info<name>;
for $mcp.list-tools -> %tool {
say "%tool<name>: %tool<description>";
}
my %result = $mcp.call-tool('search', { query => 'raku' });
say .<text> for %result<content>.grep({ .<type> eq 'text' });
$mcp.close;
DESCRIPTION
An MCP client with one job: make a third-party MCP server usable from Raku without the caller having to care which revision of the protocol that server speaks, whether it wants a handshake, whether its answers may be cached, or whether it will interrupt a tool call to ask a question.
Protocol eras
There are two of them, and they are not compatible:
Legacy (
2025-11-25and earlier) opens with aninitializehandshake. Version and capabilities are agreed once, for the connection.Modern (
2026-07-28) has no handshake and no session. Every single request carries its own protocol version, client identity and client capabilities inparams._meta, so any request may be served by any instance of the server.
The client works out which it is talking to on the first call that needs the
answer, and remembers it for the life of the connection. The probe is a
server/discover request under a short budget (:$probe-timeout, five
seconds by default, deliberately separate from the request timeout ā a legacy
server may simply never answer it):
a
DiscoverResultwhosesupportedVersionsoverlaps ours ā modern, at the newest version we both speak;-32022 UnsupportedProtocolVersionā the server is certainly modern; if itsdata.supportednames a version we speak we probe again at that version, and if it names only legacy versions we fall back;any other JSON-RPC error, a timeout, or a reply that is not a
DiscoverResultat all ā legacy: sendinitialize, check the version that comes back, and sendnotifications/initialized.
A dead transport is not an era signal: if the probe fails because the server process is gone, the connection was closed, or the caller cancelled, that failure is raised rather than misread as "must be a legacy server".
Multi round-trip requests
A modern server may answer tools/call, resources/read or prompts/get
with resultType: "input_required" instead of a result: it needs something
from the client ā a question answered, an LLM consulted, a root list ā before
it can finish. The client fulfils those requests and retries the original
call under a fresh id, carrying the answers and the server's opaque
requestState back with it. That is the whole reason the modern protocol can
be stateless, and it is handled here transparently: call-tool returns the
final result, however many round trips it took.
Wire hooks for the kinds of input you are willing to serve:
my $mcp = MCP::Client.connect-stdio(
command => 'server',
on-elicit => -> %request { { action => 'accept', content => ask(%request) } },
on-sample => -> %request { my-llm(%request<params>) },
on-list-roots => -> %request { { roots => [{ uri => 'file:///srv', name => 'srv' }] } },
);
Each hook is called with the server's request (method and params) and
returns the body of the matching result. Which hooks you set is what the
client declares as its capabilities ā the server is forbidden from asking for
input you have not declared, so an unset hook is both a refusal and a
promise never to be asked. Refusal is still handled: an unset hook, or one that
throws, declines (< { action => 'decline' } > for elicitation, an empty root
list for roots) rather than failing the call.
A server that never stops asking cannot pin the client: after
:$max-input-rounds retries (eight by default) the call fails with
X::MCP::Client::InputLoopExceeded.
Caching
tools/list, prompts/list, resources/list and resources/read are
cached exactly as far as the server permits, using the ttlMs it attaches to
each result. Legacy servers make no such promise, so nothing they say is ever
cached. Pass :refresh to any of those methods to bypass and re-fetch.
Blocking and async
Every method blocks. Every method also has an -async twin returning a
Promise, so a caller can have several calls in flight ā the transports
multiplex, and correlation is by JSON-RPC id:
my @answers = await (
$mcp.call-tool-async('search', { query => 'a' }),
$mcp.call-tool-async('search', { query => 'b' }),
);
Feeding an LLM
tools-for-llm renders the server's catalogue as OpenAI-style function
declarations, and execute-tool-calls takes the tool calls a model asked for
and runs them. The pair is deliberately identical to MCP::Server's, so a
remote server and a local toolkit are interchangeable to the caller ā and
MCP::Client::Registry aggregates any number of either behind one prefix
namespace:
use LLM::Chat::ToolLoop;
my $loop = LLM::Chat::ToolLoop.new(
backend => $backend,
tools => $mcp.tools-for-llm,
execute-tools => -> @calls { $mcp.execute-tool-calls(@calls) },
);
execute-tool-calls never throws. A malformed call, arguments that are not
JSON, a tool the server does not have, a tool that failed, a timeout, an
exhausted multi round-trip loop, even a connection that has died ā each becomes
a result with is_error set and the reason as its content, because a model
that can read what went wrong can try something else, and an exception thrown
into the middle of a tool round cannot be recovered from at all.
How a batch is executed
The calls go down the connection one at a time, in model order ā unless
every call in the batch names a tool the server annotated readOnlyHint
and idempotentHint, in which case the distinct calls are in flight
together, up to $.tool-concurrency (default 4) of them. Three things hold
either way:
Order is not an outcome. Result n answers call n, whatever came back first, each under the
tool_call_idof the call it answers.One unannotated call makes the batch a sequence. A write, a command or a question for the user beside a read means the read waits its turn: a model that asked for both in one turn is describing an order.
Identical calls in a widened batch are one request, its answer copied into every slot that asked for it. Two reads of one page is one fetch.
A catalogue this client could not fetch means no annotation can be read, so
that batch is serial: concurrency is opted into by a server that said its tools
were safe for it, never assumed. < tool-annotations($name) > reports what a
server said; < tool-concurrency => 1 > turns the widening off. The scheduler
is MCP::Server::Batch, shared with MCP::Server's local bridge so the two
cannot drift.
Errors
Everything this client raises is an X::MCP::Client (see
MCP::Client::Exceptions). A JSON-RPC error from the server becomes an
X::MCP::Client::Protocol carrying the wire code and data:
{
CATCH {
when X::MCP::Client::Timeout { note "too slow: {.message}" }
when X::MCP::Client::Protocol { note "server refused: {.message} ({.code})" }
when X::MCP::Client::ServerGone { note "server died: {.stderr-tail}" }
}
$mcp.call-tool('flaky', {});
}
Notifications and logging
notifications is a Supply of every notification the server sends ā
progress, log messages, list-changed. :&on-log is a shortcut for the log
ones, and :$log-level asks a modern server to filter them at source:
my $mcp = MCP::Client.connect-stdio(
command => 'server',
log-level => 'info',
on-log => -> %params { note "[%params<level>] %params<data>" },
);
$mcp.notifications.tap: -> %note { say "server said %note<method>" };
Set :$log-level if you want log notifications from a modern server at
all. 2026-07-28 removed logging/setLevel in favour of the per-request
_meta level, and a server MUST NOT emit notifications/message for a
request that did not carry one ā so an on-log hook without a log-level
will simply never fire.
Progress
A long tool call can say how it is getting on. Progress is opt-in from this
end ā a request carries a progressToken when the caller wants to be kept
posted, and a server may not report on one that did not ā so :&on-progress
both asks for it and receives it, for every call the LLM bridge runs:
my $mcp = MCP::Client.connect-stdio(
command => 'server',
on-progress => -> %p {
$ui.progress(%p<tool-call-id>, %p<progress>, %p<total>, %p<message>);
},
);
The payload is < { tool-call-id, progress, total?, message? } >:
tool-call-id is the id of the call the model made, which is what ties the
line to the tool card it belongs on, and total and message are there only
when the server sent them. The bridge mints a fresh token per call ā not the
call id, which a retried call would reuse ā and forgets it the moment the call
answers, so a report that arrives late, or one quoting a token from somewhere
else, is dropped rather than attributed to the wrong tool.
The hook fires on the reader thread, ahead of the answer it belongs to.
Treat it as a leaf: push the payload somewhere and return. See :&on-progress
for the full contract.
Outside the bridge, call-tool takes a :$progress-token of your own and
the notifications arrive on notifications like any other.