Seals and opens the MRTR requestState token.
The spec requires servers to treat requestState as attacker-controlled.
seal/3 encrypts the handler's state term with AES-256-GCM (a fresh 96-bit
IV per seal) and binds it to the request that produced it; open/3
returns the term only when every binding still holds.
Configuration
MRTR requires a keyring:
config :conduit_mcp, mrtr_keys: [{"2026-10", key32}, {"2026-07", old_key32}]Each entry is {kid, key}: kid is a non-empty binary without ".", key
exactly 32 random bytes (e.g. :crypto.strong_rand_bytes(32), loaded from a
secret at runtime). seal/3 always uses the first key; open/3 selects
the key named by the token's kid. To rotate, put the new key first and
keep the old one second until in-flight tokens (at most 5 minutes old) have
expired, then remove it. Rotate well before 2³² seals per key, the GCM
random-IV bound. Keys are read at runtime; when configured they are
validated at application boot (validate_keys!/1). Without :mrtr_keys,
seal/3 raises and open/3 returns :error.
Bindings
The binding passed to both calls is
%{version: v, method: m, params: p, principal: id, server: module}:
- AAD — the protocol
version,method,server(the Server or Endpoint module that handles the request), andkid.:mrtr_keysis application-global, so withoutservera token sealed by one endpoint would open at another endpoint in the same node for the same tool, arguments and principal. - Payload — the
principalid (useConduitMcp.Principal.id/1, the claim-prefixed id, ornilwhen unauthenticated), an expiry (5 minutes, 60 seconds whenprincipalis nil), a SHA-256 digest ofmethodand the salient params, and the state term.
Salient params are the request params minus "_meta",
"inputResponses", and "requestState" — the fields that legitimately
change between rounds — canonicalized as JSON with sorted object keys. A
token presented on a different tool, arguments, URI, or prompt is rejected.
Replay inside the expiry window by the same principal on the same request is bounded, not prevented; single-use redemption needs server-side storage.
Token format
kid <> "." <> Base.url_encode64(iv <> tag <> ciphertext, padding: false).
Tokens longer than 64 KiB are rejected before any decoding. The payload is
JSON; :erlang.binary_to_term/1 is never used.
State terms
The state must be JSON-encodable: nil, booleans, numbers, UTF-8 strings,
atoms (encoded as strings), lists, and maps with string or atom keys.
Tuples, pids, references, funs, ports, and non-UTF-8 binaries raise
ArgumentError at seal time. open/3 returns the decoded JSON, so atom
keys and values come back as strings.
Summary
Functions
Opens a token produced by seal/3, returning {:ok, state}.
Seals state into a requestState token bound to binding.
Validates a :mrtr_keys keyring, raising ArgumentError naming the config
key (never the key bytes) when it is malformed.
Types
Functions
Opens a token produced by seal/3, returning {:ok, state}.
Returns :error — the same value whatever the cause — for an oversize,
truncated, malformed or tampered token, an unknown kid, a different
principal, server module, version, method or salient params, or an expired token.
Options: :now, as in seal/3.
Seals state into a requestState token bound to binding.
Options: :now — current time in milliseconds since the epoch (defaults to
System.system_time(:millisecond)).
Raises ArgumentError when state is not JSON-encodable or :mrtr_keys is
not configured.
@spec validate_keys!(term()) :: :ok
Validates a :mrtr_keys keyring, raising ArgumentError naming the config
key (never the key bytes) when it is malformed.
ConduitMcp.Application calls this at boot when :mrtr_keys is set.