The behaviour a client transport implements.
A transport moves JSON-RPC messages between a ConduitMcp.Client.Connection
and one MCP server. Its callbacks run inside the connection process,
and its state is plain data the connection holds and threads through every
call (the "process-less" style of Mint). A transport may start helper
processes of its own (a port owner, one process per in-flight HTTP request)
under the client's Task.Supervisor or linked to the connection, but it
never blocks: every callback returns promptly, and anything that takes
time reports back later as a message that handle_info/2 turns into
events.
Lifecycle
validate_options/1(optional) - checks the options without side effects whenConduitMcp.Client.Config.new!/1runs, so a bad option raises anArgumentErrornaming it inchild_spec/1orstart_link/1instead of failing the connection later. It returns the optionsinit/2will get (with defaults filled in).ConduitMcp.Client.set_auth/3runs it on the options with a new:auth(and:credential_id); a transport without it takes no:auth. The connection reads those two keys to key private cached results by credential (ConduitMcp.Client.Cache).child_specs/2(optional) - processes the client supervisor starts before the connection and keeps across connection attempts (an HTTP pool), stopped with the client.init/2- once per connection attempt. Validates options and builds the state; no I/O. The first call happens while the client starts, so an{:error, reason}there failsConduitMcp.Client.start_link/1.connect/1- starts connecting without waiting. The transport reports{:connected, info}once it can carry messages (an in-process or HTTP transport may report it at once) or{:closed, reason}if it cannot.request/4,notify/2,respond/2,cancel/4while connected;handle_info/2for every message the connection does not recognise as its own.close/2- when the connection reconnects, closes or stops. The state is discarded afterwards; the next attempt starts atinit/2.
Requests
request/4 gets a complete JSON-RPC request map (string keys, an
integer "id" that the connection never reuses, "params" already
carrying the era's _meta). It returns a handle (any term, for example
the pid of the process sending it, or nil) that the connection passes
back to cancel/4.
The answer arrives as one of:
{:response, id, message}- the JSON-RPC response map (a"result"or an"error"member). The connection forwards it to the caller.{:exchange_done, id, :ok}- the transport already delivered the response straight to the caller's alias:send(reply_to, {reply_to, {:response, message}})with thereply_toreference it was given. Only an HTTP-style transport that streams responses from a helper process does this;reply_toisnilfor the connection's own requests (handshake, health checks), whose response must come back as{:response, id, message}.{:exchange_done, id, {:error, reason}}- the request failed without a response; the caller gets%ConduitMcp.Client.Error{kind: :transport, reason: reason}. For theserver/discoverprobe (wire_opts[:probe]istrue) an:httptransport reports a non-2xx answer as{:error, {:http, status, content_type, body}}and a network failure as{:error, {:transport_error, reason}}, so the connection can applyConduitMcp.Client.Negotiation.classify_probe/3.
wire_opts is a keyword list; unknown keys must be ignored. The keys
defined today:
:probe- see above.:max_deadline- the absolute (System.monotonic_time(:millisecond)) time after which the connection will certainly have cancelled a caller's request; a transport may bound its own work by it.:headers- extra{name, value}HTTP headers for a modern request (theMcp-Param-*headers computed by the caller).:listen- the request is a modernsubscriptions/listen: its answer is a long-lived stream of notifications tagged with its id, ended by the server's result or bycancel/4; it has no deadline.
Cancellation (cancel/4)
Called when the caller's deadline passes (reason :timeout), the caller
dies (:caller_down) or gives up (:caller_timeout). The transport does
what the era and binding require:
- stdio, both eras: send
notifications/cancelledwith therequestId. - modern HTTP: close the request's response stream; send nothing.
- legacy HTTP: POST
notifications/cancelled, then close the stream. - in-process: terminate the request's task.
A response that still arrives after a cancel is dropped by the connection.
set_session/2 tells the transport which era it is serving.
Events from handle_info/2
{:connected, info}- seeconnect/1. An HTTP transport puts the server's origin ("https://host[:port]") ininfo[:origin]; the client compares URL-mode elicitation URLs with it.{:response, id, message},{:exchange_done, id, outcome}- above.{:notification, method, params}- a server notification.notifications/progressis routed to the request whose id equals itsprogressToken; the rest go to the client's notification subscribers.{:server_request, id, message, related}- a legacy server-to-client request;relatedis the id of the client request it belongs to, ornil. The connection answers it throughrespond/2, which a transport emitting this event must implement.{:unauthorized, id, %{status: 401 | 403, challenges: list}}- the server refused requestidfor want of credentials or scope (HTTP); the connection fails it withkind: :auth, or, with OAuth configured, runs the authorization path and re-sends it (seeConduitMcp.Client.OAuth).challengesareConduitMcp.Client.OAuth.Challengestructs; an OAuth transport addssent:, a fingerprint of the bearer the request carried (nilfor none), never the token itself. A request carrying theserver/discoverprobe reports a 401/403 this way too when OAuth is configured.{:session_expired, id}- the server no longer knows the legacy session requestidwas sent in (HTTP 404). The connection initializes a new session without failing anything; the transport re-sendsiditself (once) whenset_session/2reports the new session.{:closed, reason}- the transport can no longer carry messages; the connection fails every pending request and reconnects.
Return :unknown for a message that is not the transport's.
Kinds
kind/0 names the transport family. It selects the evidence
ConduitMcp.Client.Negotiation.classify_probe/3 uses when probing for the
protocol era (:http uses the HTTP rules, every other kind the stdio
rules: a timeout or a non-modern error means a legacy server), the
default :probe_timeout (5 s for :http, 3 s otherwise), and the
transport value in telemetry metadata.
Summary
Types
A client request id: a positive integer, never reused.
What init/2 receives besides its options.
Transport state, owned by the connection.
Callbacks
Cancels an in-flight request; see "Cancellation" in the moduledoc.
Child specs the client's supervisor starts before the connection (and stops with the client), given the validated options and the client's name. Called once per client start.
Releases everything the transport holds. Bounded by the connection's shutdown.
Starts connecting; reports {:connected, info} or {:closed, reason} later.
Turns a message delivered to the connection into transport events.
Validates opts and builds the transport state. Must not block.
The transport family; see the moduledoc.
Sends one client notification (no response expected).
Sends one request; see "Requests" in the moduledoc.
Sends the client's response to a {:server_request, ...} event.
Gives the transport the connection's OAuth session (an opaque term,
holding the OAuth options and what discovery found) for a transport
configured with auth: {:oauth, ...}.
Called after every init/2 and whenever the session changes; requests
started afterwards read their bearer token through it.
Tells the transport the negotiated session: %{era: :modern | :legacy, version: String.t()}. Called each time the connection becomes ready.
Checks opts without side effects; see "Lifecycle" in the moduledoc. The
error message names the offending key.
Types
@type event() :: {:connected, map()} | {:response, id :: term(), message :: map()} | {:exchange_done, id(), :ok | {:error, term()}} | {:notification, method :: String.t(), params :: map()} | {:server_request, id :: term(), message :: map(), related :: id() | nil} | {:unauthorized, id(), %{status: 401 | 403, challenges: list()}} | {:session_expired, id()} | {:closed, reason :: term()}
@type id() :: pos_integer()
A client request id: a positive integer, never reused.
What init/2 receives besides its options.
@type state() :: term()
Transport state, owned by the connection.
Callbacks
Cancels an in-flight request; see "Cancellation" in the moduledoc.
@callback child_specs(opts :: keyword(), key :: term()) :: [Supervisor.child_spec()]
Child specs the client's supervisor starts before the connection (and stops with the client), given the validated options and the client's name. Called once per client start.
Releases everything the transport holds. Bounded by the connection's shutdown.
Starts connecting; reports {:connected, info} or {:closed, reason} later.
Turns a message delivered to the connection into transport events.
@callback init(opts :: keyword(), init_context()) :: {:ok, state()} | {:error, term()}
Validates opts and builds the transport state. Must not block.
@callback kind() :: :stdio | :http | :in_process
The transport family; see the moduledoc.
Sends one client notification (no response expected).
@callback request( state(), message :: map(), reply_to :: reference() | nil, wire_opts :: keyword() ) :: {:ok, handle :: term(), state()} | {:error, reason :: term(), state()}
Sends one request; see "Requests" in the moduledoc.
Sends the client's response to a {:server_request, ...} event.
Gives the transport the connection's OAuth session (an opaque term,
holding the OAuth options and what discovery found) for a transport
configured with auth: {:oauth, ...}.
Called after every init/2 and whenever the session changes; requests
started afterwards read their bearer token through it.
@callback set_session(state(), session :: %{era: :modern | :legacy, version: String.t()}) :: state()
Tells the transport the negotiated session: %{era: :modern | :legacy, version: String.t()}. Called each time the connection becomes ready.
Checks opts without side effects; see "Lifecycle" in the moduledoc. The
error message names the offending key.