oauth
The full OAuth 2.1 authorization-code flow against an in-process Authorization
Server, over Streamable HTTP. On the server side: one MCPServer(auth=..., auth_server_provider=...) constructor call co-hosts the RFC 9728
protected-resource metadata route, the AS routes (/register, /authorize,
/token, /.well-known/oauth-authorization-server) and the bearer-gated
/mcp endpoint on a single Starlette app. On the client side:
OAuthClientProvider is an httpx.Auth that reacts to the first 401 by
walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange →
bearer retry — all inside the first awaited request, with no user-visible
UnauthorizedError.
Run it
# HTTP — the client self-hosts the co-hosted AS + bearer-gated /mcp, runs the
# authorization-code flow (headless: redirect followed in-process), then tears
# it down. Self-hosting uses this story's fixed :8000 (the AS metadata pins
# it), so :8000 must be free.
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http
# same, against the lowlevel-API server variant
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http --server server_lowlevel
# against a server you run yourself (real uvicorn on :8000)
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.oauth.client --http http://127.0.0.1:8000/mcp
kill "$SERVER_PID"
The port must be 8000: the demo AS metadata (_shared/auth.py BASE_URL)
is pinned to it on both the client and server side, so on any other port the
PRM/AS discovery chain points at the wrong origin.
OAUTH_DEMO_AUTO_CONSENT=1 makes the demo AS skip the consent screen and 302
straight back with ?code=...; without it the authorize step returns
error=interaction_required so you can see where a real browser would open.
Client(url) has no auth= passthrough, so a target built from a bare URL
can't carry the flow. Both runners close that gap the same way: run_client
(above) and the pytest harness build an authed httpx.AsyncClient from
this module's build_auth export and hand main targets that are already
routed through it.
What to look at
client.py—Client(targets(), mode=mode), twice. The targetmainreceives is already authed. The first construction is where the whole flow happens: the first request401s andOAuthClientProviderruns PRM discovery → AS metadata → DCR → PKCE authorize → token exchange → bearer retry beforewhoami's result reaches the body.client.py— the secondClient(targets(), mode=mode). AClientcannot be re-entered after__aexit__; reconnecting means constructing a new one. The provider'sTokenStoragepersisted the tokens and the DCR registration, so this one sendsAuthorization: Bearer ...on its very first request — no second/authorize, no second/register. The demo AS mints a freshclient_idper DCR call, sowhoamireturning the sameclient_idis the reuse proof.client.py—build_auth().OAuthClientProvideris anhttpx.Auth.Client(url, auth=...)is the ergonomic the SDK is missing; until it lands the auth has to be threaded onto the underlyinghttpx.AsyncClientby hand.server.py—MCPServer(auth=..., auth_server_provider=...). The constructor wires everything;streamable_http_app()reads it back. (Don't also passtoken_verifier=—auth_server_providerandtoken_verifierare mutually exclusive.) Thewhoamitool reads the validated principal viaget_access_token()— a per-HTTP-request contextvar set byAuthContextMiddleware, not per-session.server_lowlevel.py— same wire shape, butlowlevel.Servertakesauth=/token_verifier=/auth_server_provider=onstreamable_http_app()rather than the constructor.mcp.server.auth.*is a helper tier the lowlevel API may import directly.
Caveats
transport_security=NO_DNS_REBIND— DNS-rebinding protection is on by default and the in-process httpx bridge sends noOriginheader. Drop the kwarg for a real deployment.HeadlessOAuthonly works because the demo AS auto-consents; a realredirect_handlerwould open a browser and a realcallback_handlerwould run a loopback HTTP listener for the redirect.- The
mcp.server.auth.*import paths are deep (nomcp.serverre-export yet).
Spec
See also
bearer_auth/ (RS-only, static token, no AS) · oauth_client_credentials/
(M2M client_credentials grant — no browser, no DCR) · reconnect/ (the other
multi-connection targets() consumer, no auth).