Auth-specific code: 5 lines · Total example: 49 lines · SDK: go-sdk v0.1.1
A minimal MCP server protected by Authplane-issued JWTs. Everything you
need to authenticate inbound MCP calls fits inside a single five-line block
in main.go; the rest of the file is plain MCP boilerplate.
- How to wire Authplane JWT validation into an MCP server with the
github.com/authplane/go-sdk/mcpadapter - 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) |
|---|---|---|
| Go | 1.25+ | brew install go / go.dev/dl |
| Docker | 24+ (daemon running) | Docker Desktop / Rancher Desktop |
| curl | any | preinstalled |
| jq | any | brew install jq / apt install jq |
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 | Under a minute warm-cache (go build + AS image pull) |
| SDK | github.com/authplane/go-sdk/mcp v0.1.1 (Go module proxy) |
| MCP framework | github.com/modelcontextprotocol/go-sdk v1.4.1 (matches go.mod) |
make run # builds the MCP binary, 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
make distclean # full reset including .envThe basic flow doesn't build any Dockerfiles. The AS is pulled as a
published image; the MCP server is a plain Go binary built with
go build and exec'd on the host. First run downloads the AS image
(~30 MB) and the Go modules; subsequent runs start in seconds.
make run auto-creates .env from .env.example on first run.
For the all-in-containers flow (slower first build, useful as a deployment
shape reference) 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 runmake verify hangs at "waiting for ..."
Something didn't come up. Inspect each component:
make status # is everything actually running?
make logs # last 40 lines from AS + MCP-servermake verify returns invalid_token
JWT audience mismatch. The Resource URI must agree byte-for-byte across
the SDK config, the /admin/resources registration, and the
resource=... form param on the token request. The default flow gets
this right; you'll only hit it if you changed MCP_PORT.
ERROR: jq not found from make verify
brew install jq (macOS) or apt install jq (Debian/Ubuntu).
missing go.sum entry for module providing package github.com/authplane/go-sdk/core/...
go get github.com/authplane/go-sdk/mcp@v0.1.1 pulls the mcp adapter but
not its transitive go-sdk/core dependency, so the next go build fails on
the missing checksum. Run go mod tidy (or
go get github.com/authplane/go-sdk/mcp/pkg/authplanemcp@v0.1.1) to record
core in go.sum. The committed go.mod/go.sum here already have it;
you only hit this when adding the adapter to your own module.
Stale state
make distclean is the full reset.
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 (the example default) →
http://authserver:9000on both.- MCP server on the host, another machine, or public →
http://localhost:9000(or your real 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.
The two common topologies:
| Topology | AUTHPLANE_SERVER_ISSUER (AS) |
AUTHPLANE_ISSUER (SDK) |
|---|---|---|
| Same docker network (the example default) | http://authserver:9000 |
http://authserver:9000 |
| AS in a container, MCP server on the host (the common dev path) | http://localhost:9000 |
http://localhost:9000 |
For the host-side topology, run the AS with docker run -p 9000:9000 -p 9001:9001 -e AUTHPLANE_SERVER_ISSUER=http://localhost:9000 ... and your Go server reads the same http://localhost:9000 from its env. Don't mix the two — authserver:9000 is not resolvable from your host, and localhost:9000 inside a container points at itself.
2. The Resource field on authplanemcp.Options is the JWT
audience byte-for-byte. It must match the URL your MCP server
actually serves on — scheme, host, port, and path included.
If you change any of those, all three of these must agree:
Resource:field onauthplanemcp.Options(orAUTHPLANE_RESOURCEenv)urifield on the Resource you register at the AS- The URL the MCP client actually reaches
Custom port: PORT=9090 go run ./ runs on :9090, so the Resource
URI becomes http://localhost:9090/mcp and the AS registration must
use that same URI. Custom path: change the Resource value 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: []string{"mcp:echo"} in authplanemcp.Options (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. The default
AUTHPLANE_RESOURCEin this example 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 MCP Go server, no auth:
server := mcp.NewServer(&mcp.Implementation{Name: "demo-server", Version: "1.0.0"}, nil)
handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { return server }, nil)
http.Handle("/mcp", handler)
http.ListenAndServe(":8080", nil)The same server with Authplane JWT validation:
+ adapter := must(authplanemcp.NewAdapter(ctx, authplanemcp.Options{
+ Issuer: os.Getenv("AUTHPLANE_ISSUER"), Resource: os.Getenv("AUTHPLANE_RESOURCE"),
+ Scopes: []string{"mcp:echo"}, DevMode: true,
+ }))
+ defer adapter.Close()
+ http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())
- http.Handle("/mcp", handler)
+ http.Handle("/mcp", adapter.AuthMiddleware(handler))
http.ListenAndServe(":8080", nil)must(v, err) is not from the SDK or stdlib — it's a tiny generic helper this example defines at the bottom of main.go (func must[T any](v T, err error) T { if err != nil { log.Fatal(err) }; return v }). It keeps the auth-setup block focused on the SDK call. In production code, replace it with explicit error handling:
adapter, err := authplanemcp.NewAdapter(ctx, opts)
if err != nil { /* return / log / retry — your call */ }
defer adapter.Close()Five lines of auth code (counting only what's inside // authplane:begin / // authplane:end) plus two mount lines (PRM handler, AuthMiddleware) plus the must-or-equivalent error-handling line. Run go run ./tools/loccount examples/go/01-mcp-server-basic from the repo root to see the auth-line count CI enforces.
authplanemcp.NewAdapter performs RFC 8414 AS metadata discovery
against Issuer, fetches the JWKS, and constructs a verifier bound to
the Resource URI. The returned Adapter exposes
AuthMiddleware (which wraps an http.Handler and enforces
bearer-token authentication via auth.RequireBearerToken from the MCP
go-sdk) and ProtectedResourceMetadataHandler
(which serves the RFC 9728 Protected Resource Metadata document at the
canonical well-known path). Every inbound HTTP request to /mcp is
checked against the AS's JWKS for signature validity, then the audience
and exp claims are validated. The Resource you pass to NewAdapter
becomes the JWT audience the verifier requires, 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 |
|---|---|---|---|
DevMode: true in authplanemcp.Options{...} |
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. |
| Signing keys | Auto-generated in /data/keys |
HashiCorp Vault Transit | See docs/guides/deploy/hashicorp-vault-transit.md. |
| Distroless base image | gcr.io/distroless/static-debian12:nonroot (no tzdata) |
Add tzdata or switch base | The example Dockerfile uses distroless static which omits /usr/share/zoneinfo. If any of your tools calls time.LoadLocation("America/New_York") (or similar), it silently falls back to UTC. Either embed the tzdata at build time (import _ "time/tzdata" in your main.go) or switch the runtime base to gcr.io/distroless/base-debian12:nonroot. |
must[T] helper |
Five-line generic at the bottom of main.go, log.Fatal on error |
Explicit if err != nil { ... } with retry, structured log, and graceful shutdown |
The helper exists to keep the auth-setup block focused; it isn't production error handling. |
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.