=begin pod

=head1 MCP::Client

A client for L<Model Context Protocol|https://modelcontextprotocol.io>
servers. Connect to a third-party MCP server over stdio or Streamable
HTTP, call its tools, read its resources, render its prompts — and hand
the whole catalogue to an LLM tool loop as if it were a local toolkit.

Both protocol eras are spoken: the C<2025-11-25> handshake era and the
stateless C<2026-07-28> era. Which one a server wants is worked out by
probing it, once, on the first call that needs to know.

=head2 Synopsis

=begin code :lang<raku>
use MCP::Client;
use MCP::Client::Registry;
use LLM::Chat::ToolLoop;

my $mcp = MCP::Client.connect-stdio(
    command     => 'mcp-server-filesystem',
    args        => ['/srv/docs'],
    client-name => 'my-agent',
);

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('read_file', { path => '/srv/docs/README' });
say .<text> for %result<content>.grep({ .<type> eq 'text' });

# The same server, as tools for a model. A local MCP::Server toolkit would
# be added the same way, under its own prefix.
my $tools = MCP::Client::Registry.new;
$tools.add($mcp, prefix => 'fs');

my $loop = LLM::Chat::ToolLoop.new(
    :$backend,
    tools         => $tools.tools-for-llm,
    execute-tools => -> @calls { $tools.execute-tool-calls(@calls) },
);

$mcp.close;
=end code

=head2 Description

MCP::Client has 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.

The distribution is a handful of units with clean seams, each carrying
its own Pod6:

=item L<MCP::Client|lib/MCP/Client.rakumod> — the client class:
      connection, era negotiation, the typed API, the multi round-trip
      loop, the LLM bridge.
=item L<MCP::Client::Registry|lib/MCP/Client/Registry.rakumod> — one
      prefixed tool namespace over many providers, remote and local.
=item L<MCP::Client::Transport|lib/MCP/Client/Transport.rakumod> — the
      role a wire implements, and the contract it signs up to.
=item L<MCP::Client::Transport::Stdio|lib/MCP/Client/Transport/Stdio.rakumod>
      — a server run as a child process.
=item L<MCP::Client::Transport::HTTP|lib/MCP/Client/Transport/HTTP.rakumod>
      — the 2026-07-28 Streamable HTTP transport.
=item L<MCP::Client::Exceptions|lib/MCP/Client/Exceptions.rakumod> — the
      typed failure vocabulary.
=item L<MCP::Client::Protocol|lib/MCP/Client/Protocol.rakumod> — request
      building, inbound-message classification, result shaping.
=item L<MCP::Client::Correlator|lib/MCP/Client/Correlator.rakumod> — the
      pending-request table that makes one pipe carry many conversations.
=item L<MCP::Client::SSE|lib/MCP/Client/SSE.rakumod> — an incremental
      Server-Sent Events parser.
=item L<MCP::Client::Cache|lib/MCP/Client/Cache.rakumod> — the
      C<ttlMs>/C<cacheScope> result cache.

Everything on this page is C<MCP::Client>'s own API unless it says
otherwise.

=head2 Protocol eras

There are two, and they are not compatible.

=item B<Legacy> — C<2025-11-25> and earlier. Session-shaped: the client
      opens with C<initialize>, the server answers with its version and
      capabilities, the client sends C<notifications/initialized>, and
      the connection carries that agreement until it hangs up.
=item B<Modern> — C<2026-07-28>. Stateless: no handshake, no session.
      Every request carries its own protocol version, client identity
      and client capabilities in C<params._meta>, so any request may be
      served by any instance of the server, and a result may say how
      long it can be cached for.

The client decides which it is talking to on the first call that needs
the answer and remembers it for the life of the connection, because the
era is a property of the server rather than of a request. Nothing has to
be configured, and nothing has to be awaited: C<era> forces the probe
and reports the verdict, but so does the first C<list-tools>.

=begin code :lang<raku>
my $mcp = MCP::Client.connect-stdio(command => 'some-server');

say $mcp.era;                 # 'modern' or 'legacy'
say $mcp.protocol-version;    # '2026-07-28', or '2025-11-25' after a handshake
say $mcp.supported-versions;  # every version the server named (empty for legacy)
say $mcp.server-info<name>;
say $mcp.capabilities.keys;   # tools resources prompts ...
say $mcp.instructions;        # the server's advice to a model, or Str
=end code

=head3 The probe

One request, under a short budget of its own (C<:$probe-timeout>, five
seconds by default, deliberately separate from C<:$request-timeout> — a
legacy server does not answer C<server/discover> at all, and waiting a
full minute to discover that would make every connection to one feel
broken):

=item A C<DiscoverResult> whose C<supportedVersions> overlaps ours ⇒
      B<modern>, at the first version I<we> prefer that the server also
      named. Preference is the client's, not the server's.
=item C<-32022 UnsupportedProtocolVersion> ⇒ the server is certainly
      modern, since only a modern server emits that code. If its
      C<data.supported> names another version we speak, the probe is
      repeated at that version; if it names only C<2025-11-25>, the
      client falls back to the handshake; if it names nothing we can use,
      C<X::MCP::Client::UnsupportedVersion>.
=item C<-32020> or C<-32021> ⇒ modern, but the server told us nothing
      about itself. The catalogue calls will fill that in.
=item Anything else — C<-32601 method not found>, a parse error, a
      timeout, a reply that is not a C<DiscoverResult> — ⇒ B<legacy>:
      C<initialize>, check the version that came back, then
      C<notifications/initialized>.

An empty C<supportedVersions> falls in that last group on purpose. It is
what C<MCP::Server> answers when it is configured for the legacy era
only, and it means the same thing as a disjoint list: the server has
declined to offer a modern version, so ask it the old way.

B<A dead wire 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 an old
server" — replacing "the server exited with code 127" with a second,
more confusing failure helps nobody.

=head3 Pinning a version

C<:@protocol-versions> is the list of B<modern> versions this client will
speak, best first. The legacy era is always available as a fallback and
must not appear in it: legacy is reached by handshake, not by version
stamp, so naming it is a mistake the constructor refuses rather than
honours.

=begin code :lang<raku>
# Speak only 2026-07-28, and fall back to the handshake for anything older.
MCP::Client.connect-stdio(
    command           => 'srv',
    protocol-versions => ['2026-07-28',],
);

MCP::Client.connect-stdio(command => 'srv', protocol-versions => ['2025-11-25',]);
# dies: X::MCP::Client::UnsupportedVersion
=end code

=head2 Connecting

=head3 stdio

The transport almost every MCP server in the wild speaks: the server is a
child process, one JSON-RPC message per line each way, stderr is a log
nobody was supposed to have to read until something went wrong.

=begin code :lang<raku>
my $mcp = MCP::Client.connect-stdio(
    command   => 'mcp-server-git',
    args      => ['--repository', '/srv/project'],
    env       => { GIT_AUTHOR_NAME => 'agent' },
    cwd       => '/srv/project',
    on-stderr => -> Str:D $line { note "[git-server] $line" },
);
=end code

C<$command> is executed directly through C<Proc::Async>. B<There is no
shell> — nothing reaches C<sh> or C<cmd.exe> — so there is no quoting to
get wrong, no glob expansion, no C<$(...)>, and no injection: an argument
containing C<; rm -rf /> is one argument containing that text. Two
consequences are worth knowing before a Windows user files a bug:

