This guide shows the canonical end-to-end wiring for protecting an HTTP MCP
server with attesto_mcp: mount the metadata discovery routes, protect the MCP
endpoint with one plug, and require the scopes the endpoint needs. Every step is
copy-pasteable.
The pieces fit together so that the RFC 9728 resource identifier a client
discovers and the resource_metadata challenge the server returns on a 401
always agree, because both derive from the same request origin and resource
path.
1. Attesto config
attesto_mcp delegates token, DPoP, and mTLS verification to Attesto, so the
host supplies an Attesto.Config (or a zero-arity function returning one).
defmodule MyApp.Attesto do
def config do
Attesto.Config.new(
issuer: "https://auth.example.com",
audience: "https://mcp.example.com/mcp",
keystore: MyApp.Attesto.Keystore
)
end
end2. DPoP replay protection
DPoP proof replay protection is required for protected-resource requests. Wire a
shared :replay_check callback (an ETS store for a single node, a
database-backed store for a cluster). Without it, DPoP requests fail closed
through Attesto.
replay_check = &MyApp.DPoPReplay.check_and_record/23. Mount discovery routes
use AttestoMCP.Router adds attesto_mcp_protected_resource_metadata/2, which
serves /.well-known/oauth-protected-resource/<path> for each resource. For a
single resource it also serves a backwards-compatible root
/.well-known/oauth-protected-resource by default.
defmodule MyAppWeb.Router do
use Phoenix.Router
use AttestoMCP.Router
pipeline :api do
plug :accepts, ["json"]
end
scope "/" do
pipe_through :api
attesto_mcp_protected_resource_metadata "/mcp",
scopes: [AttestoMCP.Scopes.tools_call()],
base_url: "https://mcp.example.com"
end
# ... protected endpoint below
endServing more than one MCP server is one call per resource. Each gets its own metadata document and scope list. A shared root cannot identify all of them, so the macro never allows an implicitly assigned root to survive after another resource is declared. If the first declaration used the single-resource default, adding a second resource is a compile-time error until the first makes its root choice explicit. Either serve no root document:
attesto_mcp_protected_resource_metadata "/mcp/foo",
scopes: ["foo:mcp:tools:call"],
root: false
attesto_mcp_protected_resource_metadata "/mcp/bar",
scopes: ["bar:mcp:tools:call"],
root: falseOr explicitly nominate exactly one resource for legacy root discovery:
attesto_mcp_protected_resource_metadata "/mcp/foo",
scopes: ["foo:mcp:tools:call"],
root: false
attesto_mcp_protected_resource_metadata "/mcp/bar",
scopes: ["bar:mcp:tools:call"],
root: truemix attesto_mcp.install uses root: false, so sequential installations are
safe by default. Change one declaration to root: true only when a legacy
client requires the unsuffixed document.
4. Protect the endpoint with one plug
AttestoMCP.Plug.ProtectResource composes authentication and scope enforcement
into one correctly ordered, halt-respecting plug. The :resource it is given is
the same path mounted for discovery above, so the resource_metadata challenge
on a 401 points at the route from step 3.
pipeline :mcp do
plug :accepts, ["json", "sse"]
plug AttestoMCP.Plug.ProtectResource,
config: &MyApp.Attesto.config/0,
replay_check: &MyApp.DPoPReplay.check_and_record/2,
resource: "/mcp",
base_url: "https://mcp.example.com",
resource_audience: :resource,
scopes: [AttestoMCP.Scopes.tools_call()],
principal: fn claims, sender ->
MyApp.Principals.from_token(claims, sender)
end
end
scope "/" do
pipe_through :mcp
forward "/mcp", MyApp.MCPServerPlug
endThe metadata declaration and protection pipeline must use the same resource
path, scopes, and origin. resource_audience: :resource makes that identifier
the token audience check, so a sibling resource cannot accept the token merely
because it requires the same scope. A scalar aud must equal the identifier,
and every member of an array-valued aud must equal it. Do not combine this
route-derived policy with Attesto core's trusted_audiences; configure one or
the other. When :base_url/:origin is omitted, both sides derive the origin
from the request; pin the same value on both behind a reverse proxy.
Combined authorization server and multiple MCP resources
One Phoenix host can mount one attesto_phoenix authorization-server catalog
and one protected-resource metadata declaration per MCP endpoint. Keep MCP
metadata in attesto_mcp, suppress the authorization server's root PRM route,
and make the browser-facing pipeline override explicit:
scope "/" do
attesto_routes(
prefix: "/mcp",
pipeline: :oauth_common,
route_pipelines: [
interactive: [:oauth_interactive, :oauth_common]
],
registration: true,
protected_resource_root: false
)
attesto_mcp_protected_resource_metadata "/mcp/alpha",
scopes: ["alpha:tools"],
base_url: "https://mcp.example.com",
root: false
attesto_mcp_protected_resource_metadata "/mcp/beta",
scopes: ["beta:tools"],
base_url: "https://mcp.example.com",
root: false
endProtect the resources with matching paths, origins, and scope lists:
pipeline :mcp_alpha do
plug :accepts, ["json", "sse"]
plug AttestoMCP.Plug.ProtectResource,
config: &MyApp.Attesto.resource_config/0,
resource: "/mcp/alpha",
scopes: ["alpha:tools"],
base_url: "https://mcp.example.com",
resource_audience: :resource
end
pipeline :mcp_beta do
plug :accepts, ["json", "sse"]
plug AttestoMCP.Plug.ProtectResource,
config: &MyApp.Attesto.resource_config/0,
resource: "/mcp/beta",
scopes: ["beta:tools"],
base_url: "https://mcp.example.com",
resource_audience: :resource
endThe authorization-server configuration must allow both exact identifiers so
clients can send them as RFC 8707 resource parameters and receive confined
access-token audiences:
resource_indicators: [
allowed_resources: [
"https://mcp.example.com/mcp/alpha",
"https://mcp.example.com/mcp/beta"
]
]The resulting chain is exact for each endpoint:
metadata.resource == requested resource == token aud == validated audience.
The host still owns its session/resource-owner authentication, CSRF, and
content-negotiation policy; externally submitted OAuth protocol POSTs should
not inherit generic browser-only handling.
After authentication, downstream code can read:
conn.assigns.attesto_mcp_claimsconn.assigns.attesto_mcp_scopesconn.assigns.attesto_mcp_senderconn.assigns.attesto_mcp_principal, if:principalis configured
5. mTLS-bound tokens (optional)
For mTLS sender-constrained tokens, supply certificate context from the TLS
layer. The callback returns the DER-encoded certificate the TLS layer already
authenticated, or nil when none was presented.
plug AttestoMCP.Plug.ProtectResource,
config: &MyApp.Attesto.config/0,
resource: "/mcp",
scopes: [AttestoMCP.Scopes.tools_call()],
cert_der: fn conn -> MyApp.TLS.client_certificate_der(conn) end6. Test the binding contract
AttestoMCP.Test.DPoPAssertions ships ExUnit assertions that drive your wired
pipeline and prove DPoP binding holds: a DPoP-bound token presented as a plain
Bearer is rejected, and the same token presented with a valid proof is accepted.
defmodule MyAppWeb.MCPAuthTest do
use ExUnit.Case
import AttestoMCP.Test.DPoPAssertions
setup do
%{config: AttestoMCP.Test.Factory.config()}
end
test "the MCP pipeline enforces DPoP binding", %{config: config} do
plug = fn conn ->
opts =
AttestoMCP.Plug.ProtectResource.init(
config: config,
replay_check: AttestoMCP.Test.DPoPReplay.callback(),
scopes: [AttestoMCP.Scopes.tools_call()]
)
AttestoMCP.Plug.ProtectResource.call(conn, opts)
end
assert_dpop_bound_bearer_rejected(plug, config, scopes: [AttestoMCP.Scopes.tools_call()])
assert_dpop_proof_accepted(plug, config, scopes: [AttestoMCP.Scopes.tools_call()])
end
end