# `Raxol.MCP.Client.Era`
[🔗](https://github.com/DROOdotFOO/raxol/blob/v2.8.0/lib/raxol/mcp/client/era.ex#L1)

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.

# `era`

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

# `evidence`

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

# `key`

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

# `evidence`

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

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

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

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

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

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

# `ttl_ms`

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

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

# `unhealthy?`

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

Whether a response status records a circuit-breaker failure.

# `verdict`

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

---

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