FastMCP (Python)
AuthPlane is the authorization server; your FastMCP server stays a pure resource server. The authplane-fastmcp adapter wires JWT validation, JWKS caching, scope enforcement, DPoP checks, and RFC 9728 Protected Resource Metadata publishing into FastMCP in five lines.
Install
Section titled “Install”The Python SDK is published on PyPI as a core package plus framework adapters. Install the adapter that matches your stack, at the pinned version:
| Your stack | Install | Import |
|---|---|---|
| FastMCP | pip install authplane-fastmcp==0.2.0 | from authplane_fastmcp import authplane_auth |
| Official MCP Python SDK | pip install authplane-mcp==0.2.0 | from authplane_mcp import ... |
| Any other framework (FastAPI, Starlette, raw ASGI) | pip install authplane-sdk==0.2.0 | from authplane import AuthplaneResource |
authplane-fastmcp depends on authplane-sdk and pairs with fastmcp>=3.0,<4. All SDK packages require Python 3.12+.
Protect your MCP server
Section titled “Protect your MCP server”A complete, runnable server — this is the examples/python/01-mcp-server-basic example that ships with AuthPlane:
import asyncioimport os
from fastmcp import FastMCP
async def main() -> None: # authplane:begin from authplane_fastmcp import authplane_auth
auth = await authplane_auth( issuer=os.environ["AUTHPLANE_ISSUER"], base_url=os.environ["AUTHPLANE_BASE_URL"], scopes=["mcp:echo"], dev_mode=True, ) # authplane:end
mcp = FastMCP("demo-server", **auth)
@mcp.tool() def echo(text: str) -> str: """Echo the supplied text back to the caller.""" return text
port = int(os.environ.get("PORT", "8080")) try: await mcp.run_async(transport="streamable-http", host="0.0.0.0", port=port) finally: await auth.aclose()
if __name__ == "__main__": asyncio.run(main())With AuthPlane running locally (the authplane/authserver Docker image listens on :9000 public, :9001 admin), the SDK needs two env vars:
# Where the SDK discovers AuthPlane metadata + JWKS. Must resolve to the# same hostname as the AS-side AUTHPLANE_SERVER_ISSUER.AUTHPLANE_ISSUER=http://localhost:9000
# Your MCP server's own public URL. The JWT audience derives as base_url + /mcp.AUTHPLANE_BASE_URL=http://localhost:8080Two startup facts worth knowing:
authplane_auth()is async and runs at startup — it performs AS metadata discovery and the initial JWKS fetch, so AuthPlane must be reachable whenmain()starts. Bring the AS up first, or wrap the call in your own retry-with-backoff loop.auth.aclose()must run on the same event loop that constructedauth— it releases the SDK’s HTTP client and stops the background JWKS refresh task, which is why setup, serve, and cleanup all share oneasyncio.run(main()).
How verification works
Section titled “How verification works”authplane_auth() performs RFC 8414 AS metadata discovery against issuer, fetches the JWKS, and wires up a FastMCP RemoteAuthProvider backed by an AuthPlane token verifier. Every inbound request is checked before your tool code runs:
- Bearer extraction. A missing or invalid token returns
401with aWWW-Authenticate: Bearer resource_metadata="..."header pointing at the PRM document. - Signature is verified against AuthPlane’s JWKS. The SDK force-refreshes the JWKS on an unknown
kid, so key rotation needs no restart. - Claims.
issmust equal your configured issuer (the SDK enforcesmetadata.issuer == config.issuerand fails fast on mismatch),audmust contain your Resource URI, andexpmust be in the future (60 s default clock-skew tolerance). - Scopes. A missing scope returns
403with{"error":"insufficient_scope","scope":"<required>"}.
Audience binding. The Python adapter derives the JWT audience as base_url + mcp_path (default mcp_path="/mcp"). That URI must byte-for-byte match the uri of the Resource you register at AuthPlane (POST /admin/resources on :9001) and the URL MCP clients actually reach. Mounting FastMCP elsewhere? Pass mcp_path="/api/mcp" to authplane_auth(...) and register the Resource with that same URI.
Protected Resource Metadata (RFC 9728). The adapter automatically serves the PRM document at /.well-known/oauth-protected-resource/<mcp-path> so clients can discover your authorization server:
{ "resource": "http://localhost:8080/mcp", "authorization_servers": ["http://localhost:9000"], "scopes_supported": ["mcp:echo"], "bearer_methods_supported": ["header"]}Per-tool scopes. Declare the required scope on the tool itself; requests lacking it are rejected before your code runs:
from fastmcp.server.auth.providers.scopes import require_scopes
@mcp.tool(auth=require_scopes("mcp:admin"))def admin_tool() -> str: return "only tokens carrying mcp:admin reach this"DPoP. The adapter also validates DPoP sender-constrained tokens (RFC 9449). DPoP is off by default on the AS — set AUTHPLANE_DPOP_ENABLED=true at startup, or the discovery document silently omits it.
Retrofit an existing server
Section titled “Retrofit an existing server”Already running a FastMCP server? The entire change is one five-line block — this is the before/after diff from the examples/python/retrofit-existing-mcp-server example:
import datetime as dtimport asyncio, osimport secrets
from fastmcp import FastMCP
mcp = FastMCP("retrofit-demo")mcp.run(transport="streamable-http", host="0.0.0.0", port=8080)from authplane_fastmcp import authplane_auth
async def main() -> None: auth = await authplane_auth( issuer=os.environ["AUTHPLANE_ISSUER"], base_url=os.environ["AUTHPLANE_BASE_URL"], scopes=["mcp:tools"], dev_mode=True, ) mcp = FastMCP("retrofit-demo", **auth) # ... @mcp.tool() registrations ... try: await mcp.run_async(transport="streamable-http", host="0.0.0.0", port=8080) finally: await auth.aclose()
asyncio.run(main())Your tool definitions, FastMCP version pin, and transport all stay put. pyproject.toml gains two dependencies: authplane-sdk==0.2.0 and authplane-fastmcp==0.2.0.
Troubleshooting
Section titled “Troubleshooting”Most opaque invalid_token failures trace to one of three byte-for-byte rules:
| Rule | What breaks it |
|---|---|
Issuer URLs match. The AS-side AUTHPLANE_SERVER_ISSUER (what the AS announces and stamps into iss) and the SDK-side AUTHPLANE_ISSUER (where it discovers metadata + JWKS) must resolve to the same hostname. | MCP server in the same Docker network as the AS → http://authserver:9000 on both. On the host or public → http://localhost:9000 (or your real hostname) on both. Mixing them 401s every call even though the token is valid. |
Resource URI matches everywhere. What you register at the AS, what the SDK derives (base_url + mcp_path), and what the MCP client reaches must match — scheme, host, port, path. | Changing PORT without updating AUTHPLANE_BASE_URL, or mounting at a custom path without passing mcp_path. |
One scope string, four places. The SDK scopes=[...] array, POST /admin/resources, POST /admin/clients, and the scope= param on POST /oauth/token. | Any drift produces invalid_scope from the AS or insufficient_scope from the SDK. |
Python-specific pointers:
- Adapter raises at startup → AuthPlane was unreachable during metadata discovery. Bring the AS up before your server.
406 Not Acceptableon MCP calls → the request is missing the mandatoryAccept: application/json, text/event-streamheader.grant type not supported→client_credentials, DPoP, and token exchange are off by default. Set the matching env var (AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true,AUTHPLANE_DPOP_ENABLED=true,AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true) and restart the AS.
Next steps
Section titled “Next steps”- Quickstart — run AuthPlane, register a Resource and a client, mint a token
- How it works — the full token flow end to end
- TypeScript SDK · Go SDK
- Deploy with Docker · Configuration