Auth-specific code: 15 lines · Total example: 187 lines · SDK: python-sdk 0.2.0
A paired MCP server and agent that demonstrate two production-shaped auth concerns layered on top of tier 02:
- DPoP sender-constraint (RFC 9449) — the agent generates an
ephemeral EC P-256 key, presents a DPoP proof on the token request to
bind the access token to that key (
cnf.jkt), and attaches a fresh DPoP proof header to every MCP call. The server enforces the binding on every inbound request. - Per-tool scope enforcement — the server publishes two tools
(
echorequiringmcp:echo,add_numbersrequiringmcp:add) and uses FastMCP'srequire_scopes(...)decorator to reject calls whose token is missing the required scope.
Pair, not "depends on tier 02". This example brings up its own
authservercontainer — it is self-contained. The companion agent (agent.py) ships in the same directory.
- How to enable DPoP-bound access tokens on the authserver via
AUTHPLANE_DPOP_ENABLED - How to mint a DPoP-bound
client_credentialstoken from theauthplane-sdk(AuthplaneClientwith aDPoPProvider) - How to attach a DPoP proof header to outbound MCP calls using
client.dpop_headers("POST", url, access_token=...) - How to enforce DPoP binding on inbound MCP requests by subclassing
AuthplaneTokenVerifierand forwarding aDPoPRequestContexttoAuthplaneResource.verify(token, dpop_request=...) - How to declare per-tool scope requirements with
require_scopesfrom FastMCP
| Time to run | ~3 minutes (first build is ~90s, subsequent runs are seconds) |
| Prereqs | Docker 24+, docker compose, curl, jq, Python 3.12+ (only if you run outside Docker — pyproject.toml pins requires-python = ">=3.12") |
| SDK | authplane-sdk + authplane-fastmcp 0.2.0 (PyPI) |
| MCP framework | fastmcp >= 3.0, < 4 (matches pyproject.toml) |
cp .env.example .env
make run
make verifymake run builds the MCP server image and brings up both services
(authserver on :9000 / :9001, MCP server on :8080); the agent is
defined in the same compose file but stays under the manual profile so
it doesn't start on make run. make verify registers the Resource and
client, then exercises the agent twice — once happy-path (both tools
succeed) and once with the DPoP proof header stripped (the MCP server
must answer 401). make clean tears everything down and removes the
.env file the run target created.
The make verify script automates every step below; the bullets here
describe what's happening so you can reproduce the flow by hand.
If you want to run the curl examples manually instead of via
make verify, first load the env vars and capture client credentials as you go:set -a; source .env; set +a # exports AUTHPLANE_ADMIN_API_KEY etc.Steps 3 and 5 emit
client_idandclient_secretin their responses; assign each to a shell variable (CLIENT_ID=...,CLIENT_SECRET=...) before the next step uses it.
-
Start authserver + MCP server.
make runbrings both up. Wait for the AS discovery endpoint to return 200:curl -fsS http://localhost:9000/.well-known/oauth-authorization-server
The discovery document is served by the AS at the
GET /.well-known/oauth-authorization-serverendpoint. WithAUTHPLANE_DPOP_ENABLED=true(see.env.example) the document advertisesdpop_signing_alg_values_supportedand the token endpoint accepts DPoP proof headers. -
Register a Mint Resource with both scopes. The Resource URI must match the JWT audience the MCP server will expect (
base_url+/mcp). Two scopes are declared so the per-toolrequire_scopes(...)decorators can enforce them independently:curl -sS -X POST http://localhost:9001/admin/resources \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "demo-mcp-dpop", "uri": "http://localhost:8080/mcp", "backend_kind": "mint", "display_name": "Demo MCP (DPoP + per-tool scopes)", "scopes": [ {"name": "mcp:echo", "description": "echo tool"}, {"name": "mcp:add", "description": "add_numbers tool"} ] }'
Endpoint:
POST /admin/resources. The same Resource can be created via the CLI inside the container (seeauthserver admin resource create):docker compose exec authserver /authserver admin resource create \ --slug demo-mcp-dpop \ --uri http://localhost:8080/mcp \ --backend-kind mint \ --display-name "Demo MCP (DPoP + per-tool scopes)" \ --scopes 'mcp:echo||echo tool' \ --scopes 'mcp:add||add_numbers tool'
-
Register an OAuth client. A
client_credentialsmachine client with both scopes pre-approved:curl -sS -X POST http://localhost:9001/admin/clients \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "client_name": "demo-dpop-client", "grant_types": ["client_credentials"], "token_endpoint_auth_method": "client_secret_basic", "scope": "mcp:echo mcp:add" }'
Endpoint:
POST /admin/clients. CLI equivalent:authserver admin client create. -
Run the agent. The agent (see
agent.py):- Generates a fresh EC P-256 keypair with
cryptography.hazmat. - Creates an
AuthplaneClientconfigured with aDPoPProviderbacked by that key. The SDK automatically attaches a DPoP proof header to every AS call (see python-sdk/authplane/dpop.py:294-307). - Calls
client.client_credentials(scopes=["mcp:echo","mcp:add"], resources=["http://localhost:8080/mcp"])to mint a DPoP-bound access token (the AS returnstoken_type: DPoPand embedscnf.jkt— seePOST /oauth/token). - Builds an
Authorization: DPoP <token>header plus a freshDPoP: <proof>header viaclient.dpop_headers("POST", mcp_url, access_token=token)for each MCP call. - Calls
initialize,tools/call echo {"text":"hello"}, andtools/call add_numbers {"a":2,"b":3}— all three must return 200.
make verifyruns the agent inside this example's compose network so it can reach the AS atauthserver:9000and the MCP server atmcp-server:8080. - Generates a fresh EC P-256 keypair with
-
(verify only) Negative probe.
make verifyre-runs the agent withAGENT_DROP_DPOP_PROOF=1, which strips theDPoPheader from the MCP request. The server's verifier compares the token'scnf.jktagainst the missing proof and rejects with 401 — proving the DPoP enforcement is real, not just configured. Skip the negative probe withSKIP_DPOP_NEG=1.
When the agent's AuthplaneClient is constructed with dpop= DPoPProvider(...), the SDK attaches a freshly-signed DPoP proof header
on every outbound call to the AS, including
client_credentials_grant(...). The AS validates the proof (signature,
htm, htu, iat, replay) per RFC 9449 §4 and, when valid, embeds the
JWK thumbprint of the proof key into the issued access token's cnf.jkt
claim. The response sets token_type: "DPoP" instead of "Bearer".
The agent generates a fresh DPoP proof for each MCP call via
client.dpop_headers("POST", mcp_url, access_token=token). The proof
carries an ath claim (SHA-256 of the access token), the request method
and normalized URL, and is signed with the same key the token was bound
to. The agent sends the token under the Bearer scheme together with
the DPoP proof header (Authorization: Bearer <token> + DPoP: <proof>). RFC 9449 §7.1 also defines an Authorization: DPoP <token>
form, but the upstream MCP SDK's BearerAuthBackend only inspects
headers starting with bearer (see
mcp/server/auth/middleware/bearer_auth.py), so the practical
interoperable choice is the Bearer scheme + DPoP header. The
sender-constraint is still enforced — the resource server verifies
cnf.jkt against the DPoP proof regardless of the access-token
scheme.
server.py passes inbound_dpop=InboundDPoPOptions(required=True) to
authplane_auth(...). That kwarg (see the installed package's
authplane_fastmcp/auth.py authplane_auth signature) configures the
underlying AuthplaneResource for inbound DPoP and turns on
dpop_signing_alg_values_supported /
dpop_bound_access_tokens_required advertisement in the PRM
(RFC 9728 §2). On every inbound request the verifier:
- detects
cnf.jkton the token and requires a proof; - validates the proof JWT (signature,
htm,htu,iat,ath, replay) per RFC 9449 §4.3; - compares the proof's JWK thumbprint against the token's
cnf.jkt; - raises
DPoPBindingMismatchError/DPoPProofMissingErrorif anything is off.
The stock AuthplaneTokenVerifier.verify_token in
authplane-fastmcp calls AuthplaneResource.verify(token)
without forwarding a DPoPRequestContext, so we subclass it as
DPoPVerifier to pull the request method, URL, and DPoP header from
FastMCP's fastmcp.server.dependencies.get_http_request(). The
subclass body is plumbing — it lives outside the # authplane:begin /
# authplane:end markers because a future minor release of
authplane-fastmcp is expected to fold the request-context forwarding
into the stock verifier and remove the need for the subclass entirely.
@mcp.tool(auth=require_scopes("mcp:echo")) tells FastMCP to gate the
handler on the presence of that scope in the validated token. FastMCP
checks the scope before the handler runs; missing scope returns HTTP
403 and the handler is never invoked. The same Resource declares both
scopes, so a single token can carry both; the gate is one-per-tool.
The agent goes from "bearer token" to "DPoP-bound token with proof per call":
+ from authplane import DPoPKeyMaterial, DPoPProvider
+ priv_pem = ec.generate_private_key(ec.SECP256R1()).private_bytes(...)
client = await AuthplaneClient.create(
issuer=...,
auth=ASCredentials(CLIENT_ID, CLIENT_SECRET),
+ dpop=DPoPProvider(DPoPKeyMaterial.from_pem(priv_pem)),
dev_mode=True,
)
token = (await client.client_credentials(
- scopes=["mcp:echo"], resources=[RESOURCE_URI],
+ scopes=["mcp:echo", "mcp:add"], resources=[RESOURCE_URI],
)).access_token
headers = {
"Authorization": f"Bearer {token}",
+ **client.dpop_headers("POST", mcp_url, access_token=token),
}The server gains two-scope advertising + a per-tool gate, plus
inbound_dpop= for the AS-side DPoP wiring:
- scopes=["mcp:echo"]
+ scopes=["mcp:echo", "mcp:add"]
+ inbound_dpop=InboundDPoPOptions(required=True)
+ result.auth.token_verifier = DPoPVerifier(...)
+ @mcp.tool(auth=require_scopes("mcp:echo"))
+ @mcp.tool(auth=require_scopes("mcp:add"))The auth-specific lines live between the # authplane:begin /
# authplane:end markers in server.py and
agent.py. Run go run ./tools/loccount examples/python/03-mcp-server-dpop-scopes from the repo root to see the
measured count.
Tier 04 — MCP server fronting a Broker shows
the MCP server doing RFC 8693 token exchange to a Broker resource (an
upstream OAuth provider like GitHub), surfacing ConsentRequiredError
when the user has not yet consented and resuming after the consent
round-trip.
To build the AS from this checkout rather than pulling
authplane/authserver:latest, follow the LOCAL BUILD ESCAPE
HATCH comment block in
../../_shared/docker-compose.authserver.yml.
Mirror the change in this example's docker-compose.yml (which inlines
the same service definition) — replace the image: line with the
build: block shown in the shared file.