Skip to content

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.

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 stackInstallImport
FastMCPpip install authplane-fastmcp==0.2.0from authplane_fastmcp import authplane_auth
Official MCP Python SDKpip install authplane-mcp==0.2.0from authplane_mcp import ...
Any other framework (FastAPI, Starlette, raw ASGI)pip install authplane-sdk==0.2.0from authplane import AuthplaneResource

authplane-fastmcp depends on authplane-sdk and pairs with fastmcp>=3.0,<4. All SDK packages require Python 3.12+.

A complete, runnable server — this is the examples/python/01-mcp-server-basic example that ships with AuthPlane:

server.py
import asyncio
import 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:

.env
# 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:8080

Two 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 when main() 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 constructed auth — it releases the SDK’s HTTP client and stops the background JWKS refresh task, which is why setup, serve, and cleanup all share one asyncio.run(main()).

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:

  1. Bearer extraction. A missing or invalid token returns 401 with a WWW-Authenticate: Bearer resource_metadata="..." header pointing at the PRM document.
  2. Signature is verified against AuthPlane’s JWKS. The SDK force-refreshes the JWKS on an unknown kid, so key rotation needs no restart.
  3. Claims. iss must equal your configured issuer (the SDK enforces metadata.issuer == config.issuer and fails fast on mismatch), aud must contain your Resource URI, and exp must be in the future (60 s default clock-skew tolerance).
  4. Scopes. A missing scope returns 403 with {"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.

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 dt
import asyncio, os
import 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.

Most opaque invalid_token failures trace to one of three byte-for-byte rules:

RuleWhat 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 Acceptable on MCP calls → the request is missing the mandatory Accept: application/json, text/event-stream header.
  • grant type not supportedclient_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.