ConduitMcp.Client.Transport behaviour (ConduitMCP v0.11.0)

Copy Markdown View Source

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

  1. validate_options/1 (optional) - checks the options without side effects when ConduitMcp.Client.Config.new!/1 runs, so a bad option raises an ArgumentError naming it in child_spec/1 or start_link/1 instead of failing the connection later. It returns the options init/2 will get (with defaults filled in). ConduitMcp.Client.set_auth/3 runs 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.
  2. 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 fails ConduitMcp.Client.start_link/1.
  3. 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.
  4. request/4, notify/2, respond/2, cancel/4 while connected; handle_info/2 for every message the connection does not recognise as its own.
  5. close/2 - when the connection reconnects, closes or stops. The state is discarded afterwards; the next attempt starts at init/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 the reply_to reference it was given. Only an HTTP-style transport that streams responses from a helper process does this; reply_to is nil for 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 the server/discover probe (wire_opts[:probe] is true) an :http transport 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 apply ConduitMcp.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 (the Mcp-Param-* headers computed by the caller).
  • :listen - the request is a modern subscriptions/listen: its answer is a long-lived stream of notifications tagged with its id, ended by the server's result or by cancel/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/cancelled with the requestId.
  • 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} - see connect/1. An HTTP transport puts the server's origin ("https://host[:port]") in info[: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/progress is routed to the request whose id equals its progressToken; the rest go to the client's notification subscribers.
  • {:server_request, id, message, related} - a legacy server-to-client request; related is the id of the client request it belongs to, or nil. The connection answers it through respond/2, which a transport emitting this event must implement.
  • {:unauthorized, id, %{status: 401 | 403, challenges: list}} - the server refused request id for want of credentials or scope (HTTP); the connection fails it with kind: :auth, or, with OAuth configured, runs the authorization path and re-sends it (see ConduitMcp.Client.OAuth). challenges are ConduitMcp.Client.OAuth.Challenge structs; an OAuth transport adds sent:, a fingerprint of the bearer the request carried (nil for none), never the token itself. A request carrying the server/discover probe reports a 401/403 this way too when OAuth is configured.

  • {:session_expired, id} - the server no longer knows the legacy session request id was sent in (HTTP 404). The connection initializes a new session without failing anything; the transport re-sends id itself (once) when set_session/2 reports 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

event()

@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()}

id()

@type id() :: pos_integer()

A client request id: a positive integer, never reused.

init_context()

@type init_context() :: %{owner: pid(), task_sup: pid(), key: term()}

What init/2 receives besides its options.

state()

@type state() :: term()

Transport state, owned by the connection.

Callbacks

cancel(state, id, handle, reason)

@callback cancel(state(), id(), handle :: term(), reason :: term()) :: {:ok, state()}

Cancels an in-flight request; see "Cancellation" in the moduledoc.

child_specs(opts, key)

(optional)
@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.

close(state, reason)

@callback close(state(), reason :: term()) :: :ok

Releases everything the transport holds. Bounded by the connection's shutdown.

connect(state)

@callback connect(state()) :: {:ok, state()}

Starts connecting; reports {:connected, info} or {:closed, reason} later.

handle_info(message, state)

@callback handle_info(message :: term(), state()) :: {:ok, [event()], state()} | :unknown

Turns a message delivered to the connection into transport events.

init(opts, init_context)

@callback init(opts :: keyword(), init_context()) :: {:ok, state()} | {:error, term()}

Validates opts and builds the transport state. Must not block.

kind()

@callback kind() :: :stdio | :http | :in_process

The transport family; see the moduledoc.

notify(state, message)

@callback notify(state(), message :: map()) :: {:ok, state()} | {:error, term(), state()}

Sends one client notification (no response expected).

request(state, message, reply_to, wire_opts)

@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.

respond(state, message)

(optional)
@callback respond(state(), message :: map()) :: {:ok, state()} | {:error, term(), state()}

Sends the client's response to a {:server_request, ...} event.

set_auth(state, session)

(optional)
@callback set_auth(state(), session :: term()) :: state()

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.

set_session(state, session)

(optional)
@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.

validate_options(opts)

(optional)
@callback validate_options(opts :: keyword()) :: {:ok, keyword()} | {:error, String.t()}

Checks opts without side effects; see "Lifecycle" in the moduledoc. The error message names the offending key.