oauth-client-credentials
OAuth 2.0 client_credentials grant — machine-to-machine MCP auth, no
browser. A backend service authenticates as itself by presenting a
pre-registered client_id/client_secret directly to the AS token endpoint;
the SDK's ClientCredentialsOAuthProvider handles 401-challenge → PRM/AS
discovery → token POST → Bearer attachment automatically.
Run it
# HTTP — the client self-hosts the server, runs the grant, then tears it down.
# Self-hosting uses this story's fixed :8000 (the AS metadata pins it), so
# :8000 must be free.
uv run python -m stories.oauth_client_credentials.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.oauth_client_credentials.client --http --server server_lowlevel
# against a server you run yourself (real uvicorn on :8000 — auth is HTTP-only)
uv run python -m stories.oauth_client_credentials.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp
kill "$SERVER_PID"
OAuth is an HTTP-layer concern; stdio servers receive credentials via the
environment per the spec, so there is no stdio leg. 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.
What to look at
client.pymain— opens withasync with Client(target, mode=mode) as client:and that's the whole program.targetis a transport that already carries the OAuthhttpx.Auth; the body never touches a token.client.pybuild_auth— five lines ofClientCredentialsOAuthProviderconfig is all the caller writes; the SDK does RFC 9728 PRM → RFC 8414 AS-metadata discovery and token exchange on the first 401.server.pytoken_endpoint— the entire AS for this grant: validate HTTP-Basicclient_id:client_secret, mint a token, return RFC 6749 JSON. The SDK's built-inauth_server_provider=only routesauthorization_code/refresh_token, so M2M servers mount their own/token.server.pywhoami—get_access_token()is how a tool reads the authenticated principal (client_id,scopes) from the request context.server_lowlevel.py— identical auth wiring viaServer.streamable_http_app(auth=..., token_verifier=..., custom_starlette_routes=[...]); only the tool registration differs.
Caveats
Client(url, auth=build_auth(http))is the ergonomic the SDK is missing —Client(url)has noauth=passthrough. Until it lands, the authedhttpx.AsyncClient→streamable_http_client(url, http_client=hc)chain has to be built outsidemainand handed in astarget; bothrun_client(the standalone--httprun) and the test harness do that from thebuild_authexport.transport_security=NO_DNS_REBIND— DNS-rebinding protection is on by default for localhost binds; the harness disables it because the in-process httpx client sends noOriginheader. Drop the kwarg for a real deployment.OAuthMetadata.authorization_endpointis a required field even though aclient_credentials-only AS has no authorize endpoint; the server sets a dummy URL.
private_key_jwt
Swap ClientCredentialsOAuthProvider for PrivateKeyJWTOAuthProvider to
authenticate the token request with a signed assertion (RFC 7523 §2.2) instead
of a shared secret. Not exercised here because the demo AS only validates
client_secret_basic.
Spec
See also
oauth/ (interactive authorization_code + PKCE — user-facing flow) ·
bearer_auth/ (static token, no AS — simplest gating).