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, forkindin:tools,:prompts,:resourcesand:resource_templates;owneris:publicor{: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.
Summary
Types
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.
A cached list.
A cached list kind.
Who may be served a list: everyone, or holders of one credential.
Functions
Whether entry may be served without asking the server again.
Every cached list kind.
The cached kind list, :miss when it was never fetched or is private
to other credentials than the connection sends now (see "Private
results").
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).
The :status row merged with the :server row (server: nil before a handshake).
The TTL in milliseconds for a server ttlMs hint: a non-negative
integer clamped to max; anything else is 0.
Types
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.
@type entry() :: %{ items: [term()], version: pos_integer(), fetched_at: integer(), expires_at: integer(), stale?: boolean(), scope: :public | :private }
A cached list.
@type kind() :: :tools | :prompts | :resources | :resource_templates
A cached list kind.
@type owner() :: :public | {:private, credential()}
Who may be served a list: everyone, or holders of one credential.
Functions
Whether entry may be served without asking the server again.
@spec kinds() :: [kind(), ...]
Every cached list kind.
@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").
@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).
@spec status(:ets.tid()) :: {:ok, map()} | {:error, ConduitMcp.Client.Error.t()}
The :status row merged with the :server row (server: nil before a handshake).
@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