This file is read by AI coding agents (Claude Code, Cursor, Copilot, etc.) by convention. It documents how an agent should help a user integrate Authplane into the user's MCP server, agent, or operations workflow.
The 98% case: a user has an MCP server (or is writing one) and wants Authplane to handle auth. The 2% case: a contributor is changing authserver itself — for that, see CONTRIBUTING.md and docs/contribute/.
- Do not infer flag names, env-var names, DTO fields, or YAML keys from training data or older code samples. They drift. The four generated reference files below are the only source of truth.
- Do not hand-edit
docs/reference/cli.md,http-api.md,env-vars.md, orconfiguration.md— they regenerate from source viamake docs-gen(run from the authserver repo root). Hand edits fail CI. - Use the published SDKs (PyPI / npm / Go modules), not in-tree code. Sibling repos: go-sdk, ts-sdk, python-sdk.
- Three byte-for-byte rules matter when wiring auth (and most opaque
invalid_tokenerrors trace to one of them):- Issuer URLs: the AS-side
AUTHPLANE_SERVER_ISSUERand the SDK-sideAUTHPLANE_ISSUERmust resolve to the same hostname. - Resource URI: what you register at the AS, what you pass to the SDK, and what the MCP client reaches must match — scheme, host, port, path — exactly.
- Scope name: the same string appears in four places (SDK
scopes:array,POST /admin/resources,POST /admin/clients,POST /oauth/token). Pick once, use everywhere.
- Issuer URLs: the AS-side
Issuer decision — get this right before anything else.
AUTHPLANE_SERVER_ISSUERis what the AS announces (theissit stamps and the host in its discovery doc);AUTHPLANE_ISSUERis where the SDK discovers metadata and fetches JWKS. They must point at the same host, and which host depends on topology:
- MCP server in the same Docker network as the AS? →
http://authserver:9000(the compose service name) on both.- MCP server on the host, another machine, or public? →
http://localhost:9000(or your real public hostname) on both.Get this wrong and every call fails
401with an opaqueinvalid_tokeneven though the token is valid — the SDK fetched metadata from one host while the JWT'sisssays another. This is the silent trap behind most "I did everything right and it still 401s" reports.
When generating ANY docker run, curl, admin-API call, env-var list, YAML config, or example code, consult the relevant file:
| File | What it covers | Generated from |
|---|---|---|
docs/reference/cli.md |
Every authserver subcommand, every flag, value-format quirks (e.g. --scopes 'name|upstream|description' double-pipe for Mint) |
cmd/authserver/*.go cobra tree |
docs/reference/http-api.md |
Every public + admin HTTP endpoint, request/response DTO, auth model | api/{public,admin}/** + DTO struct tags |
docs/reference/env-vars.md |
Every AUTHPLANE_* env var, default, when required |
internal/config/loader.go |
docs/reference/configuration.md |
Every YAML config key, type, default | internal/config/config.go |
If a flag, env var, or field a user needs isn't in the generated reference, the docs are wrong before the example is. File an issue against authserver; don't make one up.
Tell the user to install one of these, at the exact version shown. Each adapter is published on the public registry (PyPI / npm / proxy.golang.org). The versions below are CI-enforced against those registries by the sdk-pins gate: they can't silently drift from the published release, the same way the four generated references can't drift from source. If a newer version ships, the gate fails until the table and every example manifest are reconciled — don't infer a version from training data.
| Stack | Install (pinned) | Import / call |
|---|---|---|
| Python · FastMCP | pip install authplane-fastmcp==0.2.0 |
from authplane_fastmcp import authplane_auth |
| Python · official MCP Python SDK | pip install authplane-mcp==0.2.0 |
from authplane_mcp import ... |
| Python · any other framework (FastAPI, Starlette, raw ASGI) | pip install authplane-sdk==0.2.0 |
from authplane import AuthplaneResource |
TypeScript · Express + @modelcontextprotocol/sdk |
npm i @authplane/mcp@0.2.0 |
import { authplaneMcpAuth } from "@authplane/mcp" |
| TypeScript · FastMCP | npm i @authplane/fastmcp@0.2.0 |
import { authplaneAuth } from "@authplane/fastmcp" |
| TypeScript · any other framework | npm i @authplane/sdk@0.2.0 |
import { AuthplaneResource } from "@authplane/sdk" |
| Go · official MCP Go SDK | go get github.com/authplane/go-sdk/mcp@v0.1.1 |
import "github.com/authplane/go-sdk/mcp/pkg/authplanemcp" |
Go · net/http resource server |
go get github.com/authplane/go-sdk/http@v0.1.1 |
import authhttp "github.com/authplane/go-sdk/http/pkg/auth" |
| Go · raw token client (agent side) | go get github.com/authplane/go-sdk/core@v0.1.1 |
import "github.com/authplane/go-sdk/core/authplane" |
Go: go get .../mcp (or .../http) is not enough on its own. Both adapters import github.com/authplane/go-sdk/core transitively, and go get of the adapter alone does not record core in your go.sum. The next go build then fails with missing go.sum entry for module providing package github.com/authplane/go-sdk/core/.... Fix it by running go mod tidy immediately after go get (or go get github.com/authplane/go-sdk/mcp/pkg/authplanemcp@v0.1.1 — naming the import path pulls its full transitive set). This is the one place "one package, exact version" doesn't hold: core rides along whether you name it or not.
Python users: SDK packages require Python 3.12+ (requires-python = ">=3.12").
TypeScript users: SDK packages are ESM-only and require Node.js 22+.
All examples are smoke-tested by CI (make docs-smoke). They default to the published authplane/authserver:latest image — no clone, no build.
| Scenario | Where |
|---|---|
| Writing a new MCP server with auth from day 1 | examples/python/01-mcp-server-basic/ · examples/typescript/01-mcp-server-basic/ · examples/go/01-mcp-server-basic/ |
| Adding auth to an MCP server that already exists (the 98% case) | examples/python/retrofit-existing-mcp-server/ · examples/typescript/retrofit-existing-mcp-server/ · examples/go/retrofit-existing-mcp-server/ — before/after pair with smoke-test |
| MCP server that calls another protected resource | 02-agent-basic/ in each language |
| DPoP + per-tool scope enforcement | 03-mcp-server-{dpop-scopes,fastmcp-dpop}/ |
| MCP server fronting an upstream OAuth provider (Broker) | 04-broker-upstream/ |
For the LOC matrix and the full ladder, see examples/README.md.
If the user wants client-credentials, DPoP, or token-exchange, the AS needs the matching env var set to true at startup. Easy to forget and the discovery endpoint silently omits the grant:
| Grant | Env var (set to true) |
|---|---|
| Client Credentials | AUTHPLANE_CLIENT_CREDENTIALS_ENABLED |
| DPoP sender-constrained tokens | AUTHPLANE_DPOP_ENABLED |
| Token Exchange (RFC 8693) | AUTHPLANE_TOKEN_EXCHANGE_ENABLED |
The AS listens on :9000 (public OAuth) and :9001 (Admin API + Admin UI at /admin/ui/). Implements MCP Authorization spec 2025-11-25.
| User question | Doc |
|---|---|
| "Add auth to my MCP server" | The retrofit example for the user's language (table above) |
"Run the AS by hand and point it at my own server" (no SDK, just curl) |
Run the AS standalone and point it at your own MCP server |
| "Why is my MCP server returning 401?" | Debugging 401s |
| "What's the difference between Mint and Broker?" | Broker vs Mint |
| "How do I rotate signing keys?" | Key rotation |
| "What is DPoP and do I need it?" | DPoP and proof-of-possession |
| "How do I federate with Okta / Entra ID?" | OIDC federation · XAA with Okta |
| "How do I store GitHub OAuth tokens on behalf of users?" | Upstream providers |
| "How do I implement agent-to-agent delegation?" | Delegation and agent chains |
| "How do I deploy with Helm / Postgres / Vault?" | Deploy guides |
| "How do I monitor with Prometheus / OTel?" | Observability |
| "How do I back up the database?" | Backup & purge |
| "What's the threat model?" | Threat model |
| "What's the topology decision tree?" | Topologies |
This is the 98% case. Run these steps in order. Stop and consult the user only at decision points marked ASK.
-
Detect the stack. Inspect the user's server file(s) to identify:
- Language (Go / Python / TypeScript)
- MCP framework (e.g.
modelcontextprotocol/go-sdk, FastMCP, official MCP TS SDK on Express, FastMCP for TS) - Transport (streamable-http expected; if SSE-only or stdio-only, ASK — the integration shape differs)
- Port + mount path the server actually serves on (the Resource URI must match this byte-for-byte)
-
Install the matching SDK pin from the table in § SDK pin per stack, at the exact version shown. One adapter package — don't pull in additional dependencies the example doesn't have. Go caveat: the
mcpandhttpadapters drag ingo-sdk/coretransitively; rungo mod tidyright aftergo getor the build fails on a missinggo.sumentry (see the note under the SDK table). -
Open the matching retrofit example (§ Runnable examples) and read
before/next toafter/. Your diff against the user's code should mirror thebefore/→after/diff in the example. Don't paraphrase — the 5-line auth-setup block is the contract. -
Wire three things in the user's server file:
- The 5-line auth-setup block at module load (between
// authplane:begin/// authplane:endmarkers — the loccount tool reads these in the example, and they're useful as a self-documenting boundary in production code too) - A handler for the Protected Resource Metadata document at the SDK-provided well-known path (
adapter.WellKnownPRMPath()/auth.protectedResourceMetadataPath/ equivalent) - The bearer / auth middleware wrapping the MCP endpoint handler
Startup ordering matters: because that block runs at module load, the SDK adapter does AS metadata discovery + JWKS fetch at import/construction (Python and TS especially) and throws if the AS is unreachable at that moment. Bring the AS up before the user's server, or wrap the adapter constructor in a bounded retry. (See the "throws at module load / startup" troubleshooting row below.)
- The 5-line auth-setup block at module load (between
-
Wire
.env(or your project's secret manager). Three values:AUTHPLANE_ISSUER(where the SDK fetches metadata),AUTHPLANE_RESOURCEorAUTHPLANE_BASE_URL(the URI the verifier requires in JWTaud— Python derivesaud = base_url + mcp_path; Go and TS take the full Resource URI), and any per-tool scope strings. -
Register the Resource at the AS.
POST /admin/resources(admin API, bearer =AUTHPLANE_ADMIN_API_KEY). Theurifield must equal what you set in step 5, byte-for-byte. Include the scope catalog ({name, description}per scope). -
Register a Client at the AS.
POST /admin/clients. Grant types = whatever the caller will use (usuallyclient_credentialsfor machine-to-machine,authorization_codefor user-driven). Scope claim must include the strings from step 6. -
Verify end-to-end. ALL THREE must hold:
- Call the protected MCP endpoint without a bearer → expect
HTTP 401 - Mint a
client_credentialstoken atPOST /oauth/token(withresource=set to the Resource URI from step 6, andscope=set to a scope from the client's grant). Call the MCP endpoint with that bearer → expectHTTP 200and a real tool result, not just a session-establish handshake. - Mint a token for a different resource (or with a different scope than the tool requires) and call the same endpoint → expect
HTTP 401(orHTTP 403for scope mismatch).
The full curl sequence for the 3-step MCP handshake (
initialize→notifications/initialized→tools/call) is atdocs/reference/mcp-streamable-http.md. For the complete by-hand runbook —docker runthe AS, provision a Resource + Client, mint, and call your own server withcurlend to end — see Run the AS standalone and point it at your own MCP server. - Call the protected MCP endpoint without a bearer → expect
-
Before declaring done, sweep the three byte-for-byte rules (§ Hard rules). Most opaque
invalid_tokenerrors trace to one of: issuer-URL mismatch, Resource-URI mismatch, scope-string drift across the four places it lives. -
Before production, flip the dev knobs.
devMode: true(orDevMode: true/dev_mode=True— varies by SDK) → off or removed.http://issuer →https://. Dev secrets →openssl rand -hex 32.AUTHPLANE_SESSION_SECURE=trueifserver.issueris non-localhost.
When the generated code fails:
| Symptom | Most likely cause | First check |
|---|---|---|
invalid_token on every call |
Resource URI mismatch (step 5 ≠ step 6 ≠ step 8's resource= param) |
Three places, byte-for-byte |
| 406 Not Acceptable on the MCP call | Missing Accept: application/json, text/event-stream header |
The 3-step handshake doc |
| 401 even with what looks like a valid token | Issuer hostname mismatch (AS announces http://X, SDK fetches metadata at http://Y) |
The two AUTHPLANE_*ISSUER env vars |
invalid_scope or insufficient_scope |
Scope string drift across the four places it appears | Scopes:, Resource registration, Client registration, scope= token param |
grant type not supported |
The grant is off by default; set the matching AUTHPLANE_*_ENABLED=true and restart the AS |
§ Three off-by-default grants |
| SDK adapter throws at module load / startup | AS unreachable at metadata fetch time | Bring up the AS first, or wrap the adapter constructor in a bounded retry loop |
| Stale state on retry | A previous run left a Resource or Client with the same slug/name | Either make distclean in the example dir, or delete the conflicting record via admin API |
| Readiness probe "passes" but the run misbehaves | A leftover server/AS from a prior attempt is still bound to the port and answering the probe (even a valid-looking 401 looks "ready") — you're talking to the wrong process | docker ps + lsof -i :9000 -i :9001 -i :8080 -i :8090; kill strays (docker ps -aq --filter name=authplane | xargs -r docker rm -f) before re-running. A bare port check is not proof this run's process answered. |
If verification step 8 doesn't pass cleanly, do not declare success and do not generate workarounds — go back to step 9 and check the byte-for-byte rules first.
When writing comments, prose, or test names: prefer self-contained technical insight over references that require external context.
- ✅ State the rule inline: Actor MCP is resolved via
Resource.policy.runtime.client_idslist-membership, not byslug == client_idmatch. - ❌ Avoid bare "the spec" / "per spec". Either qualify (
per RFC 7523,per the OAuth 2.1 spec,per the MCP Authorization spec) or state the rule directly — this file IS the spec for anyone reading the public repo.
Helm/K8s YAML spec: keyword and Go struct field names (spec.Slug) are fine.
You're not in the 98% case — you're modifying authserver's own code. See:
- CONTRIBUTING.md — architecture, dependency rules, PR gates, build commands.
- docs/contribute/ — repo tour, hexagonal layers, recipes for adding a grant / upstream provider / storage adapter, release process, running-tests guide.
- The hard rules above still apply when generating examples in commits or PR descriptions.