Go
TL;DR — Four modules.
authplanemcptargets the official MCP Go SDK.mark3labstargets the mark3labs/mcp-go community library.httpis a genericnet/httpadapter for any Go HTTP server.coreis the framework-agnostic primitive package all others build on. Every adapter shares the sameOptions{Issuer, Resource, Scopes, DevMode, ClientOptions, VerifierOptions}shape and delivers Bearer + DPoP verification, RFC 9728 PRM, RFC 6750WWW-Authenticatechallenges, and RFC 8693 token exchange.
Install
# Official MCP Go SDK
go get github.com/authplane/go-sdk/mcp
# mark3labs/mcp-go community SDK
go get github.com/authplane/go-sdk/mark3labs
# Generic net/http (any Go server)
go get github.com/authplane/go-sdk/http
# Core primitives only
go get github.com/authplane/go-sdk/core
Compatibility:
All adapters delegate to go-sdk/core for the JWKS cache, metadata discovery, JWT validation, DPoP proof verification, token-exchange client, and introspection.
authplanemcp — Official MCP Go SDK adapter
Quickstart
package main
import (
"context"
"net/http"
"github.com/authplane/go-sdk/core/resource/verifier"
"github.com/authplane/go-sdk/mcp/pkg/authplanemcp"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
ctx := context.Background()
adapter, err := authplanemcp.NewAdapter(ctx, authplanemcp.Options{
Issuer: "http://localhost:9000",
Resource: "http://localhost:8080/mcp",
Scopes: []string{"tools/read"},
DevMode: true,
VerifierOptions: []verifier.Option{
verifier.WithInboundDPoP(verifier.InboundDPoPOptions{Required: true}),
},
})
if err != nil {
panic(err)
}
defer adapter.Close()
server := mcp.NewServer(
&mcp.Implementation{Name: "my-server", Version: "1.0.0"}, nil,
)
handler := mcp.NewStreamableHTTPHandler(
func(_ *http.Request) *mcp.Server { return server }, nil,
)
http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())
http.Handle("/mcp", adapter.AuthMiddleware(handler))
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
Options reference
WithVerifierOptions replaces (not appends to) the auto-wired option list. If you set VerifierOptions while ClientOptions also wires introspection, you must include the introspection checker yourself or accept that revocation is off.
Main API
Per-tool scope enforcement
The official MCP Go SDK doesn’t ship a per-tool scope guard — check inside your handler:
func writeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
claims := authplanemcp.ClaimsFromContext(ctx)
if claims == nil {
return nil, fmt.Errorf("no claims in context")
}
if !claims.HasScope("tools/write") {
return nil, fmt.Errorf("insufficient scope: tools/write required")
}
// ... your logic
return &mcp.CallToolResult{}, nil
}
Token exchange
Call TokenExchange on the adapter — this is the path that auto-maps ConsentRequiredError to the MCP JSON-RPC -32042 elicitation error. Calling TokenExchange on the raw client returned by adapter.Client() does not auto-map.
resp, err := adapter.TokenExchange(ctx, authplane.TokenExchangeInput{
SubjectToken: inboundToken,
Resources: []string{"https://api.example.com/downstream"},
Scopes: []string{"tools/write"},
})
// resp.AccessToken, resp.TokenType ("Bearer" or "DPoP"), resp.ExpiresIn
If the exchange fails with ConsentRequiredError (contains a ConsentURL field), the adapter maps it to the MCP JSON-RPC -32042 elicitation error for the client. Your handler doesn’t need try/except-style handling.
Sharing a pre-built client
For multi-resource deployments where one client backs several adapters, build authplane.NewClient yourself and pass it via ClientOptions — the adapter will not own its lifecycle in that case. See the full user-guide §10.
authplanemark3labs — mark3labs/mcp-go adapter
Quickstart
package main
import (
"context"
"net/http"
"github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs"
"github.com/mark3labs/mcp-go/server"
)
func main() {
ctx := context.Background()
adapter, err := authplanemark3labs.NewAdapter(ctx, authplanemark3labs.Options{
Issuer: "https://auth.example.com",
Resource: "https://mcp.example.com/mcp",
Scopes: []string{"tools/query", "tools/write"},
})
if err != nil {
panic(err)
}
defer adapter.Close()
mcpServer := server.NewMCPServer("my-server", "1.0.0",
server.WithToolCapabilities(false),
server.WithRecovery(),
)
streamable := server.NewStreamableHTTPServer(mcpServer,
server.WithHTTPContextFunc(adapter.HTTPContextFunc()),
)
http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler())
http.Handle("/mcp", adapter.AuthMiddleware(streamable))
http.ListenAndServe(":8080", nil)
}
Two coordinated hooks
mark3labs/mcp-go has an HTTP request context and a per-tool-call MCP context — they don’t share by default. The adapter bridges them via two required hooks:
Per-tool scope enforcement
Scope enforcement is per-tool, not per-request, matching the MCP protocol requirement that initialize succeed with any authenticated client:
import "github.com/authplane/go-sdk/mark3labs/pkg/authplanemark3labs"
func writeHandler(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
claims := authplanemark3labs.ClaimsFromContext(ctx)
if claims == nil {
return mcp.NewToolResultError("no claims"), nil
}
if !claims.HasScope("tools/write") {
return mcp.NewToolResultError("insufficient scope: tools/write required"), nil
}
// ... your logic
return mcp.NewToolResultText("done"), nil
}
Under the hood
*Adapter embeds *authplanehttp.Adapter — the same middleware, scope guard, DPoP/bearer parsing, and WWW-Authenticate shape as the HTTP adapter below. mark3labs-only additions are the context bridge and URL-elicitation mapping.
authplanehttp — net/http adapter
For any Go HTTP server — not tied to an MCP framework. Use when your resource server is a regular net/http service and you want AuthPlane token validation without pulling in an MCP SDK.
Quickstart
package main
import (
"context"
"net/http"
"github.com/authplane/go-sdk/core/authplane"
"github.com/authplane/go-sdk/core/resource"
"github.com/authplane/go-sdk/http/pkg/authplanehttp"
)
func main() {
ctx := context.Background()
client, err := authplane.NewClient(ctx, "https://auth.example.com",
authplane.WithClientCredentials("my-client", "s3cret"),
)
if err != nil {
panic(err)
}
defer client.Close()
res, err := client.Resource("https://api.example.com",
resource.WithScopes("read", "write"),
)
if err != nil {
panic(err)
}
adapter := authplanehttp.New(res)
mux := http.NewServeMux()
mux.Handle(adapter.WellKnownPRMPath(), adapter.PRMHandler())
mux.Handle("/api/admin", adapter.RequireScopes("admin")(http.HandlerFunc(adminHandler)))
mux.Handle("/api/", http.HandlerFunc(readHandler))
http.ListenAndServe(":8080", adapter.Middleware()(mux))
}
Three things happen per request
Middleware()extracts theAuthorizationheader, verifies the token (Bearer or DPoP), and injects*verifier.VerifiedClaims+ raw token into the request context.RequireScopes(...)middleware (optional, per-route) checks for required scopes.- The well-known PRM path is auto-excluded from token checks so unauthenticated clients can discover the AS.
On failure, the adapter writes an RFC 6750 error response: correct HTTP status, WWW-Authenticate with the right scheme (Bearer or DPoP), error code, and JSON body.
Adapter does not own client lifecycle
Unlike the MCP adapters, authplanehttp is a thin wrapper around *resource.Resource — it holds no cleanup state. The caller owns the *authplane.Client and calls client.Close(). One client typically backs many resources and adapters in a single process.
authplane — Core primitives
Framework-agnostic package. Use it when:
- You want direct control over the JWKS cache, DPoP outbound, or token-exchange flow.
- You’re building a custom framework adapter.
- You need to verify tokens outside a request context.
Build a client + resource
import (
"context"
"github.com/authplane/go-sdk/core/authplane"
"github.com/authplane/go-sdk/core/resource"
)
client, err := authplane.NewClient(ctx, "https://auth.example.com",
authplane.WithClientCredentials("my-client", "s3cret"),
)
if err != nil { panic(err) }
defer client.Close()
res, err := client.Resource("https://api.example.com/mcp",
resource.WithScopes("tools/query"),
)
if err != nil { panic(err) }
Verify a token
claims, err := res.VerifyToken(ctx, accessToken) // add resource.WithDPoP(dpopCtx) for DPoP
if err != nil {
// sentinel errors: verifier.ErrTokenExpired, verifier.ErrInsufficientScope,
// verifier.ErrDPoPBindingMismatch, etc. — check with errors.Is.
}
// claims.Sub(), claims.Scopes(), claims.Audience(), claims.Cnf(), claims.Act(), claims.AgentID(), ...
Token exchange
resp, err := client.TokenExchange(ctx, authplane.TokenExchangeInput{
SubjectToken: userToken,
Resources: []string{"https://api.example.com/downstream"},
Scopes: []string{"tools/write"},
})
// resp.AccessToken, resp.TokenType ("Bearer" | "DPoP"), resp.ExpiresIn
If the exchange requires user consent that hasn’t happened yet, client.TokenExchange returns a *authplane.ConsentRequiredError — its ConsentURL field is what an MCP adapter would map to the -32042 elicitation error.
Inbound DPoP
Build a verifier.DPoPContext from your framework’s request (method, URL, DPoP header values) and pass it as an option: res.VerifyToken(ctx, token, resource.WithDPoP(dpopCtx)). See core user-guide §9 Inbound DPoP Summary.
Error handling
Sentinel errors from github.com/authplane/go-sdk/core/resource/verifier, checked with errors.Is:
ErrTokenMissing,ErrTokenExpired,ErrInvalidSignature,ErrInvalidClaims,ErrInsufficientScope,ErrTokenRevokedErrDPoPBindingMismatch,ErrDPoPReplayDetected,ErrDPoPInvalidErrMetadataUnavailable,ErrJWKSUnavailable
Plus authplane.ConsentRequiredError for token-exchange consent failures.
Each sentinel maps to an OAuth error code suitable for a WWW-Authenticate challenge — the HTTP adapter uses these to build the response automatically; direct users of core construct their own.
Cleanup
client.Close(). Idempotent. Releases JWKS and metadata refresh goroutines.
Related
- SDKs overview — pick between adapters
- Quickstart —
authplanemcpend-to-end in 10 minutes - Guides: Enable DPoP end-to-end
- Guides: Wire up the Token Vault
- Full user-guides in the go-sdk repo: