The caller side of a client request, and the request envelope.
ConduitMcp.Client functions call run/4 in the calling process. It
never uses GenServer.call: it monitors the connection with an alias,
mref = :erlang.monitor(:process, connection, [{:alias, :demonitor}])sends {:"$mcp_request", mref, self(), method, params, wire_opts}, and
waits in one receive for messages tagged with mref:
{:accepted, id}- the connection sent the request with JSON-RPC idid.{:progress, params}- anotifications/progressfor it; passed to:on_progress.{:result, map}- the connection completed the request itself (for example a modernset_log_level, which sends nothing).{:deadline, ms}- the deadline moved: a legacy server request tied to this one is running in the client's handler (the deadline is paused, bounded by:max_total_timeout), or ended.{:error, %ConduitMcp.Client.Error{}}- the connection refused or failed it.{:DOWN, mref, ...}- the connection exited (kind: :connection_down).
The connection owns the deadline: when it passes, the connection cancels
the request on the wire and answers kind: :timeout. The caller's own
after (the deadline plus a short grace) is only a safety net for a
wedged connection; it sends {:"$mcp_cancel", mref, :caller_timeout}.
:erlang.demonitor(mref, [:flush]) deactivates the alias when run/4
returns, so a response racing the timeout is dropped by the runtime and
never reaches the caller's mailbox.
If the caller dies, the connection sees it through its own monitor and
cancels the request (ConduitMcp.Client.Transport "Cancellation").
A JSON-RPC error answer -32022 (the server no longer accepts the
negotiated version) is retried once: the connection re-negotiates before
it sends the retry.
Telemetry
Each request sent is one [:conduit_mcp, :client, :request, :start | :stop | :exception] span, emitted in the caller (see
ConduitMcp.Telemetry).
Summary
Types
What envelope/2 needs from the negotiated connection.
fun.(params), or a pid receiving {:mcp_progress, client, params}.
What to send: a JSON-RPC method, or an operation whose wire form depends on the negotiated era (resolved by the connection when it sends)
Functions
Sends a control op to client's connection and returns its reply, or
{:error, %Error{}} on timeout or when the connection is gone.
The wire params of a request: params with the era's reserved _meta
keys merged over the caller's "_meta".
Resolves client to its connection pid and its caller view: the value the
connection registers under {:client, name} (see
ConduitMcp.Client.Registry) merged with its current status from
ConduitMcp.Client.Cache (state, era, version, reason, origin,
server).
Sends op with params through client and waits for the answer.
Types
@type envelope_info() :: %{ :era => :modern | :legacy, :version => String.t(), :client_capabilities => map(), :client_info => map(), optional(:log_level) => String.t() | nil, optional(:progress_token) => term(), optional(:traceparent) => String.t() | nil }
What envelope/2 needs from the negotiated connection.
fun.(params), or a pid receiving {:mcp_progress, client, params}.
What to send: a JSON-RPC method, or an operation whose wire form depends on the negotiated era (resolved by the connection when it sends):
:ping- legacyping, modernserver/discover.{:set_log_level, level}- legacylogging/setLevel; modern stores the level for thelogLevel_metaof later requests.:roots_list_changed- legacynotifications/roots/list_changed; refused in the modern era.
Functions
Sends a control op to client's connection and returns its reply, or
{:error, %Error{}} on timeout or when the connection is gone.
@spec envelope(map(), envelope_info()) :: map()
The wire params of a request: params with the era's reserved _meta
keys merged over the caller's "_meta".
- Modern:
io.modelcontextprotocol/protocolVersion,clientCapabilities,clientInfo,logLevelwhen one is set, andtraceparent(unless the caller's_metahas one) wheninfo.traceparentis set. - Both eras:
progressTokenwheninfo.progress_tokenis set.
Reserved keys always win: any caller key named progressToken, or whose
prefix (before /) has a modelcontextprotocol or mcp label, is
dropped. An empty _meta is omitted.
iex> ConduitMcp.Client.Request.envelope(
...> %{"name" => "echo", "_meta" => %{"progressToken" => "x", "trace" => "t"}},
...> %{era: :legacy, version: "2025-11-25", client_capabilities: %{},
...> client_info: %{}, progress_token: 7}
...> )
%{"name" => "echo", "_meta" => %{"progressToken" => 7, "trace" => "t"}}
@spec lookup(term()) :: {:ok, pid(), map()} | {:error, ConduitMcp.Client.Error.t()}
Resolves client to its connection pid and its caller view: the value the
connection registers under {:client, name} (see
ConduitMcp.Client.Registry) merged with its current status from
ConduitMcp.Client.Cache (state, era, version, reason, origin,
server).
Sends op with params through client and waits for the answer.
Returns the JSON-RPC result map, or {:error, %ConduitMcp.Client.Error{}}
(a JSON-RPC error answer becomes kind: :protocol with reason
{:jsonrpc, code} via ConduitMcp.Client.Error.from_rpc_error/1).
params may carry a user "_meta" map; the connection merges the era's
reserved keys over it (envelope/2).
Options
:timeout- milliseconds before the request is cancelled (default the client's:request_timeout).:on_progress- a one-arity function called with eachnotifications/progressparams, or a pid sent{:mcp_progress, client, params}. Setting it asks the server for progress (aprogressTokenequal to the request id).:reset_timeout_on_progress- each progress notification restarts the:timeout(defaultfalse), never past:max_total_timeout.:max_total_timeout- the cap for the above (default the client's:max_total_timeout), and for a deadline paused while a legacy server request tied to this one runs in the client's handler.:assigns- a map passed toConduitMcp.Client.Handlercallbacks asctx.assignsfor legacy server requests tied to this request (default the client's:assigns).:extensions- extension capabilities to declare in this request's modernclientCapabilities(for example%{"io.modelcontextprotocol/tasks" => %{}}).:headers- extra HTTP headers for a modern request over an:httptransport, as lowercase{name, value}string pairs (theMcp-Param-*headers ofConduitMcp.Headers.param_headers/2); ignored by other transports.:traceparent- the W3C trace context to propagate (default: the caller'sLogger.metadata()[:traceparent]). Sent in a modern request's_metawhen it is well formed (ConduitMcp.Era.traceparent/1), unless the caller's"_meta"already has one;tracestateandbaggageare never added.
Example
{:ok, %{"tools" => _}} = ConduitMcp.Client.Request.run(MyApp.Docs, "tools/list", %{})