ConduitMcp.Client.Hub (ConduitMCP v0.11.0)

Copy Markdown View Source

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 :exclude patterns are added to the hub's, and its :include patterns, when it has any, replace the hub's;
  • :policy - may be replaced (:allow_all included) 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 :ready contribute; 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 a tool.rejected per tool are emitted;
  • each server's tools first pass its filter and :on_tools_listed hooks (with the call's :assigns and :principal), exactly as ConduitMcp.Client.cached_tools/2 presents 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

Types

The :name a hub was started with.

A server's state as status/1 reports it.

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

hub()

@type hub() :: term()

The :name a hub was started with.

server_status()

@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

add_server(hub, server_id, client_opts)

@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.

call_tool(hub, exposed_name, args \\ %{}, opts \\ [])

Calls the tool the model knows as exposed_name with args.

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.

child_spec(init_arg)

@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}.

client(hub, server_id)

@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"}

list_tools(hub, opts \\ [])

@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_listed hooks (as for ConduitMcp.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 - true fetches every ready server's list now, ignoring the cache and :refetch_interval (default false).

remove_server(hub, server_id)

@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.

servers(hub)

@spec servers(hub()) :: {:ok, [String.t()]} | {:error, ConduitMcp.Client.Error.t()}

The hub's server ids, in listing order.

start_link(opts)

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.

status(hub)

@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).