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

The process that owns one client's connection to one MCP server.

A `:gen_statem` (callback mode `[:handle_event_function, :state_enter]`)
started by `ConduitMcp.Client` and registered as `{:client, name}` in
`ConduitMcp.Client.Registry`. It traps exits. Users never call it
directly: `ConduitMcp.Client` functions message it from the caller (see
`ConduitMcp.Client.Request`).

It is a router, not a worker. It allocates request ids, keeps the pending
map, enforces deadlines and limits, routes cancellations and progress,
and fans notifications out to subscribers. It **never runs user code**:
no handler callbacks, no progress callbacks (progress goes to the caller,
which runs them), no policy or hooks; the client capabilities derived
from the handler are computed once, by `ConduitMcp.Client.Config.new!/1`.
The one exception is the `:backoff` fun, which must be pure and fast: one
that raises or returns anything but a non-negative integer is replaced
by `ConduitMcp.Client.Backoff.full_jitter/1`. Tasks it stops (handler
tasks, in-process requests) are killed, never waited on. The transport's callbacks run here,
and must not block (`ConduitMcp.Client.Transport`).

## States

| State | Does | Leaves to |
|---|---|---|
| `:connecting` | `transport.connect/1`, bounded by `:request_timeout` | `:discovering`, or `:initializing` for a legacy pin or hint |
| `:discovering` | sends `server/discover` (bounded by `:probe_timeout` when probing) | `:ready` (modern), `:initializing` on legacy evidence, `:reconnecting`, `:closed`, `:authorizing` (OAuth, 401) |
| `:initializing` | sends legacy `initialize`, then `notifications/initialized` | `:ready`, `:reconnecting`, `:closed`, `:authorizing` (OAuth, 401) |
| `:ready` | sends requests; resets the reconnect count after `:stable_after`; runs the optional health check | `:reconnecting`, `:discovering` (re-negotiation), `:authorizing` |
| `:authorizing` | OAuth only: after a 401, a task finds or renews a token (`ConduitMcp.Client.OAuth`); requests wait for it | back to `:ready` or the handshake, `:closed`/`:reconnecting` on an OAuth failure; stays while awaiting the user |
| `:reconnecting` | waits `backoff.(attempt)` | `:connecting`, or `:closed` after `:max_reconnects` |
| `:closed` | answers every request `kind: :closed` at once | `:connecting` via `ConduitMcp.Client.reconnect/1` |

Starting never blocks: `init/1` returns at once and the first connect runs
as an internal event. `:closed` keeps the process alive so `status/1`
shows why; with `on_give_up: :stop` the connection exits instead so a
supervisor restarts it.

### Era discovery

