Skip to main content

CHANGELOG.md

# Changelog

All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.0.0] - 2026-07-04

First stable release; the public API is now under semantic versioning. No
functional change from 0.11.0. Requires `attesto ~> 1.0`.

## [0.11.0] - 2026-06-23

### Added

- **Clustered + persistent Anubis MCP session infrastructure** (optional,
  compile-guarded — an RS-only consumer pays nothing):
  - `AttestoMCP.Anubis.SessionStore.Ecto` — a Postgres-backed
    `Anubis.Server.Session.Store`. Anubis ships only a Redis adapter; this
    persists the session map (keyed by `Mcp-Session-Id`) so a client reconnects
    after a deploy/node-replacement with its initialized state restored instead
    of re-initializing. Stateless (`start_link/1` is `:ignore`, rides the host
    repo), with lazy + scheduled (`cleanup_expired/1`) TTL reaping. Backed by the
    `AttestoMCP.Anubis.Session` schema; `mix attesto_mcp.gen.session_migration`
    creates the `attesto_mcp_sessions` table. Compile-guarded on `Ecto.Schema`.
  - `AttestoMCP.Anubis.JSONSafe.sanitize/1` — strips non-JSON-encodable values
    (bare structs, pids, refs, tuples, functions) from session state before the
    `jsonb` insert, since Anubis folds the whole request `assigns` into the
    persisted frame; without it the driver raises mid-`initialize` and kills the
    session process.
  - `AttestoMCP.Anubis.Registry.Horde` — a cluster-wide `Anubis.Server.Registry`
    that keeps the client-supplied `session_id` as CRDT/ETS data (a `:via`
    tuple), closing the **atom-table-exhaustion DoS** in the bundled `:pg`
    adapter (which derives a never-GC'd atom per session id), and routes a
    request landing on any node to the node holding the session. Compile-guarded
    on `Horde.Registry`.
  - `mix attesto_mcp.install.sessions` — an Igniter installer that writes the
    `config :anubis_mcp, :session_store` block (and, with `--registry`, adds the
    `horde` dependency), and prints next-step notices for the migration and the
    supervision-tree registry wiring.

## [0.10.0] - 2026-06-22

### Added

- **RFC 9470 Step-Up Authentication.** `AttestoMCP.Plug.ProtectResource` /
  `Plug.Authenticate` accept a per-route `:step_up` requirement
  (`[acr_values: [...], max_age: ...]`); after the token is verified its
  `acr` / `auth_time` claims must satisfy it or the request is refused 401
  `insufficient_user_authentication` naming what the client must re-request.
- `attesto_mcp_protected_resource_metadata/2` accepts `root: true | false` to
  control the unsuffixed RFC 9728 root compatibility document explicitly.

### Changed

- The root `/.well-known/oauth-protected-resource` compatibility document is now
  auto-mounted only for a **single-resource** router. Once more than one
  resource is declared its resource is ambiguous, so the host must choose with
  `root: true` (nominate the resource) or `root: false` (serve no root); a second
  resource that leaves the root implicit is a compile error. Previously the root
  silently resolved to whichever resource was declared first, which could publish
  a sensitive resource's metadata at a guessable path by accident.

## [0.9.0] - 2026-06-22

### Added

- **RFC 8707 / RFC 9728 per-resource audience confinement.**
  `AttestoMCP.Plug.ProtectResource` / `Plug.Authenticate` gain a
  `:resource_audience` option: with `:resource` (or a string / `(conn -> uri)` /
  `{m, f}` value) the access token's `aud` is validated against THIS resource's
  identifier rather than the host's global `config.audience`, so a token minted
  for a sibling resource is rejected (audience confinement, RFC 8707 §1).
- `AttestoMCP.Metadata.resource_identifier/3` — the single canonical
  origin-plus-path identifier shared by the advertised metadata `resource` and
  the validated audience, so `metadata.resource == requested resource == minted
  aud == validated aud` holds by construction.

### Changed

- Requires `attesto ~> 0.10`. `:resource_audience: :resource` raises (fails
  closed) when no resource path is configured to derive the identifier from.

## [0.8.0] - 2026-06-21

### Changed

- **MCP protected-resource authentication defaults to header-only bearer
  credentials.** `AttestoMCP.Plug.Authenticate` and `ProtectResource` pass
  `bearer_methods: [:header]` to attesto core unless the host opts in with
  `bearer_methods: [:header, :body]`. This matches the MCP protected-resource
  metadata default of `bearer_methods_supported: ["header"]`. **Requires
  `attesto ~> 0.9`.**

## [0.7.0] - 2026-06-21

### Added

- **`AttestoMCP.Anubis.put_auth/1`** — an optional bridge that projects the
  verified `:attesto_context` into an Anubis MCP server's `frame.context.auth`,
  so Anubis's framework-level authorization (the `tools/list` visibility filter,
  per-tool scope gates) sees the resource-server identity. `anubis_mcp` is an
  optional dependency and the module is compile-guarded on `Anubis.Server.Frame`,
  so a resource server that does not use Anubis never compiles it or pulls it in.
  The projection is purely mechanical — no scope-superset, role, or visibility
  policy, which stay in the host app.
- **`AttestoMCP.Plug.Authenticate` now assigns `:attesto_context`** — a single
  protocol-shaped context map (`%{subject, client_id, scope, claims, cnf,
  principal}`), identical to the one `AttestoPhoenix.Plug.Authenticate` assigns,
  under the new `:context_key` option (default `:attesto_context`). This is the
  canonical cross-plug auth context the Anubis bridge reads, so the bridge works
  whether a request was authenticated by the MCP plug or the Phoenix plug.

### Changed

- **MCP scope-rejection rendering now delegates fully to attesto core.**
  `AttestoMCP.Plug.RequireScopes` renders its 403 `insufficient_scope` through
  `Attesto.Plug.OAuthError.insufficient_scope/4`, which now honors the
  `:send_error` / `:www_authenticate` / `:no_store` transport hooks. This removes
  the local 403 gap-filler and the `AttestoMCP.Plug.Error` shim (the on-the-wire
  challenge and body are unchanged). **Requires `attesto ~> 0.8.1`.**
- **The `:no_store` transport hook** now threads through `RequireScopes` and
  `ProtectResource`, at parity with `:send_error` / `:www_authenticate`.

### Fixed

- The `mix attesto_mcp.install` tests declare `phx_new` (test-only) so
  `Igniter.Test.phx_test_project/1` has the Phoenix project generator
  (Igniter.Phoenix.Single, compile-guarded on Phx.New.Project) it needs.

## [0.6.2] - 2026-06-14

### Fixed

- **The `mix attesto_mcp.install` task no longer references `Igniter.Mix.Task`
  unconditionally, so attesto_mcp compiles in a consumer that does not depend on
  `igniter`.** `igniter` is an optional dependency, but the install task did
  `use Igniter.Mix.Task` at the top level. In a consumer application that
  depends on attesto_mcp without also pulling in `igniter` (the common prod
  case), compiling attesto_mcp failed with `module Igniter.Mix.Task is not
  loaded and could not be found`. The task is now wrapped in a top-level
  `if Code.ensure_loaded?(Igniter)` guard: the Igniter-backed installer compiles
  only when `igniter` is present, and a `use Mix.Task` fallback that prints a
  "install igniter to use this task" message takes its place otherwise. Behavior
  is unchanged when `igniter` is available.

## [0.6.1] - 2026-06-14

### Fixed

- **`AttestoMCP.Plug.ProtectResource` can again be used as a compile-time router
  pipeline plug (`plug_init_mode: :compile`).** 0.6.0 made `init/1` bake the
  generated `resource_metadata` `WWW-Authenticate` closure into the
  `RequireScopes` transport. Under `:compile` mode (the Phoenix router /
  production default) a plug's `init/1` result is embedded via `Macro.escape`,
  which rejects anonymous functions, so a router carrying the plug failed to
  compile. The generated challenge is now built at **call** time from an
  escape-safe spec (strings / `{m, f}` tuples), so `init/1` returns no closures
  and the plug compiles in a `:compile`-mode pipeline. Behavior is unchanged: an
  insufficient-scope 403 still carries the `resource_metadata` pointer, and a
  host-supplied `:www_authenticate` still wins. (Callbacks the host passes to the
  plug must be remote captures or MFA tuples, not anonymous `fn`, to be embedded
  under `:compile` mode — the same constraint Plug imposes on every plug.)

## [0.6.0] - 2026-06-14

### Added

- **Origin pinning for protected-resource metadata behind a proxy.** A host can
  now pin the advertised `resource`/challenge origin and `authorization_servers`
  instead of always deriving them from the live request connection, which behind
  a TLS-terminating proxy is both fragile (`http`/internal host) and an
  `X-Forwarded-Host` spoofing vector into the metadata document a client trusts
  to find its authorization server.
  - `AttestoMCP.Metadata.resolve_origin/2` resolves the resource server origin:
    an explicit `:base_url`/`:origin` (a `String.t()` or `(conn -> url)`) wins
    over the request connection. It drives the `resource` identifier and the
    `protected_resource_url/3` challenge URL.
  - `AttestoMCP.Metadata.protected_resource/3` now defaults
    `authorization_servers` to the `:config` issuer (or an explicit `:issuer`
    string) when one is given, rather than the resource origin — the issuer is
    the authorization server the host already trusts. The `resource` identifier
    is **not** derived from the issuer (RFC 9728 keeps the two distinct).
  - The router macro `attesto_mcp_protected_resource_metadata`,
    `AttestoMCP.MetadataController`, `AttestoMCP.Plug.Authenticate`, and
    `AttestoMCP.Plug.ProtectResource` all accept and thread `:base_url`/`:origin`
    (and `:issuer`/`:config`), so the served metadata and the
    `WWW-Authenticate` `resource_metadata` challenge stay aligned when pinned.
    `:base_url`/`:origin` accept a string, a `(conn -> url)` callback, or a
    `{module, fun}` / `{module, fun, args}` tuple (so a dynamic origin works in a
    compiled router macro, where an anonymous fn cannot).
  - The generated `resource_metadata` challenge is now emitted consistently on
    every rejection the MCP plugs render - token failure, principal-callback
    rejection (a 401 from `AttestoMCP.Plug.Authenticate`), and insufficient-scope
    rejection (a 403 from `AttestoMCP.Plug.RequireScopes` via `ProtectResource`)
    - so a client is pointed at metadata on all of them, not just token failures.
  - New `guides/proxy_origin.md` documents the pinned-origin recipe so consumers
    do not reinvent a canonical-host guard plug.

### Fixed

- `authorization_servers` advertises the issuer **verbatim**: it is no longer
  trailing-slash-trimmed, since an issuer identifier is compared by exact string
  match (a trimmed path-based issuer would break discovery). The resource origin
  is still trimmed (it is joined with a path). A blank explicit `:issuer` is
  ignored rather than publishing `[""]` or overriding a configured issuer.
- A blank, relative, or non-binary `:base_url`/`:origin` pin (`""`, `"/"`,
  `"/prefix"`, `"mcp.example.com"`, …) is treated as "not configured" and falls
  back (`:base_url` → `:origin` → request origin) instead of producing relative
  or empty security metadata (no more `resource: "/mcp"` or
  `authorization_servers: [""]`). A pin must be an absolute `scheme://…` origin.
- An explicit `:resource` / `:authorization_servers` now short-circuits the
  derivation, so a lower-precedence origin/issuer callback is never invoked (and
  cannot fail) for a value the host supplied outright. The resource origin is
  also resolved at most once per document and shared with the
  `authorization_servers` fallback, so a stateful origin callback cannot make
  the `resource` and `authorization_servers` disagree.

### Changed

- `AttestoMCP.Metadata.protected_resource/3` now sets `resource` with `put_new`
  (was `put`), so an explicit `:resource` opt overrides the derived identifier —
  matching how `:authorization_servers` already behaved.
- With no pinning options supplied, behavior is unchanged: both origins derive
  from the request connection.

## [0.5.2] - 2026-05-31

### Fixed

- Correct the README installation snippet now that the package is published on
  Hex.

## [0.5.1] - 2026-05-31

### Changed

- Reuse `Attesto.Test.DPoP` for MCP DPoP proof fixtures so downstream MCP tests
  stay aligned with Attesto's published DPoP helper API.

## [0.5.0] - 2026-05-31

### Added

- `AttestoMCP.Plug.ProtectResource`: a single plug composing
  `AttestoMCP.Plug.Authenticate` then `AttestoMCP.Plug.RequireScopes` into a
  correctly ordered, halt-respecting pipeline, with the RFC 9728
  `resource_metadata` `WWW-Authenticate` challenge auto-wired from the resource
  path.
- `AttestoMCP.Router` with the `attesto_mcp_protected_resource_metadata/2`
  Phoenix router macro, and `AttestoMCP.MetadataController`, serving
  per-resource `/.well-known/oauth-protected-resource/<path>` metadata plus a
  backwards-compatible root `/.well-known/oauth-protected-resource` route. The
  served `resource` identifier matches the `ProtectResource` challenge.
- `AttestoMCP.Test.DPoPAssertions`: shipped ExUnit assertions for host apps
  proving a DPoP-bound token presented as a plain Bearer is rejected and is
  accepted with a valid DPoP proof.
- `guides/mcp_wiring.md`: copy-pasteable end-to-end wiring guide.
- `phoenix` as an optional dependency (only needed by `AttestoMCP.Router` and
  `AttestoMCP.MetadataController`).
- Initial Plug/Phoenix authentication wrapper for protecting HTTP MCP endpoints
  with Attesto access-token verification, DPoP proof checks, and mTLS
  certificate-bound token checks.
- MCP scope convention helpers.
- OAuth protected-resource metadata builder and authorization-server metadata
  delegation.
- Focused tests for Bearer, DPoP, mTLS, scope enforcement, principal mapping,
  custom error rendering, and public assign names.