OAuth 2.1 JWT authentication for the official MCP Python SDK, powered by the Authplane Python SDK.
- Installation
- Quick Start
- Configuration Reference
- Scope Enforcement
- Accessing Token Claims
- Protected Resource Metadata (PRM)
- Token Revocation Checking
- Token Exchange (RFC 8693)
- URL Elicitation for Consent
- Development Mode
- SSRF Protection
- Resource Cleanup
- Error Handling
- API Reference
pip install authplane-mcpRequires Python 3.11+.
import asyncio
from mcp.server.fastmcp import FastMCP
from authplane_mcp import authplane_mcp_auth, require_scope
async def main() -> None:
auth_result = await authplane_mcp_auth(
issuer="https://auth.company.com",
resource="https://mcp.company.com",
scopes=["tools/query", "tools/write"],
)
mcp = FastMCP("My Server", port=8080, json_response=True, **auth_result)
@mcp.tool()
async def query(sql: str) -> str:
"""Execute a query."""
require_scope("tools/query")
return f"Ran: {sql}" # replace with your real handler
try:
await mcp.run_streamable_http_async()
finally:
await auth_result.aclose()
asyncio.run(main())authplane_mcp_auth() performs RFC 8414 metadata discovery, fetches the JWKS, and returns a dict with token_verifier and auth keys that unpack directly into FastMCP().
All parameters of authplane_mcp_auth():
| Parameter | Type | Default | Description |
|---|---|---|---|
issuer |
str |
required | Authorization server URL |
resource |
str |
required | URL of this MCP server (used as JWT audience) |
scopes |
list[str] |
[] |
Scopes this server supports |
enforce_scopes_on_all_requests |
bool |
False |
When True, the MCP SDK both advertises scopes in PRM scopes_supported and rejects any request whose token lacks all of them. Workaround for an MCP SDK limitation: AuthSettings has no separate "supported" field. Per-tool require_scope() is the recommended granular pattern; this flag enables coarse request-layer enforcement and PRM advertising. |
as_credentials |
ASCredentials |
None |
Client credentials for introspection and token exchange |
dpop |
DPoPProvider |
None |
DPoP provider for outbound calls to the AS (introspection, token exchange) |
allowed_algorithms |
list[str] |
["RS256", "ES256"] |
Allowed JWT signature algorithms (asymmetric only) |
jwks_refresh_seconds |
int |
300 |
JWKS cache TTL |
metadata_refresh_seconds |
int |
3600 |
AS metadata cache TTL |
cache_ttl_buffer_seconds |
float |
30.0 |
Buffer subtracted from token TTLs before cache expiry |
default_ttl_seconds |
float |
3600.0 |
Fallback token cache TTL when responses omit expiry metadata |
circuit_breaker_threshold |
int |
5 |
Transient failures before opening the AS circuit breaker |
circuit_breaker_cooldown_seconds |
float |
30.0 |
Cooldown before allowing a half-open probe |
clock_skew_seconds |
int |
30 |
Leeway for exp/nbf/iat validation |
dev_mode |
bool |
False |
Relaxes SSRF checks for local development |
revocation_checker |
see below | None |
Token revocation strategy |
fetch_settings |
FetchSettings |
None |
Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides dev_mode) |
inbound_dpop |
InboundDPoPOptions |
None |
Per-resource inbound DPoP policy (replay store, max proof age, clock skew, accepted proof algorithms, required). When set, the resource advertises DPoP support in PRM (RFC 9728 §2). See Inbound DPoP through the MCP adapter below for current limitations. |
The upstream mcp.server.auth framework integrates with token verifiers through a bearer-only TokenVerifier.verify_token(token: str) protocol. Its BearerAuthBackend matches only Authorization: Bearer ... headers and never reads the DPoP request header — RFC 9449 §7.1 DPoP-bound requests use the Authorization: DPoP <token> scheme and would be rejected at the framework layer before reaching this adapter.
Setting inbound_dpop=InboundDPoPOptions(...) therefore affects only Protected Resource Metadata advertising in the standard adapter integration; verify-time DPoP enforcement requires custom request-aware middleware that extracts the proof header and threads a DPoPRequestContext into AuthplaneResource.verify(...).
Use the require_scope() helper at the top of tool handlers to enforce per-tool scope requirements:
from authplane_mcp import require_scope
@mcp.tool()
async def query(sql: str) -> str:
"""Requires the tools/query scope."""
require_scope("tools/query")
return f"Ran: {sql}" # replace with your real handler
@mcp.tool()
async def delete_all() -> str:
"""Requires the tools/admin scope."""
require_scope("tools/admin")
return clear_database()If the token is missing the required scope, require_scope() raises a PermissionError. The MCP server catches this and returns an error result (isError: true) to the client. The rest of the handler is never executed.
require_scope() reads the current request's access token from the MCP request context. It checks if the token's scopes include the required scope string. If the token is absent or the scope is missing, it raises PermissionError.
Use the MCP SDK's get_access_token() to access the validated token in tool handlers:
from mcp.server.auth.middleware.auth_context import get_access_token
@mcp.tool()
async def my_tool(data: str) -> str:
token = get_access_token()
if token:
client_id = token.client_id # Client ID
scopes = token.scopes # List of granted scopes
expires_at = token.expires_at # Expiration (Unix timestamp)
resource = token.resource # Resource (audience) URL
return f"Processing {data}"The AccessToken fields populated by the adapter:
| Field | Type | Description |
|---|---|---|
token |
str |
Raw JWT string |
client_id |
str |
OAuth client ID |
scopes |
list[str] |
Granted scopes |
expires_at |
int |
Expiration Unix timestamp |
resource |
str |
Resource identifier (audience) |
The MCP SDK automatically serves RFC 9728 Protected Resource Metadata at a well-known URI. This enables MCP clients to discover the authorization server and supported scopes.
The PRM endpoint location depends on the resource URL:
| Resource URL | PRM Endpoint |
|---|---|
https://mcp.company.com |
GET /.well-known/oauth-protected-resource |
https://mcp.company.com/mcp |
GET /.well-known/oauth-protected-resource/mcp |
The response includes:
- Authorization server URL (issuer)
- Supported scopes
- Bearer token methods
- Resource identifier
No additional configuration is needed; PRM is served automatically by the MCP SDK.
By default, tokens are validated offline (signature + claims only). You can enable revocation checking to catch tokens that have been revoked before they expire.
await authplane_mcp_auth(
issuer="https://auth.company.com",
resource="https://mcp.company.com",
# revocation_checker is None by default
)Calls the authorization server's introspection endpoint to check if a token is still active:
from authplane import ASCredentials, IntrospectionRevocation
await authplane_mcp_auth(
issuer="https://auth.company.com",
resource="https://mcp.company.com",
revocation_checker=IntrospectionRevocation(),
as_credentials=ASCredentials(
client_id="my_resource_server",
client_secret="secret",
),
)- The introspection endpoint is automatically discovered from AS metadata.
- If the endpoint returns
active=false, the token is rejected withTokenRevokedError. - Fails open: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies).
as_credentialsenables authenticated introspection (recommended for production).
Implement your own revocation logic with an async callable:
from authplane import VerifiedClaims
async def check_blocklist(claims: VerifiedClaims, raw_token: str) -> bool:
"""Return True to reject the token (it is revoked)."""
return await redis_client.sismember("revoked_tokens", claims.jti)
await authplane_mcp_auth(
issuer="https://auth.company.com",
resource="https://mcp.company.com",
revocation_checker=check_blocklist,
)Exchange an inbound token for a narrowly-scoped downstream token to call other services on behalf of the caller. The call goes to the authorization server's token_endpoint (discovered via RFC 8414 metadata), reuses the client's SSRF settings, and attaches DPoP proofs when a DPoPProvider was configured.
from authplane import ASCredentials
from authplane.oauth import TokenExchangeOptions
from authplane_mcp import authplane_mcp_auth
result = await authplane_mcp_auth(
issuer="https://auth.company.com",
resource="https://mcp.company.com",
scopes=["tools/add"],
as_credentials=ASCredentials(
client_id="https://mcp.company.com",
client_secret="s3cret",
),
)
downstream = await result.client.exchange(
TokenExchangeOptions(
subject_token=inbound_token,
scope="tools/add", # narrow to the minimum
resources=("https://downstream.example",), # RFC 8707 audience binding
)
)
# downstream.access_token — present to the downstream service
# downstream.expires_in — lifetime in seconds
# downstream.token_type — "Bearer" or "DPoP"TokenExchangeOptions fields:
| Field | Type | Purpose |
|---|---|---|
subject_token |
str (required) |
Token being exchanged (typically the inbound caller's token). |
subject_token_type |
str |
RFC 8693 token-type URI; defaults to urn:ietf:params:oauth:token-type:access_token. |
actor_token / actor_token_type |
str |
Optional actor (delegation) token. |
scope |
str |
Space-separated scopes to request on the downstream token. |
resources |
tuple[str, ...] |
Target resource identifiers (RFC 8707). Binds the downstream token's audience. |
audiences |
tuple[str, ...] |
Explicit audiences when not using resources. |
client.exchange() raises InvalidGrantError on a rejected grant, ConsentRequiredError when the AS requires interactive user consent before issuance, CircuitOpenError when the AS circuit is open, and other AuthplaneError subclasses for transport/protocol failures. See Error Handling.
Some token exchanges require the user to complete interactive consent at the authorization server before a downstream token can be issued (for example, first-time authorization against a third-party service). The AS signals this with an OAuth error of consent_required or interaction_required and — when capable — a consent_url the user must visit.
MCP clients surface this through the URL elicitation flow (JSON-RPC error code -32042): the server raises an UrlElicitationRequiredError carrying the consent URL, and the MCP client prompts the user to open it. After the user completes consent, the client retries the call.
The adapter handles this for you. The client returned by authplane_mcp_auth(...) is wrapped so that client.exchange(...) automatically translates a qualifying ConsentRequiredError (one that carries a consent_url) into UrlElicitationRequiredError. No tool-side error handling is needed:
from authplane.oauth import TokenExchangeOptions
@mcp.tool()
async def call_downstream(user_token: str, payload: str) -> str:
downstream = await result.client.exchange(
TokenExchangeOptions(subject_token=user_token, scope="downstream/write")
) # raises UrlElicitationRequiredError transparently when consent is needed
return await downstream_api_call(downstream.access_token, payload)The elicitation message is generated by ConsentRequiredError.describe() and uses the format "<message> (<service_id>: <cause_detail>)".
Errors that pass through unchanged:
- Non-consent errors (
InvalidGrantError,CircuitOpenError, transport failures, etc.). ConsentRequiredErrorwithout aconsent_url— the AS signaled consent is needed but provided no URL the user can complete it at; the original SDK error propagates so the application can decide how to surface it.
Manual translation (escape hatch). If you need to map a consent error to MCP -32042 outside client.exchange() (for example, you produce a ConsentRequiredError from your own logic), call the underlying primitive:
from authplane_mcp import to_url_elicitation_required_error
mapped = to_url_elicitation_required_error(error)
if mapped is not None:
raise mapped
raise errorto_url_elicitation_required_error returns the MCP-shaped exception when the input is a ConsentRequiredError with a consent_url, otherwise None.
For local development, enable dev_mode to relax SSRF restrictions and allow HTTP/localhost:
await authplane_mcp_auth(
issuer="http://localhost:9000",
resource="http://localhost:8080/mcp",
scopes=["tools/query"],
dev_mode=True,
)Development mode allows:
- HTTP (non-TLS) connections
- Localhost addresses (
127.0.0.0/8) - Private network addresses (
10.x,172.16-31.x,192.168.x)
Cloud metadata addresses (169.254.x) are always blocked, even in dev mode.
You can also enable dev mode via environment variable:
export AUTHPLANE_DEV_MODE=true
python myserver.pyThe adapter provides SSRF controls for JWKS and metadata fetching via FetchSettings.
For most use cases, dev_mode=True is sufficient for local development. Use FetchSettings when you need fine-grained control:
from authplane import FetchSettings
settings = FetchSettings(
ssrf_protection=True,
allow_http=False,
allow_localhost=True,
allow_private_networks=True,
timeout=10.0,
)
await authplane_mcp_auth(
issuer="https://auth.internal.corp",
resource="https://api.prod.com",
fetch_settings=settings,
)When fetch_settings is provided, dev_mode is ignored for both metadata and JWKS fetches.
| Check | Default | Description |
|---|---|---|
| HTTPS required | Yes | Blocks HTTP unless explicitly allowed |
| Localhost blocked | Yes | Blocks 127.0.0.0/8 |
| Private networks blocked | Yes | Blocks 10.x, 172.16-31.x, 192.168.x |
| Cloud metadata blocked | Always | Blocks 169.254.x (cannot be disabled) |
| DNS pinning | Yes | Resolves DNS once, validates the IP |
| Redirect blocking | Yes | Prevents open redirect attacks |
| Size limit | 64KB | Maximum JWKS response size |
| Timeout | 10s | HTTP request timeout |
authplane_mcp_auth() returns an AuthplaneAuthResult that holds an HTTP connection pool and, once requests start flowing, background JWKS / metadata refresh tasks. All of these are bound to the event loop that ran authplane_mcp_auth() — asyncio.Lock, the httpx.AsyncClient, and any tasks spawned by the caches. The adapter setup, the MCP server, and aclose() must share one loop. That means driving the MCP SDK's async server entry point from inside asyncio.run(main()):
import asyncio
async def main() -> None:
auth_result = await authplane_mcp_auth(...)
try:
mcp = FastMCP("My Server", **auth_result)
await mcp.run_streamable_http_async()
finally:
await auth_result.aclose()
asyncio.run(main())Each transport has an async equivalent: run_streamable_http_async, run_sse_async, run_stdio_async. Do not wrap only the setup in asyncio.run(authplane_mcp_auth(...)) and then call the sync mcp.run() — mcp.run() starts a fresh event loop, the auth resources stay bound to the loop that was closed when the setup asyncio.run() returned, and the first request fails with cross-loop errors.
auth_result.aclose() closes the underlying AuthplaneClient, cancels its background tasks, and releases connections. Skipping it on a long-running server that exits with the process is harmless (the OS reaps everything on exit); skipping it in tests or embedded servers surfaces as leaked tasks, open sockets, and ResourceWarning.
AuthplaneTokenVerifier.verify_token catches every AuthplaneError raised by AuthplaneResource.verify() (missing/expired/invalid/revoked token, DPoP failure, etc.) and returns None. The MCP server turns that into a uniform 401 Unauthorized on the wire — the wire does not differentiate by error type, but the verifier emits a logging.DEBUG event authplane.token_verification_failed (logger authplane_mcp.verifier) with structured error_class and error fields so operators can distinguish expired tokens from JWKS outages from DPoP replays in logs.
Scope checks happen after token validation succeeds and are a separate enforcement layer — see Scope Enforcement above for the require_scope() helper. Inside a handler, claims.require_scope("…") raises InsufficientScopeError. The error carries required_scopes so the SDK can emit RFC 6750's scope= challenge automatically.
When you handle an AuthplaneError outside the verifier — typically because you are wrapping the adapter in your own middleware or calling AuthplaneResource.verify() directly — use response_headers_for(error, …) to map the error to (status, {"WWW-Authenticate": challenge}) in one call. It forwards realm, resource_metadata_url, and scope into the underlying www_authenticate() helper, which sanitizes every interpolated value against header injection.
from authplane import AuthplaneError, response_headers_for
try:
claims = await resource.verify(token, dpop_request=ctx)
except AuthplaneError as error:
status, headers = response_headers_for(
error,
realm="api.example.com",
resource_metadata_url=resource.prm_url(),
)
return Response(status_code=status, headers=headers)If you call AuthplaneResource.verify() yourself (for example, in custom middleware or non-MCP code), the relevant exceptions to catch are the ones verify() actually raises:
from authplane import (
AuthplaneError,
DPoPError,
InvalidClaimsError,
InvalidSignatureError,
TokenExpiredError,
TokenRevokedError,
)
try:
claims = await verifier.verify(token)
except TokenRevokedError:
log.warning("Revoked token used")
raise
except (TokenExpiredError, InvalidSignatureError, InvalidClaimsError):
raise # 401-class verification failures
except DPoPError:
raise # RFC 9449 binding/proof failures
except AuthplaneError:
raise # everything else from the verifierInsufficientScopeError is not raised by verify(); it comes from claims.require_scope("…") after a successful verification.
async def authplane_mcp_auth(
issuer: str,
resource: str,
scopes: list[str] | None = None,
*,
enforce_scopes_on_all_requests: bool = False,
as_credentials: ASCredentials | None = None,
dpop: DPoPProvider | None = None,
allowed_algorithms: list[str] | None = None,
jwks_refresh_seconds: int | None = None,
metadata_refresh_seconds: int | None = None,
cache_ttl_buffer_seconds: float | None = None,
default_ttl_seconds: float | None = None,
circuit_breaker_threshold: int | None = None,
circuit_breaker_cooldown_seconds: float | None = None,
clock_skew_seconds: int | None = None,
dev_mode: bool | None = None,
fetch_settings: FetchSettings | None = None,
inbound_dpop: InboundDPoPOptions | None = None,
revocation_checker: IntrospectionRevocation | RevocationChecker | None = None,
) -> AuthplaneAuthResultAsync factory that performs metadata discovery, fetches JWKS, and returns an AuthplaneAuthResult ready to unpack into FastMCP() (only token_verifier and auth are exposed in the mapping view; client is on the result for advanced use).
Raises:
ValueError— invalid configuration (e.g., HMAC algorithm)JWKSFetchError— metadata discovery or JWKS fetch failed
def require_scope(scope: str) -> NoneEnforce a scope requirement inside a tool handler. Raises PermissionError if the current request token is missing the scope.
MCP SDK TokenVerifier implementation.
| Method/Property | Description |
|---|---|
verify_token(token: str) -> AccessToken | None |
Validate JWT, return AccessToken or None |
verifier (property) |
Access underlying AuthplaneResource |
Returned by authplane_mcp_auth(). Supports ** unpacking into FastMCP() — the mapping view yields token_verifier and auth. client is exposed as a plain attribute for RFC 8693 token exchange. Call await result.aclose() on shutdown to release background tasks and HTTP connections.
| Attribute | Type | Description |
|---|---|---|
token_verifier |
AuthplaneTokenVerifier |
Token verifier for MCP |
auth |
AuthSettings |
Auth settings for MCP |
client |
AuthplaneClient |
Underlying SDK client (use client.exchange() for RFC 8693) |
The adapter does not re-export core SDK types. Import them from authplane
(or authplane.oauth for token-operation types):
| Type | Import from |
|---|---|
ASCredentials, FetchSettings, IntrospectionRevocation, RevocationChecker, DPoP types |
authplane |
Verification errors (AuthplaneError, InsufficientScopeError, TokenExpiredError, TokenRevokedError, ConsentRequiredError, …) |
authplane |
TokenExchangeOptions, TokenResponse |
authplane.oauth |
The adapter enforces (via the core SDK):
- RFC 9068 compliance — validates all 9 required JWT claims (
iss,aud,sub,client_id,exp,nbf,iat,jti,typ) - Type header enforcement — only accepts
typ: "at+jwt" - Asymmetric algorithms only — HMAC and
noneare rejected - SSRF protection — DNS pinning, IP blocklists, protocol allowlists, redirect blocking
- Background JWKS refresh — refreshes at 80% of TTL to avoid request-time latency
- Stale cache fallback — uses cached JWKS if a refresh fails, maintaining availability