# `GenMCP.Token`
[🔗](https://github.com/lud/gen_mcp/blob/main/lib/gen_mcp/token.ex#L1)

Authenticated encryption for the opaque tokens the library hands back to
clients and later verifies.

Some values the server produces have to make a round trip through an
untrusted client and come back intact: a pagination cursor returned by
`resources/list` and replayed on the next page, or the `requestState` blob a
tool returns when it needs another round trip (see the MRTR pattern). The
server is stateless, so it cannot remember what it issued. Instead it seals
the value into a token that any per-request state, on any node, can verify
using the application's `secret_key_base`.

This module is a thin wrapper over `Phoenix.Token`, which provides the
authenticated encryption (XChaCha20-Poly1305 via `Plug.Crypto`). It is not a
general key/value store: it encrypts a term, hands the caller an opaque
string, and decrypts that string back to the original term while rejecting
anything forged, tampered, expired, or minted for a different purpose.

The simplest round trip uses a `secret_key_base` string as the key source:

    iex> key = "Iy0gLZpcS5ENbZS0jJ0mIVOZD7aOu4Pn7D8BiNUyrJVzlAevQUCFGDQDmprQyevy"
    iex> token = GenMCP.Token.encrypt(key, {:cursor, "resources/list"}, "page-2")
    iex> GenMCP.Token.decrypt(key, {:cursor, "resources/list"}, token)
    {:ok, "page-2"}

### Key source

The first argument is a key source, of type `t:key_source/0`. It may be a
`GenMCP.Mux.Channel`, which carries the Phoenix endpoint the transport copied
from the connection, or anything `Phoenix.Token` itself accepts: an endpoint
module, a `secret_key_base` string, or a `Plug.Conn`. Inside request handling
you usually pass the `channel` you already hold:

    token = GenMCP.Token.encrypt(channel, {:cursor, "resources/list"}, next_cursor)

    case GenMCP.Token.decrypt(channel, {:cursor, "resources/list"}, token) do
      {:ok, cursor} -> # resume listing from this cursor
      {:error, :invalid} -> # not a cursor we issued for this method
      {:error, :expired} -> # cursor too old
    end

A channel whose `endpoint` is `nil` raises `ArgumentError`: there is no key to
work with. In practice the transport always sets it.

Because the key is the application's own `secret_key_base`, a token minted on
one node verifies on any other node sharing that configuration. The key is
never embedded in the token: `Plug.Crypto` stretches it with PBKDF2.

### Purposes

The second argument is a purpose of type `t:purpose/0`, a `{kind, qualifier}`
pair that frames what the token is for:

  * `{:cursor, method}` - a pagination cursor, qualified by the MCP method it
    paginates (for example `"resources/list"` or `"prompts/list"`).
  * `{:reqstate, unicity}` - an MRTR `requestState` blob, qualified by a map
    that binds it to the exact call that produced it (the tool name and its
    arguments).
  * `{:session, namespace}` - the client data a 2025 compatibility session id
    carries, qualified by the namespace of the session format.

The purpose kind and its bounded qualifiers participate in key derivation, so
a token only ever reads back under the exact purpose it was minted for. A
`resources/list` cursor replayed on `prompts/list` is rejected, and a cursor
can never pass as a request state. For a `{:reqstate, unicity}` purpose the
binding to the specific call travels inside the token: the map is hashed
deterministically, the hash is sealed alongside the value, and `decrypt/4`
rehashes the purpose it is given and compares — a blob presented with
different tool arguments is `{:error, :invalid}`. The deterministic hash
verifies regardless of map key order, which is what lets a retry on another
node rebuild the same purpose without coordinating map construction.

All `gen_mcp` tokens carry a salt namespace that keeps them distinct from any
`Phoenix.Token` the host application mints from the same endpoint, even if the
application happens to use a bare method name as its own salt.

### Expiry

Tokens carry a default `:max_age` of 20 minutes, set at encrypt time.
`Plug.Crypto` embeds the mint-time `:max_age` inside the encrypted payload, so
`decrypt/4` enforces it without the call site having to remember. Pass an
explicit `:max_age` (in seconds) to either function to override it.

### Key derivation cost

Deriving a key from the `secret_key_base` and a salt runs PBKDF2, which costs
around 0.2ms. `Plug.Crypto` memoizes the result in a node-wide table keyed by
salt, so each distinct salt pays that cost once. Every salt here comes from a
bounded set — a method name, a format namespace, a purpose kind — so the
table stays a handful of rows and token operations run at the memoized cost.

# `key_source`

```elixir
@type key_source() :: GenMCP.Mux.Channel.t() | module() | binary() | Plug.Conn.t()
```

# `purpose`

```elixir
@type purpose() :: {:cursor, String.t()} | {:reqstate, map()} | {:session, String.t()}
```

# `decrypt`

```elixir
@spec decrypt(key_source(), purpose(), binary() | nil, keyword()) ::
  {:ok, term()} | {:error, :invalid | :expired | :missing}
```

Reads a token minted by `encrypt/4` back to its original term.

Returns `{:ok, term}` when the token is genuine, unexpired, and was minted for
the same `context` and `purpose`. Otherwise it returns one of:

  * `{:error, :invalid}` - the token is forged, tampered, minted under a
    different key, or minted for a different purpose (a wrong method, a wrong
    `{tool, args}` binding, or the other kind).
  * `{:error, :expired}` - the token is past its `:max_age`.
  * `{:error, :missing}` - the token is `nil`.

### Arguments

  * `context` - the key source (`t:key_source/0`), the same as the one passed
    to `encrypt/4`. A `GenMCP.Mux.Channel` supplies the Phoenix endpoint and
    thus the application's `secret_key_base`.
  * `purpose` - the `t:purpose/0` the token must have been minted for.
  * `token` - the token string, or `nil`.
  * `opts` - forwarded to `Phoenix.Token.decrypt/4`. Without a `:max_age` the
    mint-time value embedded in the token is enforced; pass `:max_age` (in
    seconds) to override it.

### Examples

A token decrypts only under the purpose it was minted for, and a `nil` token
reports `:missing`:

    iex> key = "Iy0gLZpcS5ENbZS0jJ0mIVOZD7aOu4Pn7D8BiNUyrJVzlAevQUCFGDQDmprQyevy"
    iex> token = GenMCP.Token.encrypt(key, {:cursor, "resources/list"}, "page-2")
    iex> GenMCP.Token.decrypt(key, {:cursor, "resources/list"}, token)
    {:ok, "page-2"}
    iex> GenMCP.Token.decrypt(key, {:cursor, "prompts/list"}, token)
    {:error, :invalid}
    iex> GenMCP.Token.decrypt(key, {:cursor, "resources/list"}, nil)
    {:error, :missing}

# `encrypt`

```elixir
@spec encrypt(key_source(), purpose(), term(), keyword()) :: binary()
```

Seals `term` into an opaque token bound to `purpose`.

Returns an encrypted, authenticated string. The token can be carried by an
untrusted client and read back later with `decrypt/4`, as long as the same key
source and purpose are used. The `term` can be any Erlang term.

### Arguments

  * `context` - the key source (`t:key_source/0`). A `GenMCP.Mux.Channel`
    supplies the Phoenix endpoint it carries, whose `secret_key_base` becomes
    the encryption key; you may also pass an endpoint module, a
    `secret_key_base` string, or a `Plug.Conn`.
  * `purpose` - the `t:purpose/0` the token is for. It is folded into key
    derivation, so the token only decrypts under this same purpose.
  * `term` - the value to seal.
  * `opts` - forwarded to `Phoenix.Token.encrypt/4`. A `:max_age` of 20
    minutes is added when you do not pass one, and is embedded in the payload
    so `decrypt/4` enforces it.

### Examples

    iex> key = "Iy0gLZpcS5ENbZS0jJ0mIVOZD7aOu4Pn7D8BiNUyrJVzlAevQUCFGDQDmprQyevy"
    iex> token = GenMCP.Token.encrypt(key, {:cursor, "resources/list"}, "page-2")
    iex> is_binary(token)
    true

---

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