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-25 and earlier) opens with an initialize handshake. 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 in params._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 DiscoverResult whose supportedVersions overlaps ours ⇒ modern, at the newest version we both speak;

  • -32022 UnsupportedProtocolVersion ⇒ the server is certainly modern; if its data.supported names 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 DiscoverResult at all ⇒ legacy: send initialize, check the version that comes back, and send notifications/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_id of 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.

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.