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_error result;

  • a malformed call — not an object, or with no function — comes back as an is_error result;

  • 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_error results, 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, ...

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.