With `protocol: :auto` the connection probes `server/discover` and reads
the outcome with `ConduitMcp.Client.Negotiation.classify_probe/3` (the
transport's `kind/0` picks the stdio or HTTP rules):

  * a `DiscoverResult` → modern, ready;
  * `-32022` with a mutual version → discover once more with that
    version, never a fallback; a second refusal, or no version in common,
    closes;
  * legacy evidence (stdio: a non-modern error or a timeout) →
    `initialize`;
  * the server exiting during the probe → reconnect, and the next attempt
    starts with `initialize`;
  * a recognised modern error naming no common version or a missing
    client capability → `:closed`.

`protocol: :modern` (or a modern version) discovers without probing:
legacy evidence closes the connection instead of falling back.
`protocol: :legacy` (or a legacy version) goes straight to `initialize`.
`prior: :legacy` skips the probe once. Once a connection has negotiated
modern, later handshakes never fall back to legacy. A ready modern
connection that receives `-32022` re-discovers; the caller's
`ConduitMcp.Client.Request` retries that request once.

## Requests

A request that arrives before `:ready` is postponed (at most
`:max_queued` of them) and replayed on entering `:ready`, where its
deadline is checked again: a request whose caller already gave up is
answered `kind: :timeout` and never sent. `ConduitMcp.Client.await_ready/2`
and `subscribe/2`/`unsubscribe/2` made before `:ready` wait the same way
and count against the same `:max_queued`; one whose caller timed out or
exited is dropped on replay, never applied. In `:ready` a request beyond
`:max_in_flight` is refused with `kind: :overloaded`.

Ids are integers from a counter that is never reset, so a late frame
from an earlier attempt can never match a current request. A request
that asked for progress carries `progressToken` = its id.

Each request has an absolute deadline timer. When it fires, the
connection calls `transport.cancel/4` (reason `:timeout`) and answers
`kind: :timeout`; when the caller dies, it cancels with `:caller_down`.
A response that arrives for a request no longer pending is dropped
(`[:conduit_mcp, :client, :response, :dropped]`). Nothing that may have
executed is retried automatically: on reconnect every pending request
fails with `kind: :transport`.

## Notifications

Every server notification but `notifications/progress` and
`notifications/cancelled` is sent to each process subscribed with
`ConduitMcp.Client.subscribe_notifications/2` as
`{:mcp_notification, name, method, params}`, and to the handler's
`handle_notification/3` / `handle_log/3` (run in a separate process, in
order). A burst of one `notifications/*/list_changed` kind is coalesced:
the first is delivered at once, and any further ones within the next
250 ms are delivered as one trailing notification, so a flood reaches
subscribers at most twice per window. Each delivered `list_changed`
marks the matching `ConduitMcp.Client.Cache` list stale and, if it was
ever fetched, refreshes it: at most two refreshes per burst, and at most
one per second per list under a sustained flood.

## Change notifications and resource subscriptions

Modern servers send change notifications only on a `subscriptions/listen`
stream. The connection opens one itself once the client has a consumer
(a cached list, a notification subscriber, or a handler taking
notifications) for a kind the server advertises `listChanged` for, or a
resource subscription (`ConduitMcp.Client.subscribe/2`), with the union
of those as its filter. Notifications are routed only for a live listen,
with its `subscriptionId` removed. A filter change opens a new listen
and closes the old one once the new one is acknowledged; a listen that
ends is reopened after the backoff, and one that dropped also marks the
cache stale. At most four listens exist at once. Legacy servers get
`resources/subscribe` instead. Resource subscriptions survive reconnects:
each new session or listen carries them again.

## Cache

The connection owns and is the only writer of the client's
`ConduitMcp.Client.Cache` table, but never fetches lists itself: a
refresh runs in a task under the client's `Task.Supervisor` and reports
back. Losing the connection marks every cached list stale.

## HTTP outcomes

A transport may report a request refused for credentials (`{:unauthorized,
id, info}`: the caller gets `kind: :auth`) or a legacy session the server
no longer knows (`{:session_expired, id}`): the connection then runs a new
`initialize` without failing the requests in flight, and the transport
re-sends the expired ones once (`ConduitMcp.Client.Transport`).

With `auth: {:oauth, ...}` a 401 enters `:authorizing` instead, and a 403
`insufficient_scope` steps up; the refused request is sent again once a
token exists, or fails with `reason: :authorization_required` while the
user must authorize (never waiting for a human). The OAuth work runs in
tasks; see `ConduitMcp.Client.OAuth`.

## Server-to-client requests (legacy)

`ping` is answered `{}`. `sampling/createMessage`, `elicitation/create`
and `roots/list` run the client's `ConduitMcp.Client.Handler` in a task
under the client's `Task.Supervisor`, never here, with the limits of
`:handler_timeout` and `:max_server_requests`; the related call's
deadline is paused meanwhile (bounded by its `:max_total_timeout`). A
server `notifications/cancelled` kills the task. Any other method, or one
whose capability was not declared, is answered `-32601`.

## Telemetry

Emits `[:conduit_mcp, :client, :connection, :transition]`,
`[:conduit_mcp, :client, :request, :cancel]`,
`[:conduit_mcp, :client, :response, :dropped]`,
`[:conduit_mcp, :client, :notification]`,
`[:conduit_mcp, :client, :cache, :refresh, :start | :stop]` and
`[:conduit_mcp, :client, :tool, :definition_changed]` from this process;
handlers attached to them run here and must be fast (see
`ConduitMcp.Telemetry`).

## Crash reports

`format_status/1` replaces the transport state, the transport options,
the params of queued requests, every request's HTTP `:headers` (which
carry `Mcp-Param-*` copies of tool arguments), the credentials of a
queued `ConduitMcp.Client.set_auth/3` and every `assigns` (the client's
and each pending or queued call's) with `:redacted`, so a crash report
never carries credentials, session ids, host data or tool arguments.

---

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