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

The per-connection cache of a `ConduitMcp.Client`: an ETS table
(`:set`, `:protected`, `read_concurrency: true`) that only the client's
`ConduitMcp.Client.Connection` writes, and that any process reads
directly. Its id is published in the client's `ConduitMcp.Client.Registry`
value, so readers never message the connection.

## Rows

  * `{:status, %{state:, era:, version:, reason:, origin:}}` - the
    connection state (`ConduitMcp.Client.status/1`).
  * `{:server, server | nil}` - what the server reported in the handshake
    (`ConduitMcp.Client.server_info/1`).
  * `{kind, version, items, fetched_at, expires_at, stale?, owner}` - one
    list, for `kind` in `:tools`, `:prompts`, `:resources` and
    `:resource_templates`; `owner` is `:public` or `{:private,
    credential}` (see "Private results").
  * `{{:tool, name}, version, %ConduitMcp.Client.Tool{}}` - a point row
    per item, so a lookup copies one item instead of the list. Prompts are
    keyed `{:prompt, name}`, resources `{:resource, uri}`, templates
    `{:resource_template, uri_template}`.
  * `{:version, n}` - the counter list writes take their version from.
  * `{:credential, credential}` - the identity of the credentials the
    connection sends now.

A list is written with one `:ets.insert/2` of the list row and its point
rows, then the point rows of earlier versions are deleted, so a reader
never sees a half-written list.

## Freshness

`fetched_at` and `expires_at` are `System.monotonic_time(:millisecond)`
values. `expires_at` is `fetched_at` plus the server's `ttlMs` hint
(the minimum over the pages), clamped to the client's `:max_cache_ttl`;
a missing, negative or non-integer `ttlMs` means 0, so by default a list
is never fresh and `ConduitMcp.Client.list_all_tools/2` fetches again.
Nothing polls on TTL: a list is refreshed when a caller asks for it after
it expired, when the server sends `notifications/*/list_changed` (which
also marks it stale at once), and it is marked stale when the connection
is lost. Multi-round-trip interim results and retries are never cached.

## Private results (HT-11)

A server's `cacheScope: "private"` (and a missing `cacheScope`, so every
legacy result) means the answer may differ per credential. Such a list
is stored with the identity of the credentials it was fetched with, and
served only while the connection still sends those: after a change
(`ConduitMcp.Client.set_auth/3`, an OAuth re-authorization for another
principal or authorization server) it is a miss, fetched again, and the
connection deletes every private list at once. A list is `public` only
when every page said so; public lists survive a credential change.

The identity is never a secret: `nil` without credentials, the SHA-256 of
a static bearer token, the SHA-256 of the `{principal, issuer, resource}`
key OAuth tokens are stored under (so a token refresh for the same
principal keeps the cache), or of an `:auth` function's declared
`:credential_id`. An `:auth` function without one is called before every
request and may return anyone's token, so its private results are never
stored: every listing asks the server, and `ConduitMcp.Client.cached_tools/2`
and `ConduitMcp.Client.tool/3` find nothing private.

## Dead tables

The table dies with its connection. A read that races that gets
`{:error, %ConduitMcp.Client.Error{kind: :unavailable, reason: :cache_gone}}`
instead of an `ArgumentError`.

# `credential`

```elixir
@type credential() :: nil | :unshared | {atom(), binary()}
```

The identity of the credentials a list was fetched with (never a secret):
`nil` without credentials, `:unshared` when it cannot be known (an
`:auth` function without `:credential_id`), else a tagged SHA-256 digest.

# `entry`

```elixir
@type entry() :: %{
  items: [term()],
  version: pos_integer(),
  fetched_at: integer(),
  expires_at: integer(),
  stale?: boolean(),
  scope: :public | :private
}
```

A cached list.

# `kind`

```elixir
@type kind() :: :tools | :prompts | :resources | :resource_templates
```

A cached list kind.

# `owner`

```elixir
@type owner() :: :public | {:private, credential()}
```

Who may be served a list: everyone, or holders of one credential.

# `fresh?`

```elixir
@spec fresh?(entry(), integer()) :: boolean()
```

Whether `entry` may be served without asking the server again.

# `kinds`

```elixir
@spec kinds() :: [kind(), ...]
```

Every cached list kind.

# `list`

```elixir
@spec list(:ets.tid(), kind()) ::
  {:ok, entry()} | :miss | {:error, ConduitMcp.Client.Error.t()}
```

The cached `kind` list, `:miss` when it was never fetched or is private
to other credentials than the connection sends now (see "Private
results").

# `lookup`

```elixir
@spec lookup(:ets.tid(), kind(), String.t()) ::
  {:ok, term()} | :miss | {:error, ConduitMcp.Client.Error.t()}
```

The cached item of `kind` keyed `key` (a tool or prompt name, a resource
URI, a template's `uriTemplate`), `:miss` when it is not in the last
fetched list or that list is not served (`list/2`).

# `status`

```elixir
@spec status(:ets.tid()) :: {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}
```

The `:status` row merged with the `:server` row (`server: nil` before a handshake).

# `ttl`

```elixir
@spec ttl(term(), non_neg_integer()) :: non_neg_integer()
```

The TTL in milliseconds for a server `ttlMs` hint: a non-negative
integer clamped to `max`; anything else is 0.

    iex> ConduitMcp.Client.Cache.ttl(90_000, 3_600_000)
    90000
    iex> ConduitMcp.Client.Cache.ttl(nil, 3_600_000)
    0
    iex> ConduitMcp.Client.Cache.ttl(10_000_000, 3_600_000)
    3600000

---

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