=item B<No C<PATH> extension magic on Windows.> C<$command> must name a
      file that exists — C<npx.cmd>, not C<npx>. And since a C<.cmd> is a
      batch script rather than an executable, Windows can only run it
      through C<cmd.exe>: name a real C<.exe> where there is one, or ask
      for the interpreter explicitly and accept its quoting rules.
=item B<NUL bytes are refused.> A C<\0> in the command, an argument or an
      environment entry cannot survive the syscall boundary intact, so it
      is rejected up front with C<X::MCP::Client::SpawnFailed> rather
      than silently truncating what the child receives.

=begin code :lang<raku>
# The npm-shipped servers are launched through npx, which on Windows is a
# batch shim. Nothing here supplies a shell, so ask for one by name.
my ($command, @shim) = $*DISTRO.is-win
    ?? ('cmd.exe', ['/c', 'npx'])
    !! ('npx',     []);

my $mcp = MCP::Client.connect-stdio(
    :$command,
    args => [|@shim, '-y', '@modelcontextprotocol/server-filesystem', '/srv/docs'],
);
=end code

The child inherits this process's environment by default and C<%env> is
layered on top of it, because a server that cannot see C<PATH> or C<HOME>
usually cannot start. C<:!inherit-env> gives it C<%env> and nothing else.

=head3 Streamable HTTP

=begin code :lang<raku>
my $mcp = MCP::Client.connect-http(
    url     => 'https://tools.example.com/mcp',
    headers => { Authorization => 'Bearer ' ~ %*ENV<TOOLS_TOKEN> },
);
=end code

