Auth-specific code: 26 lines · Total example: 59 lines · SDK: python-sdk 0.2.0
When your MCP server needs to call a third-party API on the user's
behalf — GitHub, Slack, Google Workspace, Notion — it does an
RFC 8693 Token Exchange
against a Broker resource. Authplane brokers the OAuth dance with the
provider, holds the user's encrypted refresh token, and vends the
provider's native token (e.g. gho_…). Your MCP server uses that
token to call the upstream API.
This example shows the SDK's outbound Token Exchange call and how to
handle ConsentRequiredError — the branch every MCP server fronting a
Broker must handle, since the user must complete /connect/{provider}
at least once before tokens can be vended.
For the conceptual model, see docs/concepts/broker-vs-mint.md — the "Fronting — a relationship between a Mint and a Broker" section has the end-to-end sequence diagram.
Self-contained. This example brings up its own
authservercontainer; it does not depend on the tier-01..03 examples. The standaloneagent.pyshape is purely for readability — in production the same SDK calls live inside the tool handler of an MCP server whose Mint resource has a fronting link to this Broker.
- How to register a Broker provider
via
POST /admin/broker-providers - How to register a Broker-backed Resource
with
backend_kind=broker+broker_provider_slug=… - How to register an OAuth client with the
urn:ietf:params:oauth:grant-type:token-exchangegrant type - How to call
client.exchange(TokenExchangeOptions(...))on theauthplane-sdkPython client (client.py:301) to perform RFC 8693 - How to catch
ConsentRequiredErrorand read itsconsent_urlfield — the URL a real user would visit through/connect/{provider}to land the upstream-OAuth grant
| Time to run | ~2 minutes (first build is ~60s, subsequent runs are seconds) |
| Prereqs | Docker 24+, docker compose, curl, jq, Python 3.12+ (only if you run outside Docker — pyproject.toml pins requires-python = ">=3.12") |
| SDK | authplane-sdk 0.2.0 (PyPI) |
| Topology | Agent ↔ Authserver only — no MCP server in this tier |
cp .env.example .env
make run
make verifymake run builds and brings up only the authserver service (the agent
runs under the manual profile and is invoked one-shot by make verify).
make verify registers every artifact the broker exchange needs
(provider, Broker resource, Mint actor resource, OAuth client, two
policies) and then runs the agent. The agent asks for a brokered token
and — because no broker_grants row exists for this user/agent pair yet
— receives consent_required with a consent_url. make clean tears
everything down and removes the .env file the run target created.
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.Step 5 emits
client_idandclient_secretin its response; assign each to a shell variable (CLIENT_ID=...,CLIENT_SECRET=...) before the next step uses it.
-
Start authserver.
make runbrings it up withAUTHPLANE_TOKEN_EXCHANGE_ENABLED=true,AUTHPLANE_DATA_ENCRYPTION_DRIVER=aes_master,AUTHPLANE_CONNECT_STATE_SECRET=…, andAUTHPLANE_CONNECT_REDIRECT_BASE_URL=…— see.env.examplefor the full list. Discovery should advertise the token-exchange grant:curl -fsS http://localhost:9000/.well-known/oauth-authorization-server \ | jq '.grant_types_supported | index("urn:ietf:params:oauth:grant-type:token-exchange")'
Endpoint:
GET /.well-known/oauth-authorization-server. -
Register a Broker provider. The provider is the upstream identity the AS will (eventually) connect users to. We use a fake hostname because this example never actually drives the browser through
/connect/<provider>:curl -sS -X POST http://localhost:9001/admin/broker-providers \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "github-stub", "display_name": "GitHub (stub)", "protocol": "oauth", "config_data": { "client_id": "stub-client-id", "client_secret_env": "AUTHPLANE_ADMIN_API_KEY", "authorize_url": "https://github-stub.example.invalid/login/oauth/authorize", "token_url": "https://github-stub.example.invalid/login/oauth/access_token" } }'
Endpoint:
POST /admin/broker-providers. CLI equivalent:authserver admin provider create. -
Register the actor MCP (Mint resource). Even though there's no long-lived MCP server in this tier, the Broker exchange dispatch identifies the acting MCP by looking up a Mint resource that lists the calling client_id in its
policy.runtime.client_ids(seeinternal/services/token_exchange.go:1244-1255). Without this row the exchange fails withunauthorized_client:curl -sS -X POST http://localhost:9001/admin/resources \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "mcp-agent", "uri": "http://localhost:8080/mcp", "backend_kind": "mint", "display_name": "Tier-04 demo agent (actor MCP)", "scopes": [{"name": "mcp:tools", "description": "agent base scope"}] }'
Endpoint:
POST /admin/resources. CLI equivalent:authserver admin resource create. -
Register the Broker resource. The Broker resource names the AS-side fine scopes the agent may request and maps each to an upstream scope.
broker_provider_slugreferences the provider from step 2:curl -sS -X POST http://localhost:9001/admin/resources \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "slug": "github", "uri": "https://github-stub.example.invalid/api", "backend_kind": "broker", "broker_provider_slug": "github-stub", "display_name": "GitHub (brokered)", "scopes": [ {"name": "repo", "upstream": "repo", "description": "repo access"}, {"name": "read:user", "upstream": "read:user", "description": "user profile"} ] }'
-
Register an OAuth client with both
client_credentials(for the base machine token) andurn:ietf:params:oauth:grant-type:token-exchange(for the brokered vend). Public clients cannot do token exchange — see docs/guides/upstream-providers/token-exchange-grant.md, step 2:curl -sS -X POST http://localhost:9001/admin/clients \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "client_name": "tier04-broker-agent", "grant_types": [ "client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange" ], "token_endpoint_auth_method": "client_secret_basic", "scope": "mcp:tools repo read:user" }'
Endpoint:
POST /admin/clients. CLI equivalent:authserver admin client create. -
Bind the client to the actor MCP. This is what tier 03 does for Mint resources; here it tells the AS "treat this client_id as the
mcp-agentMCP for actor-attestation lookups during token exchange":curl -sS -X POST \ "http://localhost:9001/admin/resources/mcp-agent/policy/runtime/client-ids" \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"client_id\": \"$CLIENT_ID\"}"
Endpoint:
POST /admin/resources/{slug}/policy/runtime/client-ids. CLI equivalent:authserver admin resource runtime-client add. -
Authorize the client to exchange against the Broker resource. Per-resource ACL for the token-exchange grant:
curl -sS -X POST \ "http://localhost:9001/admin/resources/github/policy/exchange/allowed-clients" \ -H "Authorization: Bearer $AUTHPLANE_ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"client_id\": \"$CLIENT_ID\"}"
Endpoint:
POST /admin/resources/{slug}/policy/exchange/allowed-clients. The CLI exposes this list as the--policy-allowed-clientsflag onauthserver admin resource create; there's no per-row add subcommand today. -
Run the agent. The agent (see
agent.py):- Creates an
AuthplaneClient(no DPoP in this tier). - Mints a base
client_credentialstoken. The cc grant is scoped to the Broker resource URI (not the actor MCP URI) so the subject token'saudclaim resolves directly to the exchange target — otherwise the dispatcher would interpret the cross-resource jump as a fronted topology and demand afronting_linksrow (seeinternal/services/token_exchange.go:1206-1242). Seepython-sdk/authplane/client.pyfor the helper andPOST /oauth/tokenfor the wire shape. - Calls
client.exchange(TokenExchangeOptions(subject_token=..., resources=("github",), scope="repo"))—python-sdk/authplane/client.py:301. - Receives
consent_requiredbecause there's nobroker_grantsrow for this user/agent pair yet. The SDK (python-sdk/authplane/errors.py:336-343) raisesConsentRequiredError, and the agent readse.consent_url(python-sdk/authplane/errors.py:161). - Prints the URL and exits.
make verifyruns the agent inside this example's compose network so it can reach the AS atauthserver:9000. - Creates an
In tiers 01-03 the AS is minting its own JWT access tokens that target
your MCP server. Tier 04 introduces the second backend kind: Broker.
A Broker resource doesn't issue an AS-signed JWT — instead, on a
successful exchange, the AS reaches out to the upstream provider
(GitHub, Slack, Google, …), refreshes the user's grant if needed, and
returns the upstream's actual access token directly to the caller.
Authplane stores the upstream refresh-grant encrypted at rest
(broker_grants table, AES-256-GCM via the configured
data_encryption driver)
and the agent never sees it. The vended access_token IS the real
upstream Bearer that the agent can present to api.github.com.
The exchange is a plain form-POST to /oauth/token with
grant_type=urn:ietf:params:oauth:grant-type:token-exchange. The
resource= form param names the AS-side resource slug (github),
NOT a URL — the AS resolves that to the Broker resource row, which in
turn points at the provider. See
docs/guides/upstream-providers/token-exchange-grant.md
for the full request/response shape.
When any of the three-bound consent checks fails, the token endpoint
emits HTTP 400 with {"error":"consent_required","consent_url":...}
(the wire field is consent_url, per api/shared/errors.go:40). The
Python SDK maps this to ConsentRequiredError and exposes .consent_url
(python-sdk/authplane/errors.py:145-162). The URL the AS picks
depends on which bound failed:
- Bound B (no agent attestation on file) →
/authorize?resource=<actor-mcp>&scope=...— the user needs to consent to your agent acting against your MCP resource. This is what this example demonstrates. - Bound D (no broker grant on file) →
/connect/<provider>?...— the user needs to authorize the upstream provider (GitHub etc.). - Bound C/E (scope insufficient) → either of the above, with a
narrower
scopequery parameter.
From the developer's point of view all four shapes look identical: catch
ConsentRequiredError, redirect the user to e.consent_url, retry the
exchange when they return.
The auth-specific lines live between the # authplane:begin /
# authplane:end markers in agent.py. Run go run ./tools/loccount examples/python/04-broker-upstream from the repo root
to see the measured count.
Without Authplane Broker, an MCP server that needs the user's GitHub token must (a) implement its own OAuth client, (b) store refresh tokens itself, (c) refresh + revoke them on a schedule, and (d) re-prompt the user when the refresh chain breaks. The agent sees the refresh token — the most-stealable credential in the system.
With Authplane Broker the MCP server (or any agent) trades its
AS-issued user-bound token at /oauth/token and receives a fresh
upstream Bearer scoped to the requested scopes. Authplane holds the
refresh chain, encrypts it at rest, and proxies the refresh round-trip
transparently. The agent never sees the upstream refresh token.
+ from authplane import ASCredentials, AuthplaneClient, ConsentRequiredError
+ from authplane.oauth import TokenExchangeOptions
client = await AuthplaneClient.create(issuer=ISSUER, auth=ASCredentials(...))
base = (await client.client_credentials(scopes=["mcp:tools"], resources=[MCP_URI])).access_token
+ try:
+ upstream = await client.exchange(TokenExchangeOptions(
+ subject_token=base, scope="repo", resources=("github",),
+ ))
+ # `upstream.access_token` is `gho_…` — pass it to api.github.com directly.
+ except ConsentRequiredError as e:
+ redirect_user_to(e.consent_url) # /connect/<provider> or /authorize?resource=…Tier 04 is the final tier shipped today. For advanced topologies
(gateway fan-out, fronted Broker, multi-hop delegation chains, the
service-account-user "bot" pattern) see
docs/topologies/ and the
Token Exchange grant recipe.
SDK helpers and worked examples in other languages live in the public
SDK packages: github.com/authplane/go-sdk (Go module proxy) and
@authplane/sdk (npm).
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.