# `ConduitMcp.Client.Negotiation`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/negotiation.ex#L1)

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:

| Transport | Outcome | Verdict |
|---|---|---|
| both | valid `DiscoverResult` with a mutual modern version | `{:modern, version, result}` |
| both | `DiscoverResult`-shaped but invalid, or no mutual modern version | `{:error, %Error{kind: :protocol}}` |
| both | a 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 |
| stdio | any other JSON-RPC error | `:legacy` |
| stdio | timeout | `:legacy` |
| stdio | the server process exited during the probe | `{:reconnect, :legacy}` |
| HTTP | 401 / 403 | `{:authorize, status}` |
| HTTP | 408 / 429 / 5xx, network error, timeout | `{:retry, reason}`; never a downgrade |
| HTTP | 400 / 404 / 405 with a non-modern body (empty, not JSON, not JSON-RPC, or a non-modern error) | `:legacy` |
| HTTP | any status with JSON-RPC `-32601` | `:legacy` |
| HTTP | any 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 `%{}`.

# `era`

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

# `http_outcome`

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

# `stdio_outcome`

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

# `verdict`

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

# `classify_probe`

```elixir
@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 `t:stdio_outcome/0` for `:stdio` and a `t: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`

```elixir
@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`

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

Every protocol version the client speaks, newest first.

# `era`

```elixir
@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?`

```elixir
@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`

```elixir
@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}

---

*Consult [api-reference.md](api-reference.md) for complete listing*
