Raxol.MCP.Client.Era (Raxol MCP v2.8.0)

Copy Markdown View Source

Which protocol era an origin speaks, and what counts as evidence for it.

ADR-0037 decision 2. The current specification (2026-07-28) removed initialize, notifications/initialized and Mcp-Session-Id, and made server/discover mandatory for servers. The hosted servers ADR-0033 measured on 2026-08-31 are all stateful. A client that assumes either era fails against the other, so the era is probed per origin and cached.

Modern (2026-07-28)Legacy (2025-06-18 and earlier)
Handshakenoneinitialize then notifications/initialized
Capabilitiesserver/discoverthe initialize result
Sessionnoneoptional Mcp-Session-Id, echoed on every request
Required headersMcp-Method, Mcp-NameMCP-Protocol-Version

A refusal is not an era

evidence/1 is the whole demotion rule and it is deliberately narrow. An origin is demoted to legacy ONLY by a method-not-found for server/discover or by HTTP 404, 405 or 501. A 400, 401, 403, 408, 429 or 5xx is health information: it feeds Raxol.MCP.CircuitBreaker and leaves the verdict untouched.

That distinction is the difference between a transient upstream refusal and a permanently wedged backend. A 403 challenge is not hypothetical on these upstreams -- ADR-0038's probe reproduces one on a live Blockscout instance -- and treating it as era evidence would cache legacy forever, after which every call sends an initialize that a modern server is specified not to answer.

The key carries the path

A gateway can serve several MCP endpoints under one origin, so a per-origin key would hand all of them one era. The key is {origin, path}, and the value is {era, decided_at} with a TTL, so a wrong verdict expires on its own rather than waiting for a specific invalidating response.

The table is owned by Raxol.MCP.Client.Tables, not created here. :ets.new/2 tables are owned by the process that creates them (circuit_breaker.ex:31-33), so a table created by a client would make the verdict per client and defeat the point of caching it at all.

Summary

Functions

What a probe response says about the era.

Drop a verdict.

The cache key for a target URL.

An opaque, stable origin string: scheme, host and port.

Cache a verdict, stamped with the time it was decided.

The verdict TTL in milliseconds: per-call option, then app config, then the default.

Whether a response status records a circuit-breaker failure.

The cached verdict for a key, or :miss when absent or expired.

Types

era()

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

evidence()

@type evidence() :: :demote | :health | :none

key()

@type key() :: {String.t(), String.t()}

Functions

evidence(arg1)

@spec evidence({:status, non_neg_integer()} | {:jsonrpc_error, integer()} | term()) ::
  evidence()

What a probe response says about the era.

  • :demote - this origin does not implement server/discover, so it is legacy. A JSON-RPC method-not-found for that call, wherever it is carried, or HTTP 404, 405 or 501.
  • :health - the origin refused us. Breaker input, not era input.
  • :none - no era information either way.

A JSON-RPC code is the more specific of the two, so a caller holding both asks about the code first.

forget(table, key)

@spec forget(:ets.table(), key()) :: :ok

Drop a verdict.

Used by the one invalidation that is not a TTL: a session-rejected response re-probes exactly once, then fails.

key(uri)

@spec key(URI.t()) :: key()

The cache key for a target URL.

The query string is NOT part of the key, and the userinfo and fragment are not either: an MCP endpoint is identified by where it is, and a per-account URL can carry a credential in any of the three.

origin(uri)

@spec origin(URI.t()) :: String.t()

An opaque, stable origin string: scheme, host and port.

Used as the cache key's first element and as the circuit-breaker key, and never as an error term: a per-account URL names the account.

remember(table, key, era)

@spec remember(:ets.table(), key(), era()) :: :ok

Cache a verdict, stamped with the time it was decided.

ttl_ms(opts \\ [])

@spec ttl_ms(keyword()) :: pos_integer()

The verdict TTL in milliseconds: per-call option, then app config, then the default.

unhealthy?(status)

@spec unhealthy?(non_neg_integer()) :: boolean()

Whether a response status records a circuit-breaker failure.

verdict(table, key, opts \\ [])

@spec verdict(:ets.table(), key(), keyword()) :: {:ok, era()} | :miss

The cached verdict for a key, or :miss when absent or expired.

An expired row is deleted on the read that finds it, so a key probed once and never again costs one row rather than one row forever.