ConduitMcp.Client.Cache (ConduitMCP v0.11.0)

Copy Markdown View Source

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.

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

credential()

@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()

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

A cached list.

kind()

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

A cached list kind.

owner()

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

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

Functions

fresh?(map, now)

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

Whether entry may be served without asking the server again.

kinds()

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

Every cached list kind.

list(tid, 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").

lookup(tid, kind, key)

@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(tid)

@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(ttl_ms, max)

@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