Auth-specific code: 5 lines · Total example: 31 lines · SDK: python-sdk 0.2.0
A minimal FastMCP server protected by Authplane-issued JWTs. Everything you
need to authenticate inbound MCP calls fits inside a single five-line block
in server.py; the rest of the file is plain MCP boilerplate.
- How to wire Authplane JWT validation into a FastMCP server with the
authplane-fastmcpadapter - How to register a Mint Resource whose URI matches the JWT audience your server expects
- How to register an OAuth client with the
client_credentialsgrant - How to mint a machine token and call a protected MCP tool
make check-prereqs (auto-invoked by make run) enforces these and fails
loud with install hints if any are missing.
| Tool | Version | Install (macOS / Linux) |
|---|---|---|
| Python | 3.12+ (FastMCP requirement) | brew install python@3.12 / apt install python3.12 python3.12-venv |
| Docker | 24+ (daemon running) | Docker Desktop / Rancher Desktop |
| curl | any | preinstalled |
| jq | any | brew install jq / apt install jq |
The Makefile auto-picks python3.13 → python3.12 → python3 on PATH.
If your system's python3 is older than 3.12 and you have 3.12 installed
elsewhere, pass it explicitly: PY=/path/to/python3.12 make run.
The AS container binds 9000 (public) and 9001 (admin); the MCP server binds 8080. Conflicts are the most common startup failure — see Troubleshooting below.
| Time to run | About a minute first-run (venv install + AS pull); ~5 s warm |
| SDK | authplane-fastmcp 0.2.0 (PyPI) — depends on authplane-sdk 0.2.0 |
| MCP framework | fastmcp >= 3.0, < 4 (matches pyproject.toml) |
make run # creates a venv (first run only), starts the AS in a container, launches the server natively
make verify # registers Resource + client, mints a token, calls the protected tool
make logs # tails the last 40 lines of AS + MCP-server logs
make status # one-line health of the AS container + MCP process
make clean # stops everything; KEEPS .env and the venv
make distclean # full reset including .env and venvThe basic flow doesn't build any Dockerfiles. The AS is pulled as a
published image; the MCP server is a plain Python process in a
virtualenv at .run/.venv. First run downloads the AS image (~30 MB)
and installs FastMCP + the Authplane adapter; subsequent runs start in
seconds.
For the all-in-containers flow (slower first build) use make docker-run
/ make docker-clean.
bind: address already in use on make run
Another Authplane example is sitting on :9000, :9001, or :8080. Reset:
make clean
docker ps -aq --filter name=authplane | xargs -r docker rm -f
make runERROR: ... is Python 3.11 but FastMCP needs 3.12+
The Makefile's prereq check caught a too-old Python. Install 3.12+ or
pass PY=python3.12 make run.
make verify hangs at "waiting for ..."
Inspect with make status and make logs. The Authlib deprecation
warning in mcp.log is normal; what you're looking for is a real
exception above the FastMCP banner.
make verify returns invalid_token
JWT audience mismatch. AUTHPLANE_BASE_URL + /mcp must equal the
Resource URI registered at the AS, byte-for-byte.
Stale state
make distclean is the full reset (also removes the venv).
If you're copying this example into your own project, two configuration constraints matter the moment you deviate from the defaults:
1. The two issuer URLs must share the same hostname. The AS process
reads AUTHPLANE_SERVER_ISSUER (what hostname it announces in its
metadata document and bakes into every JWT's iss claim). The SDK
inside your MCP server reads AUTHPLANE_ISSUER (where to fetch that
metadata). The SDK enforces metadata.issuer == config.issuer and
fails fast on mismatch.
This is a decision, not a default — pick by topology and set both vars to the same host:
- MCP server in the same Docker network as the AS →
http://authserver:9000on both.- MCP server on the host, another machine, or public →
http://localhost:9000(or, behind a reverse proxy / TLS, one stable public hostname) on both.Get it wrong and every call 401s with an opaque
invalid_token— the token is valid, but the SDK discovered metadata at one host while the JWT'sisssays another.
2. The JWT audience must match the URL your MCP server actually
serves on — byte-for-byte. authplane_auth() derives the audience
as base_url + mcp_path (default mcp_path="/mcp"). If you change
any of port, host, or path, all three of these must agree:
base_url=(andmcp_path=if non-default) onauthplane_auth(...)urifield on the Resource you register at the AS- The URL the MCP client actually reaches
Custom port: PORT=9090 python server.py runs on :9090, so the
Resource URI is http://localhost:9090/mcp (with AUTHPLANE_BASE_URL
updated to match) and the AS registration must use that same URI.
Custom path: pass mcp_path="/api/mcp" to authplane_auth(...) AND
register the Resource with that same URI. Mismatches produce an
opaque invalid_token on every call — the JWT aud won't match
what the SDK expects.
3. The scope string is a join key across four places. Pick a name
once (here: mcp:echo) and use it in all four of these without
varying:
| # | Where it appears |
|---|---|
| 1 | scopes=["mcp:echo"] in authplane_auth(...) (SDK config) |
| 2 | scopes: [{ name: "mcp:echo", ... }] on POST /admin/resources |
| 3 | scope: "mcp:echo" on POST /admin/clients |
| 4 | scope=mcp:echo form param on POST /oauth/token |
A mismatch in any one of them produces invalid_scope from the AS or
insufficient_scope from the SDK. The full lifecycle (with what each
side enforces) lives in docs/concepts/resources-and-scopes.md ›
Lifecycle of a scope.
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_id,client_secret, andaccess_tokenin their responses; assign each to a shell variable (CLIENT_ID=...,CLIENT_SECRET=...,ACCESS_TOKEN=...) 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
-
Register a Mint Resource. The Resource URI must match the JWT audience the MCP server will expect (
base_url+/mcp). The defaultbase_urlin this example ishttp://localhost:8080so the URI ishttp://localhost:8080/mcp.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", "uri": "http://localhost:8080/mcp", "backend_kind": "mint", "display_name": "Demo MCP", "scopes": [{"name": "mcp:echo", "description": "echo tool"}] }'
The same Resource can be created via the CLI inside the container. The canonical path is
admin resource create(seedocs/reference/cli.md#cli-admin-resource-create):docker compose exec authserver /authserver admin resource create \ --slug demo-mcp \ --uri http://localhost:8080/mcp \ --backend-kind mint \ --display-name "Demo MCP" \ --scopes 'mcp:echo||echo tool'
Note the pipe-delimited
'name|upstream|description'tuple syntax — the double-empty middle is intentional for Mint scopes (no upstream mapping). -
Register an OAuth client. A
client_credentialsmachine client needs the matching grant type and a confidential auth method.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-mcp-client", "grant_types": ["client_credentials"], "token_endpoint_auth_method": "client_secret_basic", "scope": "mcp:echo" }'
The response carries
client_idandclient_secret. The secret is shown once. -
Mint a
client_credentialsaccess token. Pass theresource=parameter so the AS sets the JWT audience to the URI you registered.curl -sS -X POST http://localhost:9000/oauth/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d "grant_type=client_credentials" \ -d "scope=mcp:echo" \ --data-urlencode "resource=http://localhost:8080/mcp"
The response includes
access_token,token_type=Bearer,expires_in, andscope. There is no refresh token forclient_credentials. -
Call the MCP tool with the bearer token. The streamable-http transport is a 3-step handshake:
initialize(grab themcp-session-idresponse header),notifications/initialized(with that session header), thentools/call. The shortest authenticated probe is just step 1:curl -sS -X POST http://localhost:8080/mcp \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ "protocolVersion":"2024-11-05","capabilities":{}, "clientInfo":{"name":"demo","version":"1.0"}}}'
Without the
Authorizationheader the server returns HTTP 401 — proof that the integration is actually enforcing auth. The fullinitialize→notifications/initialized→tools/call echoround-trip (which proves the auth chain reaches a real tool, not just the initialize handler) is documented atdocs/reference/mcp-streamable-http.md— the three POSTs, the headers that matter, and the 4xx responses you'll hit if any of them are wrong.
A plain FastMCP server, no auth:
mcp = FastMCP("demo-server")
mcp.run(transport="streamable-http", host="0.0.0.0", port=8080)The same server with Authplane JWT validation:
+ async def main() -> None:
+ auth = await authplane_auth(
+ issuer=os.environ["AUTHPLANE_ISSUER"],
+ base_url=os.environ["AUTHPLANE_BASE_URL"],
+ scopes=["mcp:echo"], dev_mode=True,
+ )
- mcp = FastMCP("demo-server")
+ mcp = FastMCP("demo-server", **auth)
+ try:
+ await mcp.run_async(transport="streamable-http", host="0.0.0.0", port=8080)
+ finally:
+ await auth.aclose()
+
+ asyncio.run(main())Five lines of auth code, inside the # authplane:begin / # authplane:end
markers in server.py. Run
go run ./tools/loccount examples/python/01-mcp-server-basic from the
repo root to see the count.
The authplane_auth() factory 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 HTTP request is checked against the AS's JWKS for signature
validity, then the audience and exp claims are validated. The JWT
audience is derived as base_url + /mcp, so the
Mint backend
Resource you register at the AS must declare its uri to match. The
client_credentials grant is the simplest way to mint a machine token
for the resource — no user, no consent, just a registered confidential
client asking for the resource and a scope it owns.
This example is wired for local development. Before deploying:
| Setting | Dev value (here) | Production value | Why |
|---|---|---|---|
dev_mode=True in authplane_auth(...) |
True |
False (or remove — it's the default) |
Relaxes the SDK's SSRF guard so it accepts http://, localhost, and private-network issuers. Leaving it on in production weakens defense-in-depth against SSRF. |
AUTHPLANE_ISSUER |
http://localhost:9000 |
https://auth.example.com |
Production issuers MUST be https://. The AS itself refuses to start with a non-localhost issuer unless cookies are also Secure (see next row). |
AUTHPLANE_SESSION_SECURE |
true (the example already sets this because authserver:9000 is non-localhost) |
true |
Required by the AS's own startup validation whenever server.issuer is non-localhost. The AS refuses to start otherwise. Set to false only when both the issuer and Admin UI are reached strictly via http://localhost. |
AUTHPLANE_SESSION_SECRET |
dev-session-secret-change-me |
openssl rand -hex 32 |
Used to sign session cookies — leaking it lets an attacker forge admin sessions. |
AUTHPLANE_ADMIN_API_KEY |
dev-admin-key-change-me |
openssl rand -hex 32 |
Bearer for the entire admin surface. Treat like a root password. |
| Storage | SQLite in a Docker volume | PostgreSQL (AUTHPLANE_STORAGE_DRIVER=postgres) |
SQLite is single-instance. PostgreSQL is required for HA and cross-instance LISTEN/NOTIFY. |
| Signing keys | Auto-generated in /data/keys |
HashiCorp Vault Transit | See docs/guides/deploy/hashicorp-vault-transit.md. |
Tier 02 — Calling another resource from your MCP server
shows the same MCP server minting a client_credentials token via the SDK
to call a second protected resource. Tier 03
adds DPoP-bound tokens and per-tool scope enforcement.
Tier 04 puts an MCP server in front of a Broker
upstream (GitHub) with ConsentRequiredError handling.
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.