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) | |
|---|---|---|
| Handshake | none | initialize then notifications/initialized |
| Capabilities | server/discover | the initialize result |
| Session | none | optional Mcp-Session-Id, echoed on every request |
| Required headers | Mcp-Method, Mcp-Name | MCP-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
Functions
@spec evidence({:status, non_neg_integer()} | {:jsonrpc_error, integer()} | term()) :: evidence()
What a probe response says about the era.
:demote- this origin does not implementserver/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.
@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.
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.
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.
@spec remember(:ets.table(), key(), era()) :: :ok
Cache a verdict, stamped with the time it was decided.
@spec ttl_ms(keyword()) :: pos_integer()
The verdict TTL in milliseconds: per-call option, then app config, then the default.
@spec unhealthy?(non_neg_integer()) :: boolean()
Whether a response status records a circuit-breaker failure.
@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.