Skip to content

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).

ModuleInstall (pinned)Import
Official MCP Go SDK adaptergo get github.com/authplane/go-sdk/mcp@v0.1.1import "github.com/authplane/go-sdk/mcp/pkg/authplanemcp"
net/http resource servergo get github.com/authplane/go-sdk/http@v0.1.1import authhttp "github.com/authplane/go-sdk/http/pkg/auth"
Raw token client (agent side)go get github.com/authplane/go-sdk/core@v0.1.1import "github.com/authplane/go-sdk/core/authplane"

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):

main.go
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:

.env
# 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/mcp

NewAdapter 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.Adapter
for 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") }

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 its Authorization: Bearer token verified — signature against AuthPlane’s JWKS, then aud, exp, and required scopes. A missing or invalid token returns 401 with a WWW-Authenticate: Bearer resource_metadata="..." header; a missing scope returns 403 with {"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.

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).

Most opaque invalid_token failures trace to one of three byte-for-byte rules:

RuleWhat 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 ran go get on the adapter alone. Run go mod tidy.
  • Process exits at startupNewAdapter couldn’t reach AuthPlane during metadata discovery. Bring the AS up first or use the retry loop above.
  • 406 Not Acceptable on MCP calls → the request is missing the mandatory Accept: application/json, text/event-stream header.
  • grant type not supportedclient_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.