Many MCP servers behind one tool list: a supervisor of one
ConduitMcp.Client per server, plus the functions that list their tools
under collision-free names and route a model's tool call to the right
server through the governed call pipeline (ConduitMcp.Client.Pipeline).
children = [
{ConduitMcp.Client.Hub,
name: MyApp.Hub,
policy: [allow: ["github/get_*", "fs/read_*"], ask: ["github/*"], deny: ["*/delete_*"]],
on_ask: &MyApp.Approvals.ask/2,
hooks: [post_tool_call: [{:all, &MyApp.Audit.result/3, id: :audit}]],
servers: [
github: [transport: {ConduitMcp.Client.Transport.StreamableHTTP, url: "..."}],
fs: [transport: {ConduitMcp.Client.Transport.Stdio, command: "mcp-fs"}, trusted: true]
]}
]
{:ok, toolset} = ConduitMcp.Client.Hub.list_tools(MyApp.Hub)
# offer the toolset to a model (ConduitMcp.Client.Adapters), then:
{:ok, result} = ConduitMcp.Client.Hub.call_tool(MyApp.Hub, "github__get_issue", args, toolset: toolset)The application starts no hub; add one to your own supervision tree.
Options are validated when the child spec is built
(ConduitMcp.Client.Hub.Config), so a typo, a duplicate server id or a
missing :policy fails at boot naming it.
A policy is required (R6, PO-2)
A hub hands tools to a model, so it refuses to start without :policy.
To allow everything, say so: policy: :allow_all. Rules match the
{server_id, tool} pair ("github/create_*"), never the exposed name
(PO-3). With no matching rule a call is asked about when there is an
:on_ask approver and denied otherwise.
Servers and their options
servers: is [{server_id, client_opts}] in the order their tools are
listed. A server_id is operator configuration
(^[a-z][a-z0-9_]{0,31}$, no __, no trailing _, unique), never the
server's self-reported serverInfo.name, which is display only. Each
server runs as a ConduitMcp.Client named {:hub_server, hub, server_id}
(client/2), so every ConduitMcp.Client function (resources, prompts,
status/1) works on it directly.
The hub's governance options (:policy, :on_ask, :ask_timeout,
:grant_ttl, :hooks, :hook_timeout, :tools, :validate,
:max_result_bytes_for_llm, :clock) and :telemetry apply to every
server; the same key in a server's client_opts overrides them for that
server, except:
:hooks- the hub's hooks of an event run before the server's (both must be keyword lists);:tools- the server's:excludepatterns are added to the hub's, and its:includepatterns, when it has any, replace the hub's;:policy- may be replaced (:allow_allincluded) but never removed.
trusted: true is per server: it lets policy annotation rules believe
that server's tool hints (PO-8).
Supervision
The hub is a :rest_for_one supervisor of the OAuth token store (see
"OAuth"), a Task.Supervisor (for
ConduitMcp.Client.Adapters.execute_tool_calls/4 and listing fetches), a
DynamicSupervisor holding the server clients, and a manager process.
Server clients are restart: :temporary: when one exits (a bug, or its
own supervisor giving up after repeated connection crashes) the manager
starts it again after a jittered backoff (:restart_backoff, 1 s doubling
to at most 30 s), so one crash-looping server never exhausts a
supervisor's restart intensity or takes the others down. Each client is
itself a :one_for_all supervisor allowing 3 restarts in 5 s: a
connection that keeps crashing takes its client down within seconds, and
the manager's backoff paces what follows (ST-8). Starting never
blocks: servers are started after start_link/1 returns, and each client
connects in the background.
At most :max_stdio_servers (16) servers run a local process (a
transport whose kind/0 is :stdio), so a configuration or a caller of
add_server/3 cannot fork without bound (ST-8).
add_server/3 and remove_server/2 change the server set at runtime.
Servers added at runtime are not durable: they survive a restart of
the manager but not of the hub (re-add them from your own store;
add_server/3 is idempotent). A server removed at runtime stays removed
until the hub restarts.
OAuth (X-18)
The hub starts one in-memory ConduitMcp.Client.OAuth.TokenStore.ETS
and gives it to every server whose auth: {:oauth, opts} or
auth: {:client_credentials, opts} names no :store (a server's own
:store is kept). Tokens stay keyed by {principal, issuer, resource},
so each server still gets only its own (OA-5), but:
- a client registered dynamically with an authorization server is reused by every server behind that issuer (one registration, not one per server);
- a server client that is restarted (by the manager after a crash, or removed and added again) finds its tokens and needs no new authorization.
The store lives as long as the hub: it is in memory, and a hub restart
loses it. For tokens that survive restarts, name a persistent store
(ConduitMcp.Client.OAuth.TokenStore) in each server's OAuth options.
Tool listing (X-12, HB-1, HB-2, HB-4)
list_tools/2 keeps no aggregated index: it reads each server client's
cache (ConduitMcp.Client.Cache) and builds a ConduitMcp.Client.ToolSet
with ConduitMcp.Client.Naming.build/2:
- only servers whose connection is
:readycontribute; a failed, starting, reconnecting or closed server's tools are left out; - servers in configuration order (runtime additions after), then each server's tools in the order it listed them, which keeps the list stable for prompt caching;
- exposed names are
server_id <> "__" <> tool(plus the optional:prefix), sanitized and length-capped; when several tools map to one exposed name, none is offered, a warning is logged and[:conduit_mcp, :client, :hub, :collision]plus atool.rejectedper tool are emitted; - each server's tools first pass its filter and
:on_tools_listedhooks (with the call's:assignsand:principal), exactly asConduitMcp.Client.cached_tools/2presents them, so a hidden tool is never offered (PO-7).
Building names is memoized by the listed names (not by cache version):
the mapping is a pure function of {prefix, [{server_id, [tool_name]}]},
so a hit is always right, including with per-caller filters and across
client restarts (whose cache versions start over). On 1 000 tools,
Naming.build/2 takes about 8 ms and a hit (one ETS read, then the
toolset's maps) about 2 ms. Collisions are reported when a name list is
first seen, not on every listing.
A server's list is fetched by the hub only when the cache has none, when
it is stale (a list_changed arrived, or the connection was lost) or past
a positive ttlMs, and at most once per :refetch_interval (1 s) per
server; otherwise the cached list is used as is. The client itself already
coalesces list_changed bursts into at most one leading and one trailing
refresh per 250 ms and refreshes cached lists on its own. After
:breaker_threshold (3) consecutive failed fetches, a server's tools are
left out until a fetch succeeds (HB-4).
Calling (HB-2, MR-3)
call_tool/4 resolves the model-issued name with a table lookup in the
toolset (pass the one the model saw as toolset:; otherwise the current
one is built). An unknown name is answered with
{:ok, %ToolResult{is_error: true}} saying so, and nothing is sent to any
server. A known name runs ConduitMcp.Client.Pipeline.run/3 on the owning
server's client: selection, hooks, validation, policy and approval, the
request and its multi-round-trip retries (always to that same server),
post-hooks. A server that is not running, reconnecting or closed gives
{:error, %Error{kind: :unavailable}} at once.
Pass taint: (the server ids whose output is in the model's context) to
let policy rules react to cross-server data flow (HB-3, see
ConduitMcp.Client.Policy.tainted/1).
Telemetry
[:conduit_mcp, :client, :hub, :server_failed | :server_restarted | :collision] (see ConduitMcp.Telemetry); each server's client emits the
usual client events with client: {:hub_server, hub, server_id}.
Summary
Functions
Adds and starts server server_id with client_opts (as in servers:).
Idempotent: when the id is already present, nothing changes (remove it
first to change its options). Raises ArgumentError on an invalid id or
option. A stdio server beyond the hub's :max_stdio_servers is refused
with {:error, %Error{kind: :overloaded, reason: :max_stdio_servers}}
and nothing is started. Not durable across a hub restart.
Calls the tool the model knows as exposed_name with args.
The child spec for a hub. Validates the options (raising ArgumentError
naming a missing :policy, a bad server id or a bad client option); the
id is {ConduitMcp.Client.Hub, name}.
The ConduitMcp.Client name of server server_id in hub, for calling
ConduitMcp.Client functions on it directly.
The tools of every ready server, as a ConduitMcp.Client.ToolSet keyed by
exposed name (see "Tool listing").
Stops server server_id and forgets it (until the hub restarts, for a
server from servers:). {:error, %Error{kind: :not_found}} when the hub
has no such server.
The hub's server ids, in listing order.
Starts a hub (see ConduitMcp.Client.Hub.Config for the options).
Returns once the processes exist; servers are started right after, and
connect in the background.
Each server's state, in listing order: :starting, :failed (waiting
to be started again; attempts counts consecutive failures and reason
is a tag of the last exit), or its connection's state
(ConduitMcp.Client.status/1).
Types
@type hub() :: term()
The :name a hub was started with.
@type server_status() :: %{ id: String.t(), state: :starting | :failed | :connecting | :discovering | :initializing | :ready | :authorizing | :reconnecting | :closed, era: :modern | :legacy | nil, attempts: non_neg_integer(), reason: term() }
A server's state as status/1 reports it.
Functions
@spec add_server(hub(), String.t() | atom(), keyword()) :: :ok | {:error, ConduitMcp.Client.Error.t()}
Adds and starts server server_id with client_opts (as in servers:).
Idempotent: when the id is already present, nothing changes (remove it
first to change its options). Raises ArgumentError on an invalid id or
option. A stdio server beyond the hub's :max_stdio_servers is refused
with {:error, %Error{kind: :overloaded, reason: :max_stdio_servers}}
and nothing is started. Not durable across a hub restart.
@spec call_tool(hub(), String.t(), map(), keyword()) :: {:ok, ConduitMcp.Client.ToolResult.t() | ConduitMcp.Client.Task.t()} | {:error, ConduitMcp.Client.Error.t()}
Calls the tool the model knows as exposed_name with args.
Options
:toolset- theConduitMcp.Client.ToolSetthe model was offered (recommended: resolve against exactly what it saw). Without it the current one is built withlist_tools/2(:assigns,:principal).- every
ConduitMcp.Client.Pipeline.run/3option::principal,:assigns,:taint,:shape,:meta,:timeout,:task, progress options.
Returns {:ok, %ToolResult{is_error: true}} for a name not in the
toolset (nothing is sent), {:error, %Error{kind: :unavailable}} when the
owning server is down, else what ConduitMcp.Client.call_tool/4 returns.
@spec child_spec(keyword()) :: Supervisor.child_spec()
The child spec for a hub. Validates the options (raising ArgumentError
naming a missing :policy, a bad server id or a bad client option); the
id is {ConduitMcp.Client.Hub, name}.
@spec client(hub(), String.t()) :: ConduitMcp.Client.client()
The ConduitMcp.Client name of server server_id in hub, for calling
ConduitMcp.Client functions on it directly.
iex> ConduitMcp.Client.Hub.client(MyApp.Hub, "github")
{:hub_server, MyApp.Hub, "github"}
@spec list_tools(hub(), keyword()) :: {:ok, ConduitMcp.Client.ToolSet.t()} | {:error, ConduitMcp.Client.Error.t()}
The tools of every ready server, as a ConduitMcp.Client.ToolSet keyed by
exposed name (see "Tool listing").
Options
:assigns,:principal- passed to each server's filter functions and:on_tools_listedhooks (as forConduitMcp.Client.cached_tools/2).:timeout- milliseconds a server's list fetch may take (default 5 000); a server that does not answer in time is left out.:refresh-truefetches every ready server's list now, ignoring the cache and:refetch_interval(defaultfalse).
@spec remove_server(hub(), String.t() | atom()) :: :ok | {:error, ConduitMcp.Client.Error.t()}
Stops server server_id and forgets it (until the hub restarts, for a
server from servers:). {:error, %Error{kind: :not_found}} when the hub
has no such server.
@spec servers(hub()) :: {:ok, [String.t()]} | {:error, ConduitMcp.Client.Error.t()}
The hub's server ids, in listing order.
@spec start_link(keyword() | ConduitMcp.Client.Hub.Config.t()) :: Supervisor.on_start()
Starts a hub (see ConduitMcp.Client.Hub.Config for the options).
Returns once the processes exist; servers are started right after, and
connect in the background.
@spec status(hub()) :: {:ok, [server_status()]} | {:error, ConduitMcp.Client.Error.t()}
Each server's state, in listing order: :starting, :failed (waiting
to be started again; attempts counts consecutive failures and reason
is a tag of the last exit), or its connection's state
(ConduitMcp.Client.status/1).