ConduitMcp.Client.Negotiation (ConduitMCP v0.11.0)

Copy Markdown View Source

Pure protocol-negotiation decisions for the client: which era a server speaks, which version to use, and which capabilities to advertise.

Versions

The client supports "2026-07-28", "2025-11-25" and "2025-06-18", newest first (client_versions/0). 2026-07-28 is the modern era (stateless, server/discover, per-request _meta); the others are legacy (initialize handshake).

Probing (classify_probe/3)

With protocol: :auto the client probes server/discover and reads the outcome with classify_probe/3. Which outcomes count as evidence of a legacy server depends on the transport:

TransportOutcomeVerdict
bothvalid DiscoverResult with a mutual modern version{:modern, version, result}
bothDiscoverResult-shaped but invalid, or no mutual modern version{:error, %Error{kind: :protocol}}
botha result that is not DiscoverResult-shaped (no resultType, no supportedVersions):legacy
both-32022 with a mutual modern version in data.supported{:modern_retry, version}
both-32022 without one{:error, %Error{reason: :no_common_version}}
both-32021 / -32020{:error, %Error{kind: :protocol}}; never a fallback
stdioany other JSON-RPC error:legacy
stdiotimeout:legacy
stdiothe server process exited during the probe{:reconnect, :legacy}
HTTP401 / 403{:authorize, status}
HTTP408 / 429 / 5xx, network error, timeout{:retry, reason}; never a downgrade
HTTP400 / 404 / 405 with a non-modern body (empty, not JSON, not JSON-RPC, or a non-modern error):legacy
HTTPany status with JSON-RPC -32601:legacy
HTTPany other status or error{:error, %Error{}}

A recognized modern error is never treated as legacy evidence, and the fallback is never keyed to one error code. Once a connection has reached a modern verdict, the caller pins the era and does not downgrade silently.

Capabilities (client_capabilities/2)

Built only from the ConduitMcp.Client.Handler callbacks a module implements; with no handler the client advertises %{}.

Summary

Functions

Classifies the outcome of a server/discover probe; see the moduledoc table.

The ClientCapabilities map to advertise for handler (a module implementing ConduitMcp.Client.Handler, or nil).

Every protocol version the client speaks, newest first.

The era of a protocol version string: dates on or after 2026-07-28 are :modern, earlier dates :legacy, anything else :unknown.

Whether code is one of the modern-era JSON-RPC errors (-32020, -32021, -32022) that prove the server speaks 2026-07-28.

Picks the version to use: the first entry of client_supported (in the client's preference order) that the server also lists.

Types

era()

@type era() :: :modern | :legacy

http_outcome()

@type http_outcome() ::
  {:http, status :: 100..599, content_type :: String.t() | nil,
   body :: binary() | map() | nil}
  | {:transport_error, term()}
  | :timeout

stdio_outcome()

@type stdio_outcome() ::
  {:result, term()} | {:error, map()} | :timeout | {:closed, term()}

verdict()

@type verdict() ::
  {:modern, String.t(), ConduitMcp.Client.DiscoverResult.t()}
  | {:modern_retry, String.t()}
  | :legacy
  | {:reconnect, :legacy}
  | {:authorize, 401 | 403}
  | {:retry, term()}
  | {:error, ConduitMcp.Client.Error.t()}

Functions

classify_probe(transport, outcome, opts \\ [])

@spec classify_probe(:stdio | :http, stdio_outcome() | http_outcome(), keyword()) ::
  verdict()

Classifies the outcome of a server/discover probe; see the moduledoc table.

outcome is a stdio_outcome/0 for :stdio and a http_outcome/0 for :http. An HTTP body may be the raw bytes (decoded here as JSON for application/json, or as an event stream for text/event-stream) or an already decoded JSON-RPC message map.

Options

  • :versions - the client's versions in preference order (default client_versions/0); only its modern entries are considered.

client_capabilities(handler, opts \\ [])

@spec client_capabilities(module() | nil, keyword()) :: map()

The ClientCapabilities map to advertise for handler (a module implementing ConduitMcp.Client.Handler, or nil).

Only implemented callbacks contribute, so nil or a module without sampling, elicitation or roots callbacks gives %{}.

Options

  • :era - :modern (default) or :legacy; legacy roots may carry listChanged.
  • :sampling_tools - also declare sampling.tools (default false).
  • :sampling_context - also declare sampling.context, allowing includeContext other than "none" (default false).
  • :roots_list_changed - declare roots.listChanged in the legacy era, promising notifications/roots/list_changed (default false).

Example

iex> ConduitMcp.Client.Negotiation.client_capabilities(nil)
%{}

client_versions()

@spec client_versions() :: [String.t(), ...]

Every protocol version the client speaks, newest first.

era(version)

@spec era(String.t()) :: era() | :unknown

The era of a protocol version string: dates on or after 2026-07-28 are :modern, earlier dates :legacy, anything else :unknown.

iex> ConduitMcp.Client.Negotiation.era("2025-11-25")
:legacy

modern_error?(code)

@spec modern_error?(term()) :: boolean()

Whether code is one of the modern-era JSON-RPC errors (-32020, -32021, -32022) that prove the server speaks 2026-07-28.

pick_version(server_supported, client_supported \\ ["2026-07-28", "2025-11-25", "2025-06-18"])

@spec pick_version([String.t()], [String.t()]) ::
  {:ok, String.t()} | {:error, :no_common_version}

Picks the version to use: the first entry of client_supported (in the client's preference order) that the server also lists.

iex> ConduitMcp.Client.Negotiation.pick_version(["2025-06-18", "2025-11-25"])
{:ok, "2025-11-25"}
iex> ConduitMcp.Client.Negotiation.pick_version(["2024-11-05"])
{:error, :no_common_version}