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; -32022with 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.