B<Modern era only.> There is no C<initialize> handshake over HTTP here,
so a server that speaks only C<2025-11-25> over HTTP cannot be talked to
(see L<Limitations|#limitations>). The Cro dependency this transport needs is loaded
on demand — a stdio client never pays for it at startup.

Each JSON-RPC message is its own POST, and a handful of its fields are
mirrored into headers so that gateways can route without parsing bodies:
C<MCP-Protocol-Version>, C<Mcp-Method>, and C<Mcp-Name> for the three
methods that name a thing. The C<x-mcp-header> annotation feature is
implemented in full, transparently — see
L<MCP::Client::Transport::HTTP|lib/MCP/Client/Transport/HTTP.rakumod>.

=head3 Options

Both C<connect-*> methods take every option C<MCP::Client.new> does, and
pass the transport's own knobs through to the transport they build:

=begin table
Option              | Default            | What it does
====================|====================|=============
client-name         | raku-mcp-client    | Reported to the server as clientInfo; diagnostics only
client-version      | 0.1.0              | Ditto
protocol-versions   | (2026-07-28,)      | Modern versions to offer, best first
request-timeout     | 60                 | Seconds an ordinary request may take; 0 disables the budget
probe-timeout       | 5                  | Seconds the era probe may take
max-input-rounds    | 8                  | Multi round-trip retries before giving up
on-elicit           | (unset)            | Serve elicitation/create; declares the capability
on-sample           | (unset)            | Serve sampling/createMessage; declares the capability
on-list-roots       | (unset)            | Serve roots/list; declares the capability
on-log              | (unset)            | Called with the params of each notifications/message
on-progress         | (unset)            | Called as a bridge tool call reports progress; asks for it too
on-warn             | note to $*ERR      | One-line diagnostics; shared with the transport
log-level           | (unset)            | Ask a modern server for logs at this severity
client-capabilities | derived from hooks | Override the derivation
cache               | a fresh one        | Share a cache, or inject one with a virtual clock
=end table

Stdio also takes C<:&on-stderr>, C<:$kill-grace>, C<:$stderr-lines> and
C<:$inherit-env>; HTTP also takes C<:$http-version>, C<:$persistent> and
C<:$connect-timeout>. Those reach the transport rather than the client.
C<:&on-warn> is the one option that serves both: the client reports its
own papered-over problems through it (an input hook that threw, a
notification subscriber that died) and the transport reports wire-level
ones (a line of stdout that was not JSON-RPC, a late answer to a request
that had already given up).

=begin code :lang<raku>
my $mcp = MCP::Client.connect-stdio(
    command => 'chatty-server',
    on-warn => -> Str:D $message { $log.warn("mcp: $message") },
);
=end code

An option neither the client nor its transport recognises is an error,
not a shrug — C<.new> ignoring an unknown named argument is how a typo in
a hook name becomes a client that quietly never elicits:

=begin code :lang<raku>
MCP::Client.connect-stdio(command => 'srv', on-elicits => &ask);
# dies: X::MCP::Client: unknown option: on-elicits
=end code

C<:$transport> substitutes a ready-made transport for the one
C<connect-*> would have built — which is how the test suite drives the
client with no subprocess anywhere. Transport options are refused in that
case rather than silently ignored, since there is nothing left for them
to configure.

=head2 Calling tools, resources and prompts

Every method blocks, and every method has an C<-async> twin returning a
C<Promise>.

=begin code :lang<raku>
my @tools = $mcp.list-tools;         # name, description, inputSchema
my @res   = $mcp.list-resources;     # uri, name, mimeType, ...
my @prompts = $mcp.list-prompts;     # name, description, arguments

my %call   = $mcp.call-tool('search', { query => 'raku' });
my %read   = $mcp.read-resource('file:///srv/docs/README');
my %render = $mcp.get-prompt('review', { code => $source });
=end code

The three results have three shapes, and the protocol bookkeeping —
C<resultType>, C<_meta>, C<ttlMs>, C<cacheScope>, C<requestState>,
C<inputRequests> — is stripped before you see it, having already been
acted on:

=begin code :lang<raku>
# call-tool: content blocks, plus isError when the tool reported failure.
%call = {
    content => [ { type => 'text', text => 'three results...' } ],
};

# read-resource: one entry per thing the URI resolved to.
%read = {
    contents => [ { uri => 'file:///srv/docs/README', mimeType => 'text/plain',
                    text => '# Project' } ],
};

# get-prompt: a rendered conversation, ready to prepend to a chat.
%render = {
    description => 'Code review prompt',
    messages    => [ { role => 'user',
                       content => { type => 'text', text => 'Please review...' } } ],
};
=end code

B<A tool that fails is not an exception.> C<isError> is part of a
perfectly good result, and a caller feeding a model wants the text of it
far more than it wants a throw:

=begin code :lang<raku>
my %result = $mcp.call-tool('deploy', { env => 'prod' });
if %result<isError> {
    note 'the tool refused: ' ~ %result<content>.map({ .<text> // '' }).join;
}
=end code

=head3 Caching

C<tools/list>, C<prompts/list>, C<resources/list> and C<resources/read>
are the four results the protocol allows a client to hold on to, and each
carries the C<ttlMs> the server is prepared to promise. The client
honours exactly that: no C<ttlMs> (or C<ttlMs> of C<0>) means no storage,
expiry is by the clock, and B<a legacy server's answers are never
cached>, because the 2025-11-25 era has no cache metadata and nothing
legitimises reuse.

That makes C<list-tools> cheap enough to call per model turn, which is
what an agent loop does.

=begin code :lang<raku>
my @first  = $mcp.list-tools;            # one round trip
my @cached = $mcp.list-tools;            # no round trip, if the server allowed it

my @fresh  = $mcp.list-tools(:refresh);  # bypass and re-fetch, dropping the entry
=end code

C<:refresh> is on all four. It is what to reach for after a
C<notifications/tools/list_changed>:

=begin code :lang<raku>
$mcp.notifications.tap: -> %note {
    $catalogue = $mcp.list-tools(:refresh)
        if %note<method> eq 'notifications/tools/list_changed';
};
=end code

=head3 Timeouts

C<:$request-timeout> is the connection-wide budget (60 seconds); every
call may override it. C<0> means "no budget", which only makes sense when
the caller is managing cancellation itself.

=begin code :lang<raku>
$mcp.call-tool('reindex', {}, timeout => 600);   # this one is allowed to take ten minutes
=end code

A timeout is C<X::MCP::Client::Timeout>, and B<the connection survives
it>: the request stops being tracked, a late answer for it is dropped,
and the next request goes out normally.

=head3 Cancellation

C<:$cancelled> takes a C<Promise>. Keeping it abandons the request.

=begin code :lang<raku>
my $stop = Promise.new;
my $answer = $mcp.call-tool-async('crawl', { url => $url }, cancelled => $stop);

$stop.keep(True) if $user-pressed-escape;
# await $answer now throws X::MCP::Client::Cancelled
=end code

What the server learns depends on the transport. Over HTTP, closing the
response stream B<is> the cancellation signal the 2026-07-28 transport
defines, so the server sees the hang-up and can stop. Over stdio there is
no such signal: the request stops being tracked, the server finishes it
anyway, and its answer is dropped when it arrives. Cancelling frees the
caller, not the server.

=head3 Async

The twins are the blocking methods on a thread-pool thread. The
transports multiplex and correlation is by JSON-RPC id, so several
requests may be in flight at once; the era probe is serialised behind its
own lock, so a burst of first calls produces one probe and the rest wait
for it.

=begin code :lang<raku>
my @answers = await (
    $mcp.call-tool-async('search', { query => 'raku' }),
    $mcp.call-tool-async('search', { query => 'perl' }),
    $mcp.read-resource-async('file:///srv/docs/README'),
);
=end code

=head3 Notifications and logging

C<notifications> is a live C<Supply> of every notification the server
sends — progress, log messages, list-changed. Taps see nothing that
arrived before they tapped.

=begin code :lang<raku>
my $mcp = MCP::Client.connect-stdio(
    command   => 'srv',
    log-level => 'info',
    on-log    => -> %params { note "[%params<level>] %params<data>" },
);

$mcp.notifications.tap: -> %note { $ui.progress(%note) };
=end code

B<Set C<:$log-level> if you want log notifications from a modern server
at all.> 2026-07-28 removed C<logging/setLevel> in favour of a
per-request C<_meta> level, and a server B<must not> emit
C<notifications/message> for a request that did not carry one — so an
C<on-log> hook without a C<log-level> will never fire. On a legacy server
the level is a session setting, sent with C<logging/setLevel>, which this
client leaves to the caller.

=head3 Progress

Progress is opt-in the same way, from this end: a request carries a
C<progressToken> when the caller wants to be kept posted, and a server
must not report on one that did not. C<:&on-progress> is both halves of
that for the LLM bridge — wiring it is what attaches a token to every
tool call the bridge runs, and it is where the reports come back:

=begin code :lang<raku>
my $mcp = MCP::Client.connect-stdio(
    command     => 'server',
    on-progress => -> %p {
        $ui.progress(%p<tool-call-id>, %p<progress>, %p<total>, %p<message>);
    },
);
=end code

The payload is C<< { tool-call-id, progress, total?, message? } >>.
C<tool-call-id> is the id of the call the model made — the join key back
to the tool card it belongs on — and the two optional members are there
only when the server sent them. The token itself is minted per call
rather than being the call id, which a retried call would reuse, and it
is forgotten the moment the call answers: a report that arrives late, or
one quoting a token this client did not mint, is dropped rather than
attributed to the wrong tool.

Like C<on-log>, the hook fires B<on the client's reader thread>, ahead of
the answer it belongs to. Treat it as a leaf — hand the payload to a
queue or a store and return — because work done there delays every
message behind it on the connection, and calling back into the client
from it deadlocks on the request in flight.

Outside the bridge, C<call-tool> takes a C<:$progress-token> of your own
and the notifications arrive on C<notifications> like any other. Both
eras spell the token the same way, so a legacy server reports progress
too.

=head3 Liveness and shutdown

C<ping> is a legacy utility: 2026-07-28 removed it, and a strictly modern
server answering "no such method" is healthy rather than broken. The
C<Bool> says which happened without the caller having to know the
history — C<True> for an answer, C<False> for a server that does not
implement it, and any other failure raised.

C<close> shuts the connection down: the transport closes (for stdio,
stdin is closed, the child is given C<:$kill-grace> seconds and then
killed), anything outstanding fails with
C<X::MCP::Client::TransportClosed>, the cache is dropped and the
notification Supply finishes. It is idempotent, and safe on a connection
whose server died an hour ago.

=begin code :lang<raku>
my $mcp = MCP::Client.connect-stdio(command => 'srv');
LEAVE $mcp.close;    # the child process is yours to reap
=end code

=head2 Multi round-trip requests

A modern server may answer C<tools/call>, C<resources/read> or
C<prompts/get> with C<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 B<retries> the original call under a fresh JSON-RPC id,
carrying the answers and the server's opaque C<requestState> back with
it. That is the whole reason the modern protocol can be stateless, and it
is handled transparently: C<call-tool> returns the final result, however
many round trips it took.

Wire a hook for each kind of input you are willing to serve. Each is
called with the server's request (C<method> and C<params>) and returns
the body of the matching result:

=begin code :lang<raku>
my $mcp = MCP::Client.connect-stdio(
    command => 'srv',

    # ElicitRequest -> ElicitResult
    on-elicit => -> %request {
        my %schema = %request<params><requestedSchema>;
        { action => 'accept', content => $ui.ask(%request<params><message>, %schema) };
    },

    # CreateMessageRequest -> CreateMessageResult
    on-sample => -> %request {
        my $text = $my-llm.complete(%request<params><messages>);
        {
            model   => 'my-local-model',
            role    => 'assistant',
            content => { type => 'text', text => $text },
        };
    },

    # ListRootsRequest -> ListRootsResult
    on-list-roots => -> %request {
        { roots => [ { uri => 'file:///srv/project', name => 'project' } ] };
    },
);
=end code

=head3 Which hooks you set is what you declare

The client's capabilities are B<derived from the hooks>, not configured
separately, so "I declared elicitation but wired no handler" cannot
happen. A server is forbidden from asking for input the client never
declared, which makes an unset hook both a refusal and a promise never to
be asked:

=begin code :lang<raku>
MCP::Client.connect-stdio(command => 'srv').declared-capabilities;
# {}

MCP::Client.connect-stdio(command => 'srv', on-elicit => &ask).declared-capabilities;
# { elicitation => { form => {} } }
=end code

Elicitation is declared form-mode only. URL mode requires the client to
open a browser, which a callback returning an C<ElicitResult> cannot be
assumed to do; C<%.client-capabilities> overrides the derivation for a
caller who can.

=head3 Declining

Refusal is a first-class answer, not a failure. An unset hook, a hook
that throws, or a hook that returns something that is not a hash all
decline:

=item B<elicitation> — C<< { action => 'decline' } >>, which is a shape the
      specification defines and servers are required to handle.
=item B<roots> — an empty root list.
=item B<sampling> — there is no decline shape for it, so the key is left
      out of C<inputResponses> entirely. The server SHOULD then ask again
      or give up; either way the round budget bounds it.

A hook that throws is declined and reported through C<:&on-warn>, so a
bug in a permission dialog degrades to "the server was told no" rather
than taking the tool call down with it.

=head3 The round budget

A server that never stops asking cannot pin the client: after
C<:$max-input-rounds> retries (eight by default) the call fails with
C<X::MCP::Client::InputLoopExceeded>. Raise it for a server that
legitimately conducts a long conversation; lower it to one for a caller
that will tolerate exactly one question.

=head3 A permission UI, wired

The realistic shape — every elicitation goes in front of a human, with a
remembered allow-list so the same question is not asked twice, and a
default of "no" when there is nobody to ask:

=begin code :lang<raku>
my %remembered;
my $ask-lock = Lock.new;

sub ask-the-user(%request --> Hash) {
    my %params  = %request<params> // {};
    my $message = (%params<message> // 'The server is asking for something.').Str;

    $ask-lock.protect: {
        # Hooks are called on whichever thread the call is running on, and two
        # tools may be in flight at once. One dialog at a time.
        if %remembered{$message}:exists {
            return { action => 'accept', content => %remembered{$message} };
        }

        my $answer = $ui.prompt($message, %params<requestedSchema>);

        # 'decline' answers the question with a no; 'cancel' says the user
        # dismissed the whole request rather than answering it. The server is
        # required to handle both.
        without $answer {
            return { action => 'cancel' };
        }

        %remembered{$message} = $answer if $ui.remember-this;
        return { action => 'accept', content => $answer };
    }
}

my $mcp = MCP::Client.connect-stdio(
    command          => 'srv',
    on-elicit        => &ask-the-user,
    max-input-rounds => 3,
    on-warn          => -> Str:D $m { $log.warn("mcp: $m") },
);

{
    CATCH {
        when X::MCP::Client::InputLoopExceeded {
            note "'{.method}' kept asking past {.rounds} rounds; giving up";
        }
    }
    $mcp.call-tool('deploy', { env => 'prod' });
}
=end code

Note the C<return>s: C<&ask-the-user> is a C<sub>, so returning from
inside the C<Lock.protect> block works. Written as a pointy block
(C<< -> %request { ... } >>) it would not — a pointy block is a C<Block>, not
a C<Routine>, and C<return> from one dies. Use C<if>/C<with> guards
there instead.

=head2 The LLM bridge and the Registry

C<tools-for-llm> renders a server's catalogue as OpenAI-style function
declarations, and C<execute-tool-calls> runs the calls a model asked for.
The pair is B<byte-compatible with C<MCP::Server>'s>, which is the whole
point: a toolkit running in this process and a server running on the
other side of a pipe are interchangeable to the caller, and a test pins
that parity against a live server.

=begin code :lang<raku>
$mcp.tools-for-llm;
# ({ type => 'function',
#    function => { name => 'read_file', description => 'Read a file',
#                  parameters => { type => 'object', properties => { ... } } } }, ...)

$mcp.execute-tool-calls([
    { id => 'call_1', function => { name => 'read_file',
                                    arguments => '{"path":"/srv/docs/README"}' } },
]);
# ({ role => 'tool', tool_call_id => 'call_1',
#    content => '# Project', is_error => False },)
=end code

Arguments may be a hash or the JSON string most APIs actually send; both
are accepted, as is the empty string models send for a tool that takes
none — that is a call with no arguments, not a broken one, and it runs.
(A non-empty string that is not a JSON object is still an error result:
the model asked for something the tool cannot be given.) Content blocks
are flattened to the text a model reads,
with binary payloads named rather than inlined — a base64 PNG in a
transcript costs a fortune in tokens and tells the model nothing it can
act on. A protocol result's C<structuredContent> is retained separately as
C<structured_content>, byte-compatible with the local server bridge, so a
composer can consume machine metadata without putting it into the transcript.

B<C<execute-tool-calls> never throws.> A malformed call, arguments that
are not a JSON object, a tool the server does not have, a tool that
reported C<isError>, a timeout, an exhausted multi round-trip loop, a
connection that has died — each becomes an entry with C<is_error> set and
the reason as its C<content>. A model that can read what went wrong can
try something else; an exception thrown into the middle of a tool round
cannot be recovered from at all.

The calls also run B<inside the method>, not when the caller first reads
the answers: what comes back is a real C<List>, so a batch that has
returned is a batch that has already happened. Tools have side effects,
and a lazily-reified batch would run them on whatever thread eventually
looked at the result — or, for a run that was cancelled, never.

=head3 How a batch is executed

One call at a time, in model order — unless B<every> call in the batch names
a tool the server annotated C<readOnlyHint> B<and> C<idempotentHint> in its
C<tools/list> catalog, in which case the distinct calls are in flight
together, up to C<$.tool-concurrency> (default C<4>) of them.

=item B<Order is not an outcome.> Result I<n> answers call I<n>, whatever came
back first, each under the C<tool_call_id> of the call it answers.
=item B<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, and the
annotation is the only evidence there is that a call is not part of one.
=item B<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, and one
lot of tokens.

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.
C<< $mcp.tool-annotations('read_file') >> reports what a server said about one
of its tools, and C<< tool-concurrency => 1 >> turns the widening off
entirely. The scheduler is C<MCP::Server::Batch>, shared with
C<MCP::Server>'s local bridge — the same reason the rest of the pair is
byte-compatible.

=head3 The Registry

An agent with more than one tool source has two problems: two servers may
both call their tool C<search>, and the model must be told which one it
is talking to. L<MCP::Client::Registry|lib/MCP/Client/Registry.rakumod>
gives every provider a prefix, rewrites the names it publishes to
C<{prefix}{sep}{name}>, and routes each call back to whichever provider
owns the prefix it was made under.

C<add> is duck-typed — a provider is anything with C<tools-for-llm> and
C<execute-tool-calls>. That is satisfied by an C<MCP::Client>, by an
C<MCP::Server> with no transport under it at all, by another registry
(so registries nest), and by anything you write with those two methods.
Since a registry satisfies the pair itself, it drops straight into a tool
loop in place of a single client.

=begin code :lang<raku>
use MCP::Server;
use MCP::Server::Tool::FileSystem;
use MCP::Client;
use MCP::Client::Registry;

my $tools = MCP::Client::Registry.new;

$tools.add(MCP::Client.connect-stdio(command => 'mcp-server-git'), prefix => 'git');
$tools.add(MCP::Client.connect-http(url => 'https://tools.example.com/mcp'), prefix => 'corp');

my $local = MCP::Server.new(:name<local>, :version<1.0.0>);
$local.plug(MCP::Server::Tool::FileSystem.new(root => '/srv/docs'));
$tools.add($local, prefix => 'fs');

say $tools.tools-for-llm.map({ $_<function><name> });
# (git_log git_diff ... corp_search ... fs_read fs_write ...)

$tools.remove('corp');           # returns the provider, or Nil
say $tools.providers.map({ $_<prefix> });   # (git fs)
=end code

Routing is by longest matching prefix, so prefixes may be extensions of
each other (C<fs> and C<fs_ext>) and tool names may contain the
separator. Calls are batched per provider and threaded back into the
order the model asked for them, so a model that interleaves calls to
three servers still gets one result per call, in its own order. A
provider that throws fails only its own calls.

=head3 Permissions: the Policy

An agent that can write files is an agent that can write the wrong file.
L<MCP::Client::Policy|lib/MCP/Client/Policy.rakumod> is the Claude
Code-shaped answer to that, client-side: reads go through, anything that
changes the world stops at a prompt, and the human's answer can be
remembered for the rest of the session.

It satisfies the same duck type the registry does, so it stacks anywhere
a provider goes — over a client, over a registry, under a registry, or
several deep with a different rule set at each layer:

=begin code :lang<raku>
use MCP::Client::Policy;

my $policy = MCP::Client::Policy.new(
    provider => $mcp,
    rules    => [
        |MCP::Client::Policy.default-rules,                        # reads, and asking
        { tool => 'fs_write',  decision => 'allow', under => 'scratch' },
        { tool => 'fs_delete', decision => 'deny' },
    ],
    roots  => { fs => '/srv' },      # tool-name prefix → the server's directory
    on-ask => &ask-the-user,
);

$policy.tools-for-llm;               # fs_delete is not on it
$policy.execute-tool-calls(@calls);  # allowed calls forwarded as one batch
=end code

A rule is plain data with two required members — C<tool>, an exact name
or a trailing-C<*> prefix glob, and C<decision>, one of
C<allow>/C<deny>/C<ask> — and a handful of optional narrowers saying
which calls to C<tool> the rule actually has an opinion about:

=item C<under> — a directory; the rule only speaks about calls whose
      location arguments (C<path>/C<from>/C<to>, by convention) fall
      inside it.
=item C<command> — a program basename, exact or a trailing-C<*> glob,
      matched against the call's program (never a path — a C</> in a
      pattern could not fire).
=item C<args> — argv tokens the call's program must B<begin> with, in
      order. Needs a C<command>.
=item C<args-any> — argv tokens, B<any one> of which must appear
      anywhere in the argv. Needs a C<command>.
=item C<check> — the name of a semantic predicate the policy itself
      supplies, evaluated over the same effective command as C<command>
      and C<args>.
=item C<host> — a website: an exact host, or a C<*.> suffix glob
      (C<*.raku.org>), matched against the call's C<url> argument.

A C<sh_run> rule narrowed by command and argv:

=begin code :lang<raku>
{ tool => 'sh_run', decision => 'ask', command => 'git',
  args-any => ['--force', '-f'],
  note => 'force pushes rewrite history', severity => 'danger' }
=end code

fires for any C<git> call whose argv contains C<--force> or C<-f> — a
plain C<git status> is untouched, a C<git push --force> is asked about.
And a C<web_fetch> rule narrowed by host:

=begin code :lang<raku>
{ tool => 'web_fetch', decision => 'allow', host => '*.raku.org' }
=end code

fires for C<https://docs.raku.org/...> but not for
C<https://raku.org/...> (the site itself, not a site under it) or
C<https://evilraku.org/...> (which merely ends in the same letters).

Every predicate present on one rule is ANDed together, and every
matching rule is evaluated with the strongest decision winning —
B<deny E<gt> ask E<gt> allow>, so a serialized rule set can be merged
and re-ordered without changing what it means. Every predicate fails
closed the same way: an B<allow> only fires when every item it can
check comes back a definite C<yes>, while a B<deny> or C<ask> fires on
a C<yes> B<or> on anything it cannot read to a conclusion — and
B<a call no rule matched is asked about>, never allowed.

Containment is B<purely lexical>, segment by segment, and never touches
the filesystem: the paths belong to the server's disk, and symlink truth
is C<MCP::Server::Tool::FileSystem>'s sandbox root to enforce, not this
client's to second-guess. Segment-wise also means C</tmp/root2> is not
inside C</tmp/root>, however much the strings agree.

The answer to "is it inside?" therefore has three values. C<unknown> —
a C<..> segment, a null byte, a backslash, an absolute path with no root
to measure it against, a missing or non-string argument, arguments that
are not JSON — fails closed in both directions: an C<allow> rule needs
B<every> location it was given to be C<yes>, while a C<deny> or C<ask>
rule fires on B<any> C<yes> or C<unknown>.

C<&on-ask> gets one seam and two kinds of question. A permission
question carries the tool, the parsed arguments, a copy of the call, the
rule that provoked it, the locations it names and a C<suggestion> — the
deepest directory containing everything the call touches — and is
answered with C<allow-once>, C<deny-once>, C<always-allow> or
C<always-deny>, the last two leaving a session grant behind. The other
kind is the server's own elicitation, forwarded by C<elicit-hook> and
answered with a bare C<ElicitResult>. Both go through one lock, because
there is one human and they can answer one question at a time. That lock
has no timeout — a human deliberating stalls every waiting batch, so a
UI wanting a deadline imposes its own inside the callback — and it is
non-reentrant: an C<&on-ask> that dispatches another policy-mediated
call from inside the prompt handler deadlocks on its own question.
Treat the callback as a leaf.

=begin code :lang<raku>
my $policy;
my $mcp = MCP::Client.connect-stdio(
    command   => 'mcp-filesystem',
    args      => ['/srv'],
    on-elicit => -> %request { $policy.elicit-hook.(%request) },
);
$policy = MCP::Client::Policy.new(:provider($mcp), :&on-ask, roots => { fs => '/srv' });

$policy.grants;   # plain data: persist it, hand it back next session as `grants`
=end code

C<&on-grant> is told when that list changes: it is handed the whole set
(already copied) the moment an C<always-> answer adds one, so a host does
not have to poll C<.grants> or infer it from an answer it watched go
past. It fires while the call that provoked the grant is still being
decided, which is what lets an engine mirror the grant into a session in
time for the next call in the same batch — and, for the same reason, it
is as much of a leaf as C<&on-ask> is. A hook that throws is swallowed:
the grant is already made, and a failed listener is no reason to refuse
the call.

=begin code :lang<raku>
my $policy = MCP::Client::Policy.new(
    :$provider, :&on-ask,
    on-grant => -> @grants { spurt 'grants.json', to-json(@grants) },
);
=end code

A fleet needs one more thing: a grant belongs to the B<human>, not to
the policy that happened to ask. Ten children with ten policies will ask
the same question ten times unless they share a book, so
L<MCP::Client::Policy::Grants|lib/MCP/Client/Policy/Grants.rakumod> is
one — a small lock-protected list any number of policies consult and any
of them can add to. Hand it to all of them as C<grants-store> and the
doctrine is B<one session, one grant set>: the human says "always" once,
and every agent alive then, plus every agent started afterwards, is
bound by it.

=begin code :lang<raku>
my $grants = MCP::Client::Policy::Grants.new(grants => $session.grants);

my $parent = MCP::Client::Policy.new(:$provider, :&on-ask, grants-store => $grants);
my $child  = MCP::Client::Policy.new(:$provider, :&on-ask, grants-store => $grants);
=end code

With a store wired, an C<always-> answer is written there rather than to
the policy that asked, and every policy reads the store on B<every>
decision, so a grant is in force for a call a millisecond later on
another thread. C<.grants> then renders the B<effective> set — this
policy's own grants, then the book — which is what C<&on-grant> is
handed and what a session should persist; seed a resumed session into
the store rather than into C<grants> so a rule is not counted twice.
None of it widens anything: grants are still consulted only after the
static rules have said C<ask>, so no shared grant can overrule a C<deny>
rule or the danger floor.

When the suggestion is too fine-grained to be worth offering — one
directory, one tool, ten more questions coming — an answer can carry
C<rules> instead of C<rule> and remember several at once:

=begin code :lang<raku>
{
    action => 'always-allow',
    rules  => [
        { tool => 'fs_write', under => '/srv/work' },
        { tool => 'fs_edit',  under => '/srv/work' },
        { tool => 'fs_mkdir', under => '/srv/work' },
    ],
}
=end code

Each is validated exactly as a single rule is, and the answer is all or
nothing: one unusable rule refuses that call and remembers none of them.

Wire C<on-elicit> B<only> when there is somebody to ask — setting it is
what declares the elicitation capability, and a server told it may ask
will ask. C<.interactive> is the guard. Headless, every C<ask> outcome is
an C<is_error> result saying there was nobody to ask, which is a refusal
the model can read rather than a hang.

Two things to know before wiring one up. Rules name tools B<as this
policy sees them>: a registry strips its prefix before passing a call
on, so a policy under a registry sees C<read> where a policy over it sees
C<fs_read>, and a rule at the wrong layer silently matches nothing (the
symptom is everything asking). And grants are consulted B<only> once the
static rules have said C<ask>, so an explicit C<< { tool => '*',
decision => 'ask' } >> rule means "keep asking" — which is why the
default rules do not include one.

=head3 File leases: the Leases layer

Two agents editing one checkout will, eventually, edit one file. Not at the
same instant — the calls are serialized by the server — but across the window
that matters: read the file, think about it, write it back. The second
agent's read happened before the first agent's write, and its write throws
that away, and nothing in the permission layer notices, because both calls
were things their agent was perfectly entitled to make.
L<MCP::Client::Leases|lib/MCP/Client/Leases.rakumod> is the smallest thing
that closes that window: an in-process, advisory, bounded-wait lock table
with two tools in front of it.

It satisfies the same duck type a registry and a policy do, so it stacks
anywhere a provider goes — but it is meant to be built B<one instance per
agent>, each carrying that agent's own id, all of them pointing at B<one>
C<MCP::Client::Leases::Table> the engine owns for the whole workspace. The
composer is where a call is routed and judged; the table is where contention
is actually resolved, which is why it is the one object every agent's stack
has to share:

=begin code :lang<raku>
use MCP::Client::Leases;
use MCP::Client::Leases::Table;
use MCP::Client::Policy;

my $table = MCP::Client::Leases::Table.new;   # one per workspace

sub provider-for(Str:D $agent-id) {
    MCP::Client::Policy.new(
        provider => MCP::Client::Leases.new(
            inner      => $registry,
            table      => $table,
            agent-id   => $agent-id,
            roots      => { fs => '/srv/work', lock => '/srv/work' },
            concurrent => { $engine.live-agents },   # > 1 turns strict mode on
        ),
        rules => MCP::Client::Policy.default-rules,
        :&on-ask,
    );
}
=end code

Every C<Leases> instance publishes two tools, without any registry prefix, and
routes on those names ahead of everything else:
C<lock_acquire(paths, ttl?, wait?)> claims paths, all or nothing;
C<lock_release(paths?)> gives them back, or everything the caller holds when
C<paths> is omitted.

B<Acquisition waits, but never for ever.> C<lock_acquire> tries at once and,
if somebody else has the path, polls the table about once a second for up to
C<wait> seconds — 90 by default, capped at the table's C<default-ttl> — before
returning the refusal. Waiting here rather than in the model is the entire
point: a refused agent's "try again" is a fresh round trip carrying the whole
conversation, so two agents sharing one file can spend hundreds of thousands
of prompt tokens taking turns at a lock that was free after four seconds.
Polling a hash in-process costs nothing. Safety comes from the wait being
bounded twice over — by C<wait>, and by the TTL every lease expires at, which
the cap ties it to — and from nothing being held while waiting, so no cycle of
waiters can form. C<wait: 0> is the original never-blocking acquire, to the
byte. There is still B<no queue and no fairness>: a freed lease goes to
whoever's poll lands first.

The refusal at the end of the wait is a value, not a throw: it names the
holder and how long they have held it, which is enough for a model to work on
something else and come back rather than treat the refusal as a hard failure.

Because a waiting agent is not a working one, the composer takes two optional
callbacks — C<on-wait-begin> and C<on-wait-end> — which fire around a wait that
really begins (never around an immediate grant, and never for C<wait: 0>), so a
host that caps concurrency by counting working agents can stop counting one
that is parked on a lock. C<on-wait-end> fires on every exit, including an
exception. A C<cancelled> thunk, asked once per poll, ends a wait early when
the run it belongs to has been cancelled; unlike C<concurrent> it fails
B<open>, because over-waiting costs seconds while a falsely abandoned acquire
costs correctness. Both hooks are shielded — a host whose bookkeeping throws
must not turn a granted lock into an error the model has to interpret.

Enforcement follows the tools that change things — C<fs_write>, C<fs_edit>,
C<fs_move> and C<fs_delete> by default, C<:enforced-tools> for a server that
calls its mutating tools something else. B<Another holder's lease always
refuses>, whether or not anything is strict: that is the one rule this layer
enforces unconditionally. Everything else depends on C<:&concurrent>, a
thunk answering how many agents are live right now. With none set, or with
one live agent, an unleased mutation goes straight through — locking
discipline is overhead nobody needs when there is nobody to contend with.
The moment C<&concurrent> reports more than one, the layer goes B<strict>:
a mutating call must be covered by a lease this agent holds, or it is
refused with an error that teaches the fix (call C<lock_acquire> first).
Strictness relaxes on its own once the last sibling drains. The thunk fails
B<closed> — a throw, or an answer that cannot be read as a count, is taken
as "concurrent" — because the cost of over-strictness is a model asking for
locks it did not need, and the cost of under-strictness is two agents
silently overwriting each other's work, and only one of those shows up in
the transcript.

A lease lasts C<default-ttl> seconds (300 on the table, unless a composer or
a C<lock_acquire> call says otherwise), and a holder re-acquiring a path it
already holds refreshes that clock rather than stacking a second lease — the
way a long edit keeps its claim alive without a second kind of call. Leases
come back four ways: the model calls C<lock_release>; the engine releases
them off the agent's run being C<drained> — C<$run.drained.then({
$table.release-holder($agent-id) })> — never off C<result>, since a detached
write may still be landing when the answer is already in; a wedged holder's
claim simply expires; or the whole table is emptied at shutdown with
C<$table.release-all>.

None of this reaches outside the process. A lease is advisory, does not
survive the process, and nothing but this table honours it — the filesystem
truth stays with the server's own sandbox, and C<fs_edit>'s exact-match
C<old-string> is still what protects the single-call race a lease is not
meant to cover.

=head3 Why a call was made: the Reasons layer

A tool call tells you what an agent is about to do. It does not tell you
why, and "why" is most of what a human needs to answer a permission prompt
in under a minute — C<fs_write> to C<config/app.yml> is either the change
that was asked for or the beginning of a bad afternoon, and the arguments
look the same either way. L<MCP::Client::Reasons|lib/MCP/Client/Reasons.rakumod>
is the smallest thing that gets the why onto the record: it adds one
optional C<reason> string parameter to every tool declaration on the way
out, and deletes it from every call's arguments on the way back in, so the
model fills it in because it is declared where the model is looking, and the
server never learns the parameter exists.

=begin code :lang<raku>
use MCP::Client::Reasons;
use MCP::Client::Policy;

my $policy = MCP::Client::Policy.new(
    provider => MCP::Client::Reasons.new(provider => $registry),
    rules    => MCP::Client::Policy.default-rules,
    :&on-ask,
);

# The model now writes:
#   fs_write { "path": "src/app.raku", "content": "...",
#              "reason": "Add the missing guard clause you asked for" }
#
# The permission prompt sees the reason (it is in the call's arguments), the
# tool card renders it, and the server is handed the call without it.
=end code

Where it sits in the stack is the whole design: B<below> the policy, and
B<above> everything else —

=begin code
Policy( Reasons( Subagents( ...( Registry ) ) ) )
=end code

Below the policy because C<&on-ask>'s request carries a copy of the call, and
the call still has C<reason> in its arguments at that point — put this layer
above the policy instead and the prompt shows a human a call the field has
already been stripped from, which is the one thing this layer exists to
prevent. Above everything else because every provider under it gets its
declarations augmented too — the subagent composer's C<task> tool included,
delegating being exactly the kind of call a human wants the why of — and
because no layer that inspects arguments (a lease layer locating paths, an
escalation layer matching a command) should ever see a parameter that is not
the server's own.

A tool that already declares its own C<reason> parameter is left B<completely
alone>, in both directions: not augmented a second time, and — more
importantly — not stripped, since that C<reason> is a real argument its
server expects to receive. The general rule is: when in doubt, do not strip.

Two things follow from a reason being written by the model, about its own
intentions, in the same sampling step as the arguments — it is a B<claim>,
not evidence, and nobody has checked it:

=item A UI must keep the arguments primary. Render the reason as supporting
      text next to the command, the path and the diff — never instead of
      them, and never in a way that lets a human approve a call having read
      only the reason. A model that is wrong about what it is doing writes a
      sincere reason for the wrong call.
=item Machine policy must never consume it. The rule engine, the danger
      floor and any auto-mode classifier decide on the tool name, the
      arguments and the paths — never on this field. A permission layer that
      read the reason would be one a model could talk its way through by
      writing nicer sentences, which is precisely the property the static
      rules exist not to have.

Because the strip happens at forward time and nowhere else, the reason
stays in the assistant message the model produced — a session transcript, a
replay and a resumed run all still have it, with no schema change anywhere.
Read it back with the parameter name this module publishes as
C<MCP::Client::Reasons::REASON-PARAM>.

=head3 Arguments nobody declared: the UnknownKeys layer

A tool call arrives as a JSON object, and nothing between the sampler and the
server has to agree about what is in it. A truncated generation that a
constrained decoder closed into I<valid> JSON, a provider that re-serialised
the arguments through a lossy shape, a model that invented a plausible
parameter: all three produce a call whose keys are not the keys the tool
declared, and a server that ignores the extras does four fifths of the job
with nobody the wiser.

L<MCP::Client::UnknownKeys|lib/MCP/Client/UnknownKeys.rakumod> makes that
visible and does nothing else. It caches the C<properties> of every declaration
it passes through, compares each call's argument keys against them, and calls
C<&on-warn> once per (tool, undeclared key) — then forwards the caller's own
call object, untouched, and returns the inner provider's results as they came.

=begin code :lang<raku>
use MCP::Client::UnknownKeys;

my $audited = MCP::Client::UnknownKeys.new(
    provider => MCP::Client::Reasons.new(provider => $registry),
    on-warn  => -> %warning {
        note "{%warning<tool>} ({%warning<id>}) carried undeclared argument(s) "
            ~ "{%warning<unknown-keys>.join(', ')}; it declares "
            ~ %warning<declared-keys>.join(', ');
    },
);

my $policy = MCP::Client::Policy.new(provider => $audited, :@rules, :&on-ask);
=end code

It is a B<smoke alarm, not a valve>: it never refuses a call, strips a key,
rewrites arguments, delays a batch or turns an unknown key into an error a
model has to interpret, and an inner provider that throws throws straight
through it. Rejection is right where the argument list is known exactly and the
cost of acting on a mangled call is unbounded — spawning a subagent on a
truncated brief, say, which a delegation tool is free to validate strictly on
its own account. For the rest of a toolkit a client-side refusal would be a
second opinion about somebody else's schema, and MCP servers are free to accept
arguments they never advertised.

It is quiet unless it has something to say. A tool it never saw listed, a tool
that declared no properties, arguments that will not parse or are not an object
at all, and the empty string a model sends for an argument-free call are all
somebody else's to report. C<reason> is always tolerated, declared or not, so a
stack with the reason layer switched on produces no false positives. And each
(tool, key) pair is reported B<once per instance> — a model that has decided
C<fs_read> takes a C<recursive> flag decides it again on every call, and a
warning per call would bury the first — while a new key on the same tool is a
new fact and is reported.

Its seat is between the two layers whose rewriting it has to see the right side
of:

=begin code
Policy( UnknownKeys( Reasons( ...( Registry ) ) ) )
=end code

Under the policy, so the arguments it reads are the model's own. Over the
reason layer, so the declarations it caches are the B<augmented> ones the model
was actually shown — which is what makes a C<reason> in a call match a declared
property rather than look like the very thing this layer reports.

=head3 A full tool loop

Everything above, wired to
L<LLM::Chat::ToolLoop|https://github.com/m-doughty/LLM_Chat>. Note that
C<LLM::Chat> is B<not> a dependency of this distribution — the bridge is a shape, not
a coupling, and the same two methods drive any loop that speaks
OpenAI-style function calling.

=begin code :lang<raku>
use MCP::Server;
use MCP::Server::Tool::FileSystem;
use MCP::Client;
use MCP::Client::Registry;
use LLM::Chat::ToolLoop;
use LLM::Chat::Conversation::Message;

# 1. A toolkit in this process. No transport, no serialisation, no subprocess.
my $local = MCP::Server.new(:name<local-tools>, :version<1.0.0>);
$local.plug(MCP::Server::Tool::FileSystem.new(root => '/srv/docs'));

# 2. A server on the other side of a pipe.
my $remote = MCP::Client.connect-stdio(
    command     => 'mcp-server-git',
    args        => ['--repository', '/srv/project'],
    client-name => 'my-agent',
    on-warn     => -> Str:D $m { note "mcp: $m" },
);
LEAVE $remote.close;

# 3. One namespace over both.
my $tools = MCP::Client::Registry.new;
$tools.add($local,  prefix => 'fs');
$tools.add($remote, prefix => 'git');

# 4. A model that cannot tell them apart.
my $loop = LLM::Chat::ToolLoop.new(
    :$backend,
    tools          => $tools.tools-for-llm,
    execute-tools  => -> @calls { $tools.execute-tool-calls(@calls) },
    on-tool-call   => -> %call   { note "→ {%call<function><name>}" },
    on-tool-result => -> %result { note "← {%result<content>}" },
);

my $answer = $loop.chat-completion-stream([
    LLM::Chat::Conversation::Message.new(
        role    => 'user',
        content => 'What changed in the docs since the last release?',
    ),
]);

react whenever $answer.supply -> $chunk { print $chunk }
=end code

C<examples/toolloop.raku> is that program, runnable, with a scripted
backend in place of a real model so it costs nothing and produces the
same output every time.

=head2 Error handling

Everything this client raises on its own behalf is an C<X::MCP::Client>,
so one C<CATCH> clause separates "the MCP conversation went wrong" from
"the code around it went wrong". The subclasses carry the context you
would otherwise have to scrape back out of a message string.

=begin table
Exception                          | Raised when                                                               | Carries
===================================|===========================================================================|========
X::MCP::Client                     | A misuse of this API: an unknown option, an invalid setting               | detail
X::MCP::Client::Timeout            | A request outlived its budget. The connection survives it                 | seconds, method, id
X::MCP::Client::ServerGone         | The child process exited, or the connection died, with work outstanding   | exit-code, signal, command, stderr-tail
X::MCP::Client::Protocol           | A JSON-RPC error, or a reply the protocol does not allow                  | code, data
X::MCP::Client::UnsupportedVersion | No overlap between the versions we speak and the ones the server speaks   | requested, supported
X::MCP::Client::Cancelled          | The caller kept the cancelled promise                                     | method, id
X::MCP::Client::InputLoopExceeded  | A multi round-trip request kept asking past max-input-rounds              | rounds, method
X::MCP::Client::TransportClosed    | close was called, or a failure took the connection with it                | detail
X::MCP::Client::SpawnFailed        | The server command could not be started                                   | command, args
=end table

A tool that fails is B<not> on that list: C<isError> is a result. Nor is
a model-facing failure — C<execute-tool-calls> converts every one of the
above into an C<is_error> entry rather than throwing.

=begin code :lang<raku>
use MCP::Client;
use MCP::Client::Exceptions;

{
    CATCH {
        when X::MCP::Client::Timeout {
            note "gave up on {.method // 'request'} after {.seconds}s";
        }
        when X::MCP::Client::ServerGone {
            note "server died (exit {.exit-code // '?'})";
            note "  last words: {.stderr-tail}" if .stderr-tail.chars;
        }
        when X::MCP::Client::Protocol {
            note "server refused: {.message} (code {.code // 'none'})";
            note "  it offers: " ~ (.data<supported> // []).join(', ')
                if .code.defined && .code == -32022;
        }
        when X::MCP::Client {
            note "MCP failure: {.^name}: {.message}";
        }
    }
    $mcp.call-tool('flaky', {});
}
=end code

C<.stderr-tail> is the one worth wiring into any operator-facing log. A
misconfigured stdio server — a missing API key, a bad path, a node
version it cannot run under — writes the entire diagnosis to stderr and
then exits, and that tail is the only place it survives.

=head2 Limitations

Each of these is a decision, and each has a reason. None of them is a bug
report.

=item B<No legacy Streamable HTTP.> The HTTP transport is 2026-07-28
      only: no C<Mcp-Session-Id>, no C<GET> stream, no C<Last-Event-ID>
      resumability, no HTTP+SSE. Legacy servers in the wild are
      overwhelmingly stdio, and stdio is dual-era here, so a legacy
      server is not locked out of the client — only out of this
      transport.
=item B<No C<subscriptions/listen>.> A subscription's only job is pushing
      cache invalidation, and C<ttlMs> expiry is spec-compliant without
      it. The cost is staleness bounded by the TTL the server itself
      chose, and C<:refresh> is always available.
=item B<No tasks extension.> C<io.modelcontextprotocol/tasks> —
      long-running work polled through a task id — is not implemented.
      A long tool call is a long request here, bounded by its timeout.
=item B<No OAuth.> There is no authorization-server flow, no token
      refresh, no dynamic client registration. Static credentials go in
      C<connect-http>'s C<%headers>; anything richer belongs in front of
      the endpoint.
=item B<C<ping> is legacy-only.> 2026-07-28 removed the utility, so
      against a modern server it reports C<False> ("the server does not
      implement it") rather than liveness. Liveness in the modern era
      belongs to the transport.
=item B<Elicitation is declared form-mode only> unless
      C<%.client-capabilities> says otherwise, because URL-mode
      elicitation asks the client to open a browser.

=head2 Testing

=begin code :lang<shell>
prove6 -I. t                                   # against installed dependencies, as CI does
prove6 -Ilib -It/lib -I../MCP-Server/lib t/    # against a sibling MCP-Server checkout
=end code

Sixteen files, from the protocol units up: a scripted transport double
for the era machine and the round-trip loop, an in-process transport that
puts the client in front of a live C<MCP::Server> with no wire between
them, a real child process for the stdio transport (including one that
kills itself mid-request), and a live HTTP server on a random port.

Four of them are cross-checks rather than unit tests. C<t/13> is a
golden-parity test: the same toolkit behind a local C<MCP::Server> and
behind an C<MCP::Client> must produce identical bridge output, which is
what makes "interchangeable" a claim rather than a hope. C<t/14> puts a
policy over that same live server, including a real elicitation answered
through C<elicit-hook>, and re-runs C<t/13>'s argument shapes through an
allow-everything policy to pin the two argument parsers together.
C<t/15> runs the multi round-trip loop against an C<MCP::Server> that
really does elicit — the client half of the contract, checked against the
server half rather than against a script. C<t/16> does the same for
progress, against a server whose tools really do report it, and pins the
other half of "when does a tool call happen": that a batch has finished
running by the time C<execute-tool-calls> returns.

The fixtures under C<t/lib> are reusable, and are meant to be: C<t/lib> on
the include path gives you C<MCP::Client::Test::FakeTransport> (scripted
answers, and notifications you can push at a client whenever you like),
C<MCP::Client::Test::InProcessTransport> (a live C<MCP::Server>, no wire),
C<MCP::Client::Test::TestKit> (one tool of every shape) and
C<MCP::Client::Test::ProgressKit> (tools that report progress, for testing
anything built on the C<on-progress> hook).

The example is runnable too, and needs no network and no API key:

=begin code :lang<shell>
raku -Ilib -It/lib -I../MCP-Server/lib -I../LLM-Chat/lib -I../Template-Jinja2/lib \
     examples/toolloop.raku
=end code

=head2 Author

Matt Doughty

=head2 License

Artistic-2.0

=end pod
