ConduitMcp.Client.Connection (ConduitMCP v0.11.0)

Copy Markdown View Source

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

StateDoesLeaves to
:connectingtransport.connect/1, bounded by :request_timeout:discovering, or :initializing for a legacy pin or hint
:discoveringsends server/discover (bounded by :probe_timeout when probing):ready (modern), :initializing on legacy evidence, :reconnecting, :closed, :authorizing (OAuth, 401)
:initializingsends legacy initialize, then notifications/initialized:ready, :reconnecting, :closed, :authorizing (OAuth, 401)
:readysends requests; resets the reconnect count after :stable_after; runs the optional health check:reconnecting, :discovering (re-negotiation), :authorizing
:authorizingOAuth only: after a 401, a task finds or renews a token (ConduitMcp.Client.OAuth); requests wait for itback to :ready or the handshake, :closed/:reconnecting on an OAuth failure; stays while awaiting the user
:reconnectingwaits backoff.(attempt):connecting, or :closed after :max_reconnects
:closedanswers 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.