# `ConduitMcp.Client.Hub`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/hub.ex#L1)

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

# `hub`

```elixir
@type hub() :: term()
```

The `:name` a hub was started with.

# `server_status`

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

# `add_server`

```elixir
@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`

```elixir
@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` - the `ConduitMcp.Client.ToolSet` the model was offered
    (recommended: resolve against exactly what it saw). Without it the
    current one is built with `list_tools/2` (`:assigns`, `:principal`).
  * every `ConduitMcp.Client.Pipeline.run/3` option: `: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.

# `child_spec`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

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

The hub's server ids, in listing order.

# `start_link`

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

# `status`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
