Go SDK
AuthPlane issues and signs the tokens; your Go MCP server verifies them with one adapter. github.com/authplane/go-sdk ships three modules: mcp (adapter for the official MCP Go SDK), http (plain net/http resource servers), and core (raw token client for the agent side).
Install
Section titled “Install”| Module | Install (pinned) | Import |
|---|---|---|
| Official MCP Go SDK adapter | go get github.com/authplane/go-sdk/mcp@v0.1.1 | import "github.com/authplane/go-sdk/mcp/pkg/authplanemcp" |
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" |
| Raw token client (agent side) | go get github.com/authplane/go-sdk/core@v0.1.1 | import "github.com/authplane/go-sdk/core/authplane" |
Protect your MCP server
Section titled “Protect your MCP server”A complete, runnable server — this is the examples/go/01-mcp-server-basic example that ships with AuthPlane (built against modelcontextprotocol/go-sdk v1.4.1):
package main
import ( "context" "log" "net/http" "os"
"github.com/authplane/go-sdk/mcp/pkg/authplanemcp" "github.com/modelcontextprotocol/go-sdk/mcp")
func main() { ctx := context.Background()
server := mcp.NewServer(&mcp.Implementation{Name: "demo-server", Version: "1.0.0"}, nil) handler := mcp.NewStreamableHTTPHandler( func(_ *http.Request) *mcp.Server { return server }, nil, )
// authplane:begin 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() // authplane:end
http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) http.Handle("/mcp", adapter.AuthMiddleware(handler))
mcp.AddTool(server, &mcp.Tool{ Name: "echo", Description: "Echo the supplied text back to the caller.", }, echo)
port := os.Getenv("PORT") if port == "" { port = "8080" } log.Printf("listening on :%s", port) if err := http.ListenAndServe(":"+port, nil); err != nil { log.Fatal(err) }}
type echoArgs struct { Text string `json:"text"`}
func echo(_ context.Context, _ *mcp.CallToolRequest, args echoArgs) (*mcp.CallToolResult, any, error) { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{Text: args.Text}}, }, nil, nil}
func must[T any](v T, err error) T { if err != nil { log.Fatal(err) } return v}With AuthPlane running locally (the authplane/authserver Docker image listens on :9000 public, :9001 admin), the SDK needs two env vars:
# Where the SDK discovers AuthPlane metadata + JWKS. Must resolve to the# same hostname as the AS-side AUTHPLANE_SERVER_ISSUER.AUTHPLANE_ISSUER=http://localhost:9000
# This server's full Resource URI — the JWT `aud` claim the verifier requires.AUTHPLANE_RESOURCE=http://localhost:8080/mcpNewAdapter does AS metadata discovery and the JWKS fetch at construction. AuthPlane must be reachable at that moment or must(...) exits the process before ListenAndServe — bring the AS up first, or wrap the constructor in a bounded retry:
var adapter *authplanemcp.Adapterfor i := 0; i < 30; i++ { a, err := authplanemcp.NewAdapter(ctx, opts) if err == nil { adapter = a; break } time.Sleep(time.Second)}if adapter == nil { log.Fatal("authserver unreachable after 30s") }How verification works
Section titled “How verification works”authplanemcp.NewAdapter performs RFC 8414 AS metadata discovery against Options.Issuer, fetches the JWKS, and gives you three handles:
adapter.AuthMiddleware(handler)wraps your MCP streamable-http handler. Every request gets itsAuthorization: Bearertoken verified — signature against AuthPlane’s JWKS, thenaud,exp, and required scopes. A missing or invalid token returns401with aWWW-Authenticate: Bearer resource_metadata="..."header; a missing scope returns403with{"error":"insufficient_scope","scope":"<required>"}.adapter.WellKnownPRMPath()+adapter.ProtectedResourceMetadataHandler()serve the RFC 9728 Protected Resource Metadata document at/.well-known/oauth-protected-resource/<mcp-path>(the resource’s URL path, suffixed per the MCP spec), pointing clients back at AuthPlane.adapter.Close()releases the adapter’s resources on shutdown.
JWKS handling is automatic: the SDK force-refreshes on an unknown kid, so AuthPlane key rotation needs no redeploy. The issuer check is strict — the SDK enforces metadata.issuer == config.issuer and fails fast on mismatch. Clock-skew tolerance for exp defaults to 60 s.
Audience binding. The Go adapter takes the full Resource URI as Options.Resource — no path derivation. The JWT aud must equal that string, so three things must agree byte-for-byte (scheme, host, port, path): Options.Resource, the uri you register at the AS (POST /admin/resources on :9001), and the URL MCP clients actually reach.
Per-route scopes. When you mount tools as individual routes with the net/http validator, declare the required scope per route with RequireScope:
// Scope strings are arbitrary identifiers; we use the `mcp:<tool>`// convention to match the rest of these docs. The URL path is independent.mux.Handle("POST /tools/echo", validator.RequireScope("mcp:echo", http.HandlerFunc(handleEcho)))DPoP. The adapter also validates DPoP sender-constrained tokens (RFC 9449). DPoP is off by default on the AS — set AUTHPLANE_DPOP_ENABLED=true at startup, or the discovery document silently omits it.
Retrofit an existing server
Section titled “Retrofit an existing server”Already running a modelcontextprotocol/go-sdk server? This is the before/after diff from the examples/go/retrofit-existing-mcp-server example — the tools and ListenAndServe stay untouched:
"github.com/authplane/go-sdk/mcp/pkg/authplanemcp" "github.com/modelcontextprotocol/go-sdk/mcp"
func main() { ctx := context.Background()
server := mcp.NewServer(&mcp.Implementation{Name: "retrofit-demo", Version: "1.0.0"}, nil) handler := mcp.NewStreamableHTTPHandler( func(_ *http.Request) *mcp.Server { return server }, nil, ) http.Handle("/mcp", handler)
// authplane:begin adapter := must(authplanemcp.NewAdapter(ctx, authplanemcp.Options{ Issuer: os.Getenv("AUTHPLANE_ISSUER"), Resource: os.Getenv("AUTHPLANE_RESOURCE"), Scopes: []string{"mcp:tools"}, DevMode: true, })) defer adapter.Close() // authplane:end
http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()) http.Handle("/mcp", adapter.AuthMiddleware(handler))The after version also adds the same ten-line must helper shown in the full example above, and go.mod gains github.com/authplane/go-sdk/mcp v0.1.1 (with core v0.1.1 riding along as an indirect dependency).
Troubleshooting
Section titled “Troubleshooting”Most opaque invalid_token failures trace to one of three byte-for-byte rules:
| Rule | What breaks it |
|---|---|
Issuer URLs match. The AS-side AUTHPLANE_SERVER_ISSUER (what the AS announces and stamps into iss) and the SDK-side AUTHPLANE_ISSUER (where it discovers metadata + JWKS) must resolve to the same hostname. | MCP server in the same Docker network as the AS → http://authserver:9000 on both. On the host or public → http://localhost:9000 (or your real hostname) on both. Mixing them 401s every call even though the token is valid. |
Resource URI matches everywhere. Options.Resource, the uri registered at the AS, and the resource= form param on the token request must match — scheme, host, port, path. | Changing PORT without updating AUTHPLANE_RESOURCE. |
One scope string, four places. The SDK Scopes: slice, POST /admin/resources, POST /admin/clients, and the scope= param on POST /oauth/token. | Any drift produces invalid_scope from the AS or insufficient_scope from the SDK. |
Go-specific pointers:
missing go.sum entry for module providing package github.com/authplane/go-sdk/core/...→ you rango geton the adapter alone. Rungo mod tidy.- Process exits at startup →
NewAdaptercouldn’t reach AuthPlane during metadata discovery. Bring the AS up first or use the retry loop above. 406 Not Acceptableon MCP calls → the request is missing the mandatoryAccept: application/json, text/event-streamheader.grant type not supported→client_credentials, DPoP, and token exchange are off by default. Set the matching env var (AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true,AUTHPLANE_DPOP_ENABLED=true,AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true) and restart the AS.
Next steps
Section titled “Next steps”- Quickstart — run AuthPlane, register a Resource and a client, mint a token
- How it works — the full token flow end to end
- FastMCP (Python) · TypeScript SDK
- Deploy with Docker · Configuration