Registry
NAME
MCP::Client::Registry - one tool namespace over many MCP providers
SYNOPSIS
use MCP::Client;
use MCP::Client::Registry;
my $tools = MCP::Client::Registry.new;
# A remote server, spawned as a child process...
$tools.add(
MCP::Client.connect-stdio(command => 'mcp-filesystem', args => ['/srv']),
prefix => 'fs',
);
# ...and a toolkit running in this very process. Both look the same from here.
my $local = MCP::Server.new(:name<local>, :version<1.0.0>);
$local.plug(MCP::Server::Tool::Shell.new);
$tools.add($local, prefix => 'sh');
say $tools.tools-for-llm.map({ $_<function><name> });
# (fs_read_file fs_write_file ... sh_run ...)
$tools.execute-tool-calls([
{ id => 'call_1', function => { name => 'fs_read_file', arguments => '{"path":"/srv/x"}' } },
]);
DESCRIPTION
An agent with more than one tool source has two problems: two servers may both
call their tool search, and the model must be told which one it is talking
to. A registry solves both by giving every provider a prefix, rewriting the
names it publishes to {prefix}{sep}{name}, and routing a call back to
whichever provider owns the prefix it was made under.
The prefix convention is MCP::Server's own ā $server.plug($kit,
:prefix<x>) namespaces a toolkit exactly this way ā so a tool called
fs_read_file means the same thing whether fs was applied when the toolkit
was plugged into a server or when the server was added to a registry.
Anything that speaks the bridge
add is duck-typed: a provider is anything with a tools-for-llm method and
an execute-tool-calls method. That is deliberately the smallest possible
contract, and it is satisfied by:
an
MCP::Clientā a remote server over stdio or HTTP;an
MCP::Serverā a toolkit running in this process, with no transport anywhere;another
MCP::Client::Registry, because a registry satisfies the pair too, so registries nest;anything you write that implements the two methods.
Since the registry is itself a provider, it drops straight into
LLM::Chat::ToolLoop in place of a single client:
use LLM::Chat::ToolLoop;
my $loop = LLM::Chat::ToolLoop.new(
backend => $backend,
tools => $tools.tools-for-llm,
execute-tools => -> @calls { $tools.execute-tool-calls(@calls) },
on-tool-call => -> %call { note "ā %call<function><name>" },
);
my $stream = $loop.chat-completion-stream(@messages);
react whenever $stream.supply -> $chunk { print $chunk }
Routing
A call is routed by the longest registered prefix that its function name starts
with, so prefixes may be extensions of each other and names may contain the
separator without ambiguity. With fs and fs_ext both registered under the
default _ separator:
$tools.add($basic, prefix => 'fs'); # tag "fs_"
$tools.add($extended, prefix => 'fs_ext'); # tag "fs_ext_"
# fs_read_file ā $basic, called as "read_file"
# fs_ext_read ā $extended, called as "read" (the longer tag wins)
The prefix is stripped before the call is handed on, so a provider never sees a name it did not publish, and never has to know it is inside a registry.
Calls are batched: every call for one provider is passed to it in a single
execute-tool-calls, in the order the model asked for them, and the results
are threaded back into the original positions. A model that interleaves calls to
three servers still gets one result per call, in its own order.
Failure
Nothing a provider does makes execute-tool-calls throw:
a call naming a prefix nobody registered comes back as an
is_errorresult;a malformed call ā not an object, or with no
functionā comes back as anis_errorresult;a provider that throws (a dead connection, a bug) fails only its own calls: every other provider's results are unaffected;
a provider that returns fewer results than it was given calls has the gaps filled with
is_errorresults, so the caller always gets exactly one result per call.
tools-for-llm is the exception, and on purpose: a provider that cannot list
its tools throws, because silently publishing a shorter tool list would leave a
model wondering why a capability it was told about yesterday has vanished. Wrap
the call if you would rather degrade than fail.
EXAMPLES
Take a provider away again ā an MCP server that has gone down, a toolkit switched off by configuration ā and the tool list shrinks accordingly:
my $gone = $tools.remove('fs'); # the provider, or Nil if there was none
$gone.close if $gone ~~ MCP::Client;
say $tools.providers.map({ $_<prefix> }); # (sh)
Group several servers under one prefix by nesting:
my $vendor = MCP::Client::Registry.new;
$vendor.add($jira, prefix => 'jira');
$vendor.add($slack, prefix => 'slack');
$tools.add($vendor, prefix => 'corp');
# corp_jira_search, corp_slack_post, ...