ConduitMcp.Client.Request (ConduitMCP v0.11.0)

Copy Markdown View Source

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 id id.
  • {:progress, params} - a notifications/progress for it; passed to :on_progress.
  • {:result, map} - the connection completed the request itself (for example a modern set_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

envelope_info()

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

on_progress()

@type on_progress() :: (map() -> any()) | pid() | nil

fun.(params), or a pid receiving {:mcp_progress, client, params}.

op()

@type op() :: String.t() | :ping | {:set_log_level, String.t()} | :roots_list_changed

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 - legacy ping, modern server/discover.
  • {:set_log_level, level} - legacy logging/setLevel; modern stores the level for the logLevel _meta of later requests.
  • :roots_list_changed - legacy notifications/roots/list_changed; refused in the modern era.

Functions

call(client, op, timeout)

@spec call(term(), term(), timeout()) :: term()

Sends a control op to client's connection and returns its reply, or {:error, %Error{}} on timeout or when the connection is gone.

envelope(params, info)

@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, logLevel when one is set, and traceparent (unless the caller's _meta has one) when info.traceparent is set.
  • Both eras: progressToken when info.progress_token is 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"}}

lookup(client)

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

run(client, op, params \\ %{}, opts \\ [])

@spec run(term(), op(), map(), keyword()) ::
  {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}

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 each notifications/progress params, or a pid sent {:mcp_progress, client, params}. Setting it asks the server for progress (a progressToken equal to the request id).
  • :reset_timeout_on_progress - each progress notification restarts the :timeout (default false), 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 to ConduitMcp.Client.Handler callbacks as ctx.assigns for legacy server requests tied to this request (default the client's :assigns).
  • :extensions - extension capabilities to declare in this request's modern clientCapabilities (for example %{"io.modelcontextprotocol/tasks" => %{}}).
  • :headers - extra HTTP headers for a modern request over an :http transport, as lowercase {name, value} string pairs (the Mcp-Param-* headers of ConduitMcp.Headers.param_headers/2); ignored by other transports.
  • :traceparent - the W3C trace context to propagate (default: the caller's Logger.metadata()[:traceparent]). Sent in a modern request's _meta when it is well formed (ConduitMcp.Era.traceparent/1), unless the caller's "_meta" already has one; tracestate and baggage are never added.

Example

{:ok, %{"tools" => _}} = ConduitMcp.Client.Request.run(MyApp.Docs, "tools/list", %{})