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

An MCP client: one supervised connection to one MCP server, speaking the
modern (`2026-07-28`) or a legacy (`2025-11-25`, `2025-06-18`) protocol
era, whichever the server supports.

## Starting

Add a client to your supervision tree. Starting never blocks on the
server: the connection is established in the background.

    children = [
      {ConduitMcp.Client,
       name: MyApp.Docs,
       transport: {ConduitMcp.Client.Transport.InProcess, server: MyApp.DocsServer}}
    ]

Options are validated when the child spec is built, so a typo fails at
boot naming the key; see `ConduitMcp.Client.Config` for all of them.
`:name` may be any term (`{MyApp.Docs, tenant_id}` works) and is how
every function below addresses the client. The application starts no
clients itself; it only runs the registry they register in.

Each client is a supervisor (`:one_for_all`) of a `Task.Supervisor`, the
owner of its governance table (grants, the approval baseline, built
schemas; see "Governance") and a `ConduitMcp.Client.Connection`. Use
`await_ready/2` when you need the handshake done before going on;
requests made earlier simply wait for it (within their own timeout).

## Calling

Every request function runs in the calling process and returns
`{:ok, value}` or `{:error, %ConduitMcp.Client.Error{}}`. A tool that ran
and reported failure is **not** an error: `call_tool/4` returns
`{:ok, %ConduitMcp.Client.ToolResult{is_error: true}}`. Errors are for
protocol, transport and limit failures (match on `kind`).

Request options, accepted by every request function:

  * `:timeout` - milliseconds before the request is cancelled (default
    the client's `:request_timeout`, 60 s).
  * `:on_progress` - a one-arity function, or a pid sent
    `{:mcp_progress, name, params}`, for `notifications/progress`.
  * `:reset_timeout_on_progress` - restart `:timeout` on each progress
    notification, capped by `:max_total_timeout`.
  * `:max_total_timeout` - see above; also caps a deadline paused while
    the client's handler answers a legacy server request tied to the call.
  * `:meta` - a map merged into the request's `_meta`. Keys the protocol
    reserves (`progressToken`, `io.modelcontextprotocol/*`) are dropped.
  * `:assigns` - a map given to `ConduitMcp.Client.Handler` callbacks as
    `ctx.assigns` for input requests made on behalf of this call (default
    the client's `:assigns`).
  * `:traceparent` - the W3C trace context to send in a modern request's
    `_meta` (default: the caller's `Logger.metadata()[:traceparent]`, as
    OpenTelemetry-style integrations set it). Dropped unless well formed;
    a `traceparent` in `:meta` wins. Only `traceparent` is propagated,
    never `tracestate` or `baggage`.

## Eras

With the default `protocol: :auto` the client probes `server/discover`
and falls back to `initialize` only on legacy evidence. The era decides
the wire format; the functions here work in both, with these
exceptions: `ping/2` sends `server/discover` to a modern server,
`set_log_level/2` is a request in legacy and a per-request `_meta` value
in modern, and `notify_roots_changed/1` is legacy only.

In the modern era `call_tool/4`, `read_resource/3` and `get_prompt/4` may
get `resultType: "input_required"`; the client runs the
multi-round-trip loop (`ConduitMcp.Client.Request.MRTR`) in the caller,
answering each input request with its `ConduitMcp.Client.Handler` in the
calling process. An input request the handler does not declare fails the
call with `kind: :protocol` before any callback runs; a callback that
fails or refuses fails it with `kind: :input_handler`. Legacy servers ask
with JSON-RPC requests instead, which the connection hands to the handler
in a supervised task (see `ConduitMcp.Client.Connection`). Both go
through the same safety checks (`ConduitMcp.Client.Handler`).

## Listing and the cache

`list_tools/2` and friends fetch one page. `list_all_tools/2` and friends
fetch every page (capped: 100 pages, 10 000 items, a repeated or
over-1 KiB cursor fails with `kind: :pagination_cycle`) and keep the
result in the client's `ConduitMcp.Client.Cache`, which serves it again
while the server's `ttlMs` hint says it is fresh. `cached_tools/2` and
`tool/3` read the cache without asking the server. A result the server
marks private is served only while the client sends the credentials it
was fetched with (`set_auth/3` changes them; see "Private results" in
`ConduitMcp.Client.Cache`).

Every listed tool goes through intake: a tool whose name is outside the
spec charset (`[A-Za-z0-9_.-]`, 1-128 characters), whose definition is
malformed, whose name repeats, or (over HTTP) whose `x-mcp-header`
annotations are invalid is dropped with a warning and
`[:conduit_mcp, :client, :tool, :rejected]`; the others carry a
`ConduitMcp.Client.Fingerprint`.

## Tasks

`call_tool/4` with `task: :return | :await` lets a long tool call run as a
server task (the modern tasks extension, or a legacy task-augmented
call); see `ConduitMcp.Client.Task`, `get_task/3`, `await_task/3` and
`cancel_task/3`.

## Governance

A plain client sends what it is asked to. Configure any of `:policy`
(`ConduitMcp.Client.Policy`, with an `:on_ask` approver and
`ConduitMcp.Client.Grants`), `:hooks` (`ConduitMcp.Client.Hooks`),
`:tools` (a `ConduitMcp.Client.ToolFilter`) or `:validate`
(`ConduitMcp.Client.SchemaValidator`) and every `call_tool/4` runs
through `ConduitMcp.Client.Pipeline`, in the caller: hidden tools vanish
from listings, pre-hooks and validation run before policy decides on the
final arguments, and post-hooks see the result. Hooks and approvers run
in tasks under the client's `Task.Supervisor`, never in the connection.

## Notifications

`subscribe_notifications/2` sends server notifications to a process as
`{:mcp_notification, name, method, params}`; see
`ConduitMcp.Client.Connection` for coalescing of `list_changed`.
`subscribe/2` asks for updates of one resource in either era (legacy
`resources/subscribe`, modern `subscriptions/listen`); a `list_changed`
invalidates and refreshes the cached list it names.

## Transports

`ConduitMcp.Client.Transport.Stdio` runs a local server process;
`ConduitMcp.Client.Transport.StreamableHTTP` (needs `:finch`) talks to a
remote one; `ConduitMcp.Client.Transport.InProcess` serves a server module
in this VM for tests. Over HTTP, a modern `call_tool/4` sends the tool's
`Mcp-Param-*` headers computed from the cached tool definition (see
`list_all_tools/2`); a server rejecting them (`-32020`) makes the client
refresh the tools and send the call once more.

# `client`

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

The `:name` a client was started with.

# `page`

```elixir
@type page(key, item) :: %{required(key) =&gt; [item], next_cursor: String.t() | nil}
```

A page of a list request; `next_cursor` is `nil` on the last page.

# `server_info`

```elixir
@type server_info() :: %{
  era: :modern | :legacy,
  version: String.t(),
  server_info: ConduitMcp.Client.ServerInfo.t() | nil,
  capabilities: map(),
  instructions: String.t() | nil
}
```

What the server said about itself in the handshake.

# `status`

```elixir
@type status() :: %{
  state:
    :connecting
    | :discovering
    | :initializing
    | :ready
    | :authorizing
    | :reconnecting
    | :closed,
  era: :modern | :legacy | nil,
  version: String.t() | nil,
  reason: term()
}
```

The connection's state as `status/1` reports it. `reason` explains a
`:closed` state and is `nil` otherwise.

# `async_call_tool`

```elixir
@spec async_call_tool(client(), String.t(), map(), keyword()) :: Task.t()
```

`call_tool/4` in a task under the client's `Task.Supervisor`
(`Task.Supervisor.async_nolink/2`): await it with `Task.await/2` or
`Task.yield/2`. A crash in the task does not take the caller down.
Exits with `:noproc` if the client is not running.

    task = ConduitMcp.Client.async_call_tool(:docs, "echo", %{"message" => "hi"})
    {:ok, %ConduitMcp.Client.ToolResult{}} = Task.await(task)

# `authorized`

```elixir
@spec authorized(client()) :: :ok | {:error, ConduitMcp.Client.Error.t()}
```

Tells a client using OAuth (`auth: {:oauth, ...}`) that the host stored
new tokens: a connection waiting for interactive authorization checks the
token store now and resumes, instead of on its next request.
`ConduitMcp.Client.OAuth.handle_callback/3` calls it when given
`client:`. Returns `:ok` without waiting for the check, or
`{:error, %Error{kind: :auth, reason: :oauth_not_configured}}`.

    :ok = ConduitMcp.Client.authorized(:docs)

# `await_ready`

```elixir
@spec await_ready(client(), timeout()) :: :ok | {:error, ConduitMcp.Client.Error.t()}
```

Waits until the client is ready to send requests. Returns `:ok`, or
`{:error, %Error{kind: :timeout}}` after `timeout` ms, or the `:closed`
error at once if the connection gave up.

    :ok = ConduitMcp.Client.await_ready(:docs, 5_000)

# `await_task`

```elixir
@spec await_task(client(), ConduitMcp.Client.Task.t() | String.t(), keyword()) ::
  {:ok, ConduitMcp.Client.ToolResult.t()}
  | {:error, ConduitMcp.Client.Error.t()}
```

Waits for a server task to finish and returns its tool result, polling at
the task's `pollIntervalMs` and answering its input requests with the
client's handler (modern) or reading `tasks/result` (legacy). `:timeout`
bounds the whole wait (default the client's `:request_timeout`); when it
passes the task is cancelled and `{:error, %Error{kind: :timeout}}`
returned. `:assigns` is passed to the handler.

    {:ok, %ConduitMcp.Client.ToolResult{}} = ConduitMcp.Client.await_task(:docs, task)

# `cached_tools`

```elixir
@spec cached_tools(client(), keyword()) ::
  {:ok, [ConduitMcp.Client.Tool.t()]} | {:error, ConduitMcp.Client.Error.t()}
```

The tools of the last `list_all_tools/2` (or `list_changed` refresh) as
cached, fresh or not, without asking the server.
`{:error, %Error{kind: :not_found, reason: :not_cached}}` before the first
listing. Takes `:assigns` and `:principal` (see `list_tools/2`).

    {:ok, tools} = ConduitMcp.Client.cached_tools(:docs)

# `call_tool`

```elixir
@spec call_tool(client(), String.t(), map(), keyword()) ::
  {:ok, ConduitMcp.Client.ToolResult.t() | ConduitMcp.Client.Task.t()}
  | {:error, ConduitMcp.Client.Error.t()}
```

Calls tool `name` with `args` (string- or atom-keyed; sent as given).

A tool that ran and failed is `{:ok, %ToolResult{is_error: true}}`.

## Tasks

  * `task: :return` - if the server runs the call as a task, return
    `{:ok, %ConduitMcp.Client.Task{}}` at once (poll it with
    `get_task/3`, `await_task/3`).
  * `task: :await` - if the server runs the call as a task, poll it until
    it finishes and return its `ToolResult`. `:timeout` bounds the whole
    wait; when it passes the task is cancelled (`tasks/cancel`), as it is
    if the calling process dies.
  * `task_ttl: ms` - the legacy `task.ttl` to request.

Modern servers decide per call whether to create a task; the client
declares the tasks extension only when `:task` is given, so without it the
server must answer directly. Legacy servers get a task-augmented call only
when they declare `tasks.requests.tools.call` and the tool's
`execution.taskSupport` allows it: `"required"` always (awaited when no
`:task` is given), `"optional"` only with `:task`. With `:task`, a call
made before the first handshake waits for it (within `:timeout`), since
the era decides how the call becomes a task; without it the call is
queued as usual and sent plain.

    {:ok, %ConduitMcp.Client.ToolResult{content: [%{"text" => "hi"}]}} =
      ConduitMcp.Client.call_tool(:docs, "echo", %{"message" => "hi"})

## Governance

When the client is configured with `:policy`, `:hooks`, `:tools` or
`:validate`, or the call passes `:shape`, the call runs through
`ConduitMcp.Client.Pipeline` (selection, pre-hooks, validation, policy
and approval, post-hooks); otherwise it is sent as given. Pipeline
options:

  * `:principal` - who the call is made for (in `ctx`, and the key of
    "always allow" grants). Ignored by a plain client.
  * `:shape` - `:llm` to shape the result for a model.

A governed call may also fail with `kind: :not_found` (hidden or unknown
tool), `:denied` (policy, approver or hook) or `:hook` (a pre-hook
failed).

# `cancel_task`

```elixir
@spec cancel_task(client(), ConduitMcp.Client.Task.t() | String.t(), keyword()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}
```

Asks the server to cancel a task (`tasks/cancel`). Cancellation is
cooperative: the task may still finish.

    :ok = ConduitMcp.Client.cancel_task(:docs, task)

# `child_spec`

```elixir
@spec child_spec(keyword()) :: Supervisor.child_spec()
```

The child spec for a client. Validates the options (raising
`ArgumentError` naming a bad key); the id is `{ConduitMcp.Client, name}`,
so one supervisor can hold many clients.

    iex> spec =
    ...>   ConduitMcp.Client.child_spec(
    ...>     name: :docs,
    ...>     transport: {ConduitMcp.Client.Transport.InProcess, server: ConduitMcp.TestServer}
    ...>   )
    iex> spec.id
    {ConduitMcp.Client, :docs}

# `close`

```elixir
@spec close(client()) :: :ok | {:error, ConduitMcp.Client.Error.t()}
```

Closes the connection: pending requests fail with `kind: :closed`, the
transport is closed, and the client stays in `:closed` (answering every
request at once) until `reconnect/1`.

    :ok = ConduitMcp.Client.close(:docs)

# `complete`

```elixir
@spec complete(
  client(),
  {:prompt | :resource, String.t()} | map(),
  {String.t(), String.t()} | map(),
  keyword()
) :: {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}
```

Asks for completions of `argument` of `ref`. Returns the server's
string-keyed `CompleteResult` (`"completion"`).

`ref` is `{:prompt, name}`, `{:resource, uri_template}` or a wire
reference map; `argument` is `{name, value}` or a wire map.

## Options

The request options, plus `:context` - already-resolved arguments,
`%{"arguments" => %{...}}`.

    {:ok, %{"completion" => %{"values" => _}}} =
      ConduitMcp.Client.complete(:docs, {:prompt, "greeting"}, {"name", "A"})

# `era`

```elixir
@spec era(client()) ::
  {:ok, :modern | :legacy} | {:error, ConduitMcp.Client.Error.t()}
```

The negotiated protocol era (`:modern` or `:legacy`), kept while the
client reconnects. `{:error, %Error{kind: :unavailable}}` before the first
handshake.

    {:ok, :modern} = ConduitMcp.Client.era(:docs)

# `get_prompt`

```elixir
@spec get_prompt(client(), String.t(), map(), keyword()) ::
  {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}
```

Gets prompt `name` with `args`. Prompt arguments are strings on the wire,
so keys and values are converted: atoms, numbers and booleans with
`to_string/1`, other non-strings as JSON. `nil` values are dropped.
Returns the server's string-keyed `GetPromptResult` (`"messages"`).

On a governed client the `:on_prompt_get` hooks (which may rewrite
`args`) and the policy's prompt rules run first, as for
`read_resource/3`.

    {:ok, %{"messages" => [_ | _]}} =
      ConduitMcp.Client.get_prompt(:docs, "greeting", %{name: "Ada"})

# `get_task`

```elixir
@spec get_task(client(), ConduitMcp.Client.Task.t() | String.t(), keyword()) ::
  {:ok, ConduitMcp.Client.Task.t()} | {:error, ConduitMcp.Client.Error.t()}
```

Reads the current state of a server task (`tasks/get`).

    {:ok, %ConduitMcp.Client.Task{status: :working}} = ConduitMcp.Client.get_task(:docs, task)

# `instructions_for_llm`

```elixir
@spec instructions_for_llm(client()) ::
  {:ok, String.t() | nil} | {:error, ConduitMcp.Client.Error.t()}
```

The server's `instructions` as they may be put in a model's context
(PI-1): `{:ok, nil}` unless the client was started with
`include_instructions: true` (they arrive with the handshake and are
written by the server, so they are kept out of system prompts by default).
When included they are capped at 4 KiB with invisible and
direction-changing characters removed, and, unless the client is
`trusted: true`, prefixed with a line naming the server they came from.
`server_info/1` still returns them raw.

    {:ok, nil} = ConduitMcp.Client.instructions_for_llm(:docs)

# `list_all_prompts`

```elixir
@spec list_all_prompts(client(), keyword()) ::
  {:ok, [ConduitMcp.Client.Prompt.t()]} | {:error, ConduitMcp.Client.Error.t()}
```

Lists every prompt through the cache, as `list_all_tools/2` does for
tools.

    {:ok, [%ConduitMcp.Client.Prompt{} | _]} = ConduitMcp.Client.list_all_prompts(:docs)

# `list_all_resource_templates`

```elixir
@spec list_all_resource_templates(client(), keyword()) ::
  {:ok, [map()]} | {:error, ConduitMcp.Client.Error.t()}
```

Lists every resource template through the cache, as `list_all_tools/2`
does for tools.

    {:ok, [%{"uriTemplate" => _} | _]} = ConduitMcp.Client.list_all_resource_templates(:docs)

# `list_all_resources`

```elixir
@spec list_all_resources(client(), keyword()) ::
  {:ok, [ConduitMcp.Client.Resource.t()]}
  | {:error, ConduitMcp.Client.Error.t()}
```

Lists every resource through the cache, as `list_all_tools/2` does for
tools.

    {:ok, [%ConduitMcp.Client.Resource{} | _]} = ConduitMcp.Client.list_all_resources(:docs)

# `list_all_tools`

```elixir
@spec list_all_tools(client(), keyword()) ::
  {:ok, [ConduitMcp.Client.Tool.t()]} | {:error, ConduitMcp.Client.Error.t()}
```

Lists every tool, following `nextCursor`, through the client's
`ConduitMcp.Client.Cache`: a fresh cached list is returned without asking
the server; otherwise the connection refreshes it (one refresh at a time,
shared by concurrent callers) and the new list is returned and cached.

## Options

  * `:timeout` - per page, and the longest the caller waits (default the
    client's `:request_timeout`). A refresh the caller stopped waiting for
    still completes and fills the cache.
  * `:refresh` - `true` to ignore a fresh cached list (default `false`).
  * `:assigns`, `:principal` - for a governed client's filter and hooks.

Pagination is capped (100 pages, 10 000 items, a repeated or over-1 KiB
cursor): over a cap the result is `{:error, %Error{kind:
:pagination_cycle}}` and nothing is cached.

    {:ok, [%ConduitMcp.Client.Tool{} | _]} = ConduitMcp.Client.list_all_tools(:docs)

# `list_prompts`

```elixir
@spec list_prompts(client(), keyword()) ::
  {:ok, page(:prompts, ConduitMcp.Client.Prompt.t())}
  | {:error, ConduitMcp.Client.Error.t()}
```

Lists one page of prompts (`:cursor` as in `list_tools/2`).

    {:ok, %{prompts: [%ConduitMcp.Client.Prompt{name: "greeting"} | _]}} =
      ConduitMcp.Client.list_prompts(:docs)

# `list_resource_templates`

```elixir
@spec list_resource_templates(client(), keyword()) ::
  {:ok, page(:resource_templates, map())}
  | {:error, ConduitMcp.Client.Error.t()}
```

Lists one page of resource templates, as the server's string-keyed
`ResourceTemplate` maps (`:cursor` as in `list_tools/2`).

    {:ok, %{resource_templates: [%{"uriTemplate" => "item://{id}"} | _]}} =
      ConduitMcp.Client.list_resource_templates(:docs)

# `list_resources`

```elixir
@spec list_resources(client(), keyword()) ::
  {:ok, page(:resources, ConduitMcp.Client.Resource.t())}
  | {:error, ConduitMcp.Client.Error.t()}
```

Lists one page of resources (`:cursor` as in `list_tools/2`).

    {:ok, %{resources: [%ConduitMcp.Client.Resource{} | _]}} =
      ConduitMcp.Client.list_resources(:docs)

# `list_tools`

```elixir
@spec list_tools(client(), keyword()) ::
  {:ok, page(:tools, ConduitMcp.Client.Tool.t())}
  | {:error, ConduitMcp.Client.Error.t()}
```

Lists one page of tools. Pass the previous page's `next_cursor` as
`:cursor` for the next one. Tools that fail intake are left out (see
"Listing and the cache" above); the page is never cached.

On a governed client (see `call_tool/4`) the page holds only the tools
the `:tools` filter shows (function patterns see `:assigns` and
`:principal`), with a usable schema when `:validate` is on, after the
`:on_tools_listed` hooks (`ConduitMcp.Client.Hooks`); the same applies to
`list_all_tools/2`, `cached_tools/2` and `tool/3`.

    {:ok, %{tools: [%ConduitMcp.Client.Tool{name: "echo"} | _], next_cursor: nil}} =
      ConduitMcp.Client.list_tools(:docs)

# `notify_roots_changed`

```elixir
@spec notify_roots_changed(client()) :: :ok | {:error, ConduitMcp.Client.Error.t()}
```

Sends `notifications/roots/list_changed` (legacy only; the client
declares `roots.listChanged` when its handler implements `list_roots/1`).
In the modern era, or without a roots handler, returns
`{:error, %Error{kind: :protocol}}`.

    :ok = ConduitMcp.Client.notify_roots_changed(:docs)

# `ping`

```elixir
@spec ping(client(), keyword()) :: :ok | {:error, ConduitMcp.Client.Error.t()}
```

Checks the server answers: a legacy `ping`, or `server/discover` for a
modern server (which has no `ping`).

    :ok = ConduitMcp.Client.ping(:docs, timeout: 1_000)

# `read_resource`

```elixir
@spec read_resource(client(), String.t(), keyword()) ::
  {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}
```

Reads the resource at `uri`; returns the server's string-keyed
`ReadResourceResult` (`"contents"`).

On a governed client the `:on_resource_read` hooks run first, then the
policy's resource rules when its `:scope` includes `:resources` (an ask
rule denies: reads have no approver); `:principal` is passed to them.

    {:ok, %{"contents" => [%{"text" => _} | _]}} =
      ConduitMcp.Client.read_resource(:docs, "test://resource1")

# `reconnect`

```elixir
@spec reconnect(client()) :: :ok | {:error, ConduitMcp.Client.Error.t()}
```

Drops the current connection (pending requests fail with
`kind: :transport`) and connects again at once, with the reconnect count
reset. Also revives a `:closed` client.

    :ok = ConduitMcp.Client.reconnect(:docs)

# `server_info`

```elixir
@spec server_info(client()) ::
  {:ok, server_info()} | {:error, ConduitMcp.Client.Error.t()}
```

What the server reported in the handshake: its `ServerInfo`, capabilities
and instructions, with the negotiated era and version.
`{:error, %Error{kind: :unavailable, reason: :not_ready}}` while the
client is not ready.

    {:ok, %{capabilities: %{"tools" => _}}} = ConduitMcp.Client.server_info(:docs)

# `set_auth`

```elixir
@spec set_auth(client(), term(), keyword()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}
```

Replaces the credentials the client sends with `auth`, any form the
transport's `:auth` option takes (`ConduitMcp.Client.Transport.StreamableHTTP`:
`nil`, `{:bearer, token}`, a one-arity function, `{:oauth, opts}`,
`{:client_credentials, opts}`), checked like that option. Raises
`ArgumentError` when it is invalid, or when the transport takes no
`:auth`.

Everything bound to the old credentials ends: the client reconnects as
`reconnect/1` does (requests in flight fail with `kind: :transport`,
listen streams and a legacy session close, an OAuth authorization in
progress is abandoned). Cached results the server marked private are
deleted before this returns when the credentials' identity changes, and
fetched again under the new ones; public results stay (see "Private
results" in `ConduitMcp.Client.Cache`). A `:closed` client stays closed.

OAuth options naming no `:store` keep the client's current token store;
change `:principal` to act for another user (tokens are stored per
principal). The change lasts as long as the client process: a client
restarted by its supervisor starts with its configured credentials.

    :ok = ConduitMcp.Client.set_auth(:docs, {:bearer, new_token})

Options:

  * `:credential_id` - with an `:auth` function, any term naming whose
    tokens it returns (the transport's `:credential_id`). Without it,
    private results fetched with the function are never served from the
    cache.

# `set_log_level`

```elixir
@spec set_log_level(client(), String.t() | atom()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}
```

Sets the server's log level (`"debug"` … `"emergency"`, string or atom).
Legacy: sends `logging/setLevel`. Modern: the level is sent in the
`_meta` of every later request; a modern server sends no log
notifications for requests without one.

    :ok = ConduitMcp.Client.set_log_level(:docs, :warning)

# `start_link`

```elixir
@spec start_link(keyword() | ConduitMcp.Client.Config.t()) :: Supervisor.on_start()
```

Starts a client (see `ConduitMcp.Client.Config` for the options). Returns
once the processes exist, without waiting for the server.

    {:ok, _pid} =
      ConduitMcp.Client.start_link(
        name: :docs,
        transport: {ConduitMcp.Client.Transport.InProcess, server: MyApp.DocsServer}
      )

# `status`

```elixir
@spec status(client()) :: {:ok, status()} | {:error, ConduitMcp.Client.Error.t()}
```

The connection's current state, read without messaging it.

    {:ok, %{state: :ready, era: :modern}} = ConduitMcp.Client.status(:docs)

# `subscribe`

```elixir
@spec subscribe(client(), String.t(), keyword()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}
```

Subscribes to updates of the resource at `uri`, whichever era the server
speaks. Updates arrive as `{:mcp_notification, name,
"notifications/resources/updated", %{"uri" => uri}}` to the processes
subscribed with `subscribe_notifications/2`.

  * Legacy: sends `resources/subscribe` (the server must declare
    `resources.subscribe`, else `kind: :protocol`, reason
    `:subscriptions_not_supported`) and answers with the server's answer.
  * Modern: adds `uri` to the `subscriptions/listen` filter (a new listen
    replaces the old one once the server acknowledges it) and answers
    `:ok` when the acknowledgment honours `uri`, else `kind: :protocol`,
    reason `:subscription_not_acknowledged`.

The subscription is kept across reconnects and session renewals (sent
again each time the connection becomes ready). A call made before the
connection is ready waits for it, up to `:timeout` (default the client's
`:request_timeout`).

    :ok = ConduitMcp.Client.subscribe(:docs, "file:///notes.md")

# `subscribe_notifications`

```elixir
@spec subscribe_notifications(client(), pid()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}
```

Sends server notifications to `pid` (default: the caller) as
`{:mcp_notification, name, method, params}`. Idempotent; the subscription
ends when `pid` exits or `unsubscribe_notifications/2` is called.
`notifications/progress` goes to the request's caller instead.

    :ok = ConduitMcp.Client.subscribe_notifications(:docs)

    receive do
      {:mcp_notification, :docs, "notifications/tools/list_changed", _params} -> :refresh
    end

# `tool`

```elixir
@spec tool(client(), String.t(), keyword()) ::
  {:ok, ConduitMcp.Client.Tool.t()} | {:error, ConduitMcp.Client.Error.t()}
```

The cached definition of tool `name` (one cache row; the server is not
asked). `{:error, %Error{kind: :not_found}}` when the last listing did not
include it, or nothing was listed yet, or (on a governed client) the tool
is hidden. Takes `:assigns` and `:principal` (see `list_tools/2`).

    {:ok, %ConduitMcp.Client.Tool{name: "echo"}} = ConduitMcp.Client.tool(:docs, "echo")

# `unsubscribe`

```elixir
@spec unsubscribe(client(), String.t(), keyword()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}
```

Ends a `subscribe/2` subscription to `uri` (legacy
`resources/unsubscribe`; modern: a listen without `uri`).

    :ok = ConduitMcp.Client.unsubscribe(:docs, "file:///notes.md")

# `unsubscribe_notifications`

```elixir
@spec unsubscribe_notifications(client(), pid()) ::
  :ok | {:error, ConduitMcp.Client.Error.t()}
```

Stops sending server notifications to `pid` (default: the caller).

    :ok = ConduitMcp.Client.unsubscribe_notifications(:docs)

---

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