ConduitMcp.MRTR.State (ConduitMCP v0.11.0)

Copy Markdown View Source

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), and kid. :mrtr_keys is application-global, so without server a token sealed by one endpoint would open at another endpoint in the same node for the same tool, arguments and principal.
  • Payload — the principal id (use ConduitMcp.Principal.id/1, the claim-prefixed id, or nil when unauthenticated), an expiry (5 minutes, 60 seconds when principal is nil), a SHA-256 digest of method and 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

Types

What a token is bound to; principal is a derived principal id or nil.

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

binding()

@type binding() :: %{
  version: String.t(),
  method: String.t(),
  params: map() | nil,
  principal: String.t() | nil,
  server: module()
}

What a token is bound to; principal is a derived principal id or nil.

Functions

open(token, binding, opts \\ [])

@spec open(term(), binding(), keyword()) :: {:ok, term()} | :error

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.

seal(state, binding, opts \\ [])

@spec seal(term(), binding(), keyword()) :: String.t()

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.

validate_keys!(keys)

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