Hex.pm Hexdocs.pm Elixir CI License: MIT Elixir

OAuth resource-server helpers for HTTP MCP servers in Plug/Phoenix: protect the MCP endpoint, publish OAuth discovery metadata, verify Bearer/DPoP/mTLS access tokens, enforce scopes, and hand the verified identity to Anubis when your MCP server runs on Anubis.

Why use this

An MCP server library gives you tools, prompts, resources, and transport lifecycle. OAuth still leaves several resource-server chores at the HTTP boundary:

  • Challenge unauthenticated clients with an RFC 9728 resource_metadata pointer so ChatGPT, Claude, and other MCP clients can discover how to authorize.
  • Verify access tokens locally by signature, issuer, audience, expiry, and sender constraint.
  • Reject DPoP-bound tokens presented as plain Bearer tokens, and reject mTLS-bound tokens without matching certificate context.
  • Enforce route-level MCP scopes before the request reaches your tools.
  • Render OAuth-compatible 401/403 errors through the same host-controlled response envelope.
  • Put verified subject, client, scopes, and raw claims where downstream MCP code can read them.

attesto_mcp packages that glue as Plug modules. You still bring the MCP server implementation and your app's policy.

If you use Anubis

Anubis already has authorization-aware helpers such as Frame.scopes/1, Frame.has_scope?/2, and scope-aware tool visibility. Those helpers read from frame.context.auth. A Plug/Phoenix auth pipeline, however, naturally verifies the request before the Anubis frame exists.

This package connects those two layers:

  1. AttestoMCP.Plug.ProtectResource protects /mcp before the Anubis transport handles the request.
  2. The auth plug assigns a neutral conn.assigns.attesto_context map containing the verified subject, client ID, scopes, claims, confirmation claim, and optional host principal.
  3. AttestoMCP.Anubis.put_auth/1 projects that context into frame.context.auth, the place Anubis expects it.
pipeline :mcp_auth do
  plug AttestoMCP.Plug.ProtectResource,
    config: &MyApp.Attesto.config/0,
    replay_check: &MyApp.DPoPReplay.check_and_record/2,
    resource: "/mcp",
    scopes: [AttestoMCP.Scopes.tools_call()]
end

def handle_request(request, frame) do
  frame = AttestoMCP.Anubis.put_auth(frame)
  # Anubis authorization helpers now see the verified subject/scopes/claims.
end

That saves an Anubis host from hand-writing token parsing, DPoP proof checks, protected-resource challenges, scope rejection responses, and the frame.context.auth projection. It does not add role, tenant, admin, or tool visibility policy; keep that in your app.

anubis_mcp is optional. The bridge module compiles only when Anubis is present, so non-Anubis MCP servers do not take a hard dependency on it.

Clustered or persistent sessions (optional)

If you run Anubis across multiple nodes, or want MCP sessions to survive a deploy, two optional adapters fill gaps in Anubis's bundled options (both compile-guarded, so an RS-only consumer pulls in neither):

See each module's docs for wiring details.

MCP authorization and metadata

The MCP authorization spec treats a protected HTTP MCP server as an OAuth resource server. Clients discover authorization information through OAuth Protected Resource Metadata (RFC 9728), then use Authorization Server Metadata (RFC 8414) for issuer endpoints.

This package provides builders for:

  • /.well-known/oauth-protected-resource metadata.
  • authorization_servers handoff to one or more issuers.
  • issuer, jwks_uri, authorization_endpoint, and token_endpoint metadata via Attesto's authorization-server metadata builder.
  • Resource identifier handling through the explicit :resource value you pass.

It intentionally avoids a hard dependency on a specific Elixir MCP SDK. Anubis gets a bridge because its frame authorization contract is widely used and small to support; the core auth boundary remains a normal Plug boundary.

Per-resource audience confinement (RFC 8707 + RFC 9728)

A protected resource advertises its own identifier as the RFC 9728 metadata resource; a spec-correct client echoes that identifier back as the RFC 8707 resource parameter at the token endpoint, and the authorization server mints the token's aud to it (see attesto/attesto_phoenix). The resource server's job is the last link: validate that the presented token's aud is this resource, so a token minted for a sibling endpoint cannot be replayed here.

ProtectResource / Plug.Authenticate enforce that with resource_audience:

plug AttestoMCP.Plug.ProtectResource,
  config: &MyApp.Attesto.config/0,
  resource: "/mcp",
  base_url: "https://mcp.example.com",   # pin the origin behind a proxy
  resource_audience: :resource,          # validate aud == this resource's identifier
  scopes: [AttestoMCP.Scopes.tools_call()]

resource_audience: :resource validates the token's aud against this endpoint's identifier (base_url + resource path) instead of the host's global config.audience. That identifier is computed by the same AttestoMCP.Metadata.resource_identifier/3 that produces the advertised metadata resource, so the chain — metadata.resource == requested resource == minted aud == validated aud — holds by construction. You can also pass a literal string or a (conn -> uri) / {m, f} callback.

Pin the origin with :base_url when you enable this behind a TLS-terminating proxy: the identifier is otherwise derived from the live request origin (Host / forwarded headers), which an attacker could spoof to a sibling resource's identifier. resource_audience is opt-in so existing single-audience deployments are unaffected; enabling it (with a pinned origin) is the recommended wiring for any server that fronts more than one MCP resource.

What this package is not

attesto_mcp does not implement MCP, JSON-RPC, tools, prompts, resources, transports, or server lifecycle. It wraps the HTTP endpoint your MCP server implementation exposes and connects that endpoint to Attesto's OAuth/OIDC token verification, DPoP proof verification, mTLS certificate binding, scope algebra, and metadata builders.

attesto is the protocol engine: JWT access tokens, DPoP, mTLS, PKCE, JWKS, discovery, and scopes. attesto_mcp reuses those checks and adds MCP-facing Plug and Anubis ergonomics.

attesto_phoenix is the Phoenix/Ecto authorization-server layer: routes, controllers, registration, stores, and Phoenix-friendly configuration. MCP servers that need dynamic client registration should expose it through the authorization server layer rather than duplicate RFC 7591 here.

Installation

def deps do
  [
    {:attesto_mcp, "~> 0.8"}
  ]
end

For Phoenix apps, the optional Igniter installer can scaffold the protected resource metadata route and protecting pipeline:

mix attesto_mcp.install --resource-path /mcp --scopes mcp:tools:call

For a fuller Phoenix wiring example, see the MCP wiring guide.

Minimal Plug/Phoenix usage

Protect the mounted MCP endpoint before forwarding to whichever MCP server plug you use:

pipeline :mcp_auth do
  plug AttestoMCP.Plug.Authenticate,
    config: &MyApp.Attesto.config/0,
    htu: fn _conn -> "https://mcp.example.com/mcp" end,
    replay_check: &MyApp.DPoPReplay.check_and_record/2,
    resource_path: "/mcp",
    principal: fn claims, sender ->
      MyApp.Principals.from_token(claims, sender)
    end

  plug AttestoMCP.Plug.RequireScopes,
    scopes: [AttestoMCP.Scopes.tools_call()]
end

scope "/" do
  pipe_through [:mcp_auth]
  forward "/mcp", to: MyApp.MCPServerPlug
end

AttestoMCP.Plug.ProtectResource composes the two plugs above — authenticate, then require scopes — into one correctly-ordered, halt-respecting plug, so a route declares both in a single line and both render through the same error envelope and resource_metadata challenge:

plug AttestoMCP.Plug.ProtectResource,
  config: &MyApp.Attesto.config/0,
  replay_check: &MyApp.DPoPReplay.check_and_record/2,
  resource: "/mcp",
  scopes: [AttestoMCP.Scopes.tools_call()]

After authentication, downstream code can read:

  • conn.assigns.attesto_mcp_claims
  • conn.assigns.attesto_mcp_scopes
  • conn.assigns.attesto_mcp_sender
  • conn.assigns.attesto_mcp_principal, if :principal is configured
  • conn.assigns.attesto_context - a neutral %{subject, client_id, scope, claims, cnf, principal} map, the same protocol context AttestoPhoenix.Plug.Authenticate assigns

For mTLS-bound access tokens, supply certificate context from your TLS layer:

plug AttestoMCP.Plug.Authenticate,
  config: &MyApp.Attesto.config/0,
  cert_der: fn conn ->
    MyApp.TLS.client_certificate_der(conn)
  end

The callback must return the DER-encoded certificate that the TLS layer already authenticated, or nil when no certificate was presented.

Metadata

The installer mounts the standard metadata routes for a resource path. When building metadata directly, serve protected-resource metadata from the well-known location derived from your MCP resource identifier:

metadata =
  AttestoMCP.Metadata.protected_resource(conn, "/mcp",
    authorization_servers: ["https://auth.example.com"],
    resource_name: "Example MCP server",
    scopes_supported: AttestoMCP.Scopes.all(),
    tls_client_certificate_bound_access_tokens: true
  )

Authorization-server metadata belongs at the issuer:

AttestoMCP.Metadata.authorization_server(config,
  authorization_endpoint: "https://auth.example.com/oauth/authorize",
  token_endpoint_auth_methods_supported: ["client_secret_basic", "private_key_jwt"],
  registration_endpoint: "https://auth.example.com/oauth/register"
)

Dynamic client registration should be exposed by the authorization server. When using attesto_phoenix, enable its registration route and callbacks there. Only advertise registration response fields such as client_secret_expires_at, registration_access_token, and registration_client_uri if the authorization server implementation returns and persists them correctly.

Scope conventions

The package ships common MCP-style scope strings as conventions:

  • mcp:tools:read
  • mcp:tools:call
  • mcp:resources:read
  • mcp:prompts:read

Server-specific prefixes are available:

AttestoMCP.Scopes.server("search", :tools_call)
# "search:mcp:tools:call"

These helpers are not policy. The authorization server decides what to issue and each MCP route decides what to require.

DPoP nonce and replay

DPoP proof replay protection is required for protected-resource requests. Pass a shared :replay_check callback, such as an ETS store for a single node or a database-backed store for clustered deployments. Without that callback, DPoP requests fail closed through Attesto unless you explicitly acknowledge the risk with Attesto's lower-level option.

If the server requires DPoP nonces, also pass :nonce_check and :nonce_issue. Nonce failures produce use_dpop_nonce with a fresh DPoP-Nonce header so the client can retry.

Security notes

  • Use HTTPS for HTTP MCP servers.
  • Validate token audience/resource identifiers for the exact MCP endpoint. When one server fronts more than one resource, enable resource_audience: :resource with a pinned :base_url so a token minted for a sibling resource is rejected (see "Per-resource audience confinement" above).
  • Do not accept access tokens in the URI query string.
  • MCP auth defaults to bearer_methods: [:header]. Enable bearer_methods: [:header, :body] only if your metadata also advertises body credentials and you accept the logging, retry, and replay risks.
  • Do not pass inbound MCP access tokens through to unrelated upstream services.
  • Keep access tokens short-lived and scoped to the smallest MCP capability that can satisfy the request.
  • Prefer DPoP or mTLS sender-constrained tokens for MCP servers exposed beyond a trusted local environment.

Development

mix deps.get
mix format --check-formatted
mix credo --strict
mix test
mix docs

License

MIT. See LICENSE.