Your first MCP server
TL;DR — This page dissects the Quickstart snippet. If you copied it and it works, this is where you learn why. If you want to extend it (custom scopes, upstream tokens, per-tool policy), start here.
What the SDK call does
The Quickstart’s one-liner setup is not one call — it’s a stack. Whichever language you picked, authplane_mcp_auth / authplaneFastMcpAuth / authplanemcp.NewAdapter does the same seven things:
- Discovers the authorization server —
GET {issuer}/.well-known/oauth-authorization-serverper RFC 8414. Result cached in memory with a periodic refresh (default 3600 s). - Fetches the JWKS —
GET {issuer}/.well-known/jwks.jsonper RFC 7517. Cached with a shorter refresh (default 300 s) so key rotation is picked up quickly. - Builds an
AuthplaneResource— an in-memory object that knows this resource’s URI, its declared scopes, and its inbound DPoP policy. - Wraps the resource in a token verifier — the object that MCP-framework
authenticatecallbacks call to verify each incoming JWT. - Builds a Protected Resource Metadata document — the RFC 9728 JSON that will be served path-scoped at
/.well-known/oauth-protected-resource/<resource-path>(for a resource athttp://host/mcp, that’s/.well-known/oauth-protected-resource/mcp), listing this resource’s URI, its authorization servers, and its supported scopes. - Wraps the underlying OAuth client so that any
client.exchange(...)call that raisesConsentRequiredErrorgets translated to MCP JSON-RPC-32042UrlElicitationRequiredError— the wire format that prompts the MCP client to open a consent URL. - Wires an on-shutdown hook — the
aclose()/Close()you call infinally/deferreleases the background JWKS refresh task and the httpx / net/http connection pool.
None of this touches your request path. Every subsequent JWT validation is a local RSA/ECDSA signature check against the cached JWKS — no round-trip to AuthPlane per request.
The pieces you need to wire yourself
The **auth spread / .authenticate field / AuthMiddleware() handles the token check. What you own on top:
A tool that requires a scope
Scope enforcement happens at two layers:
- Request layer — set once per adapter. If a request’s token does not carry every scope in
requiredScopes/enforce_scopes_on_all_requests, the SDK rejects the request with403 insufficient_scopebefore your tool code runs. - Per-tool — call the framework’s scope guard inside each handler. This is the granular pattern that lets one tool require
tools/readand another requiretools/write.
Use both together (as the Quickstart does): request-layer sets the baseline; per-tool enforces the fine-grained rules.
from authplane_mcp import require_scope
@mcp.tool()
async def write(payload: str) -> str:
"""Requires a token with scope 'tools/write' — enforced per-call."""
require_scope("tools/write")
return f"Wrote: {payload}"import { requireScopes } from "fastmcp";
server.addTool({
name: "write",
description: "Requires a token with scope 'tools/write' — enforced per-call.",
parameters: z.object({ payload: z.string() }),
canAccess: requireScopes("tools/write"),
execute: async ({ payload }) => ({ content: [{ type: "text", text: `Wrote: ${payload}` }] }),
});// The Go MCP SDK doesn't ship a per-tool scope guard — check the token
// yourself from the request context. Claims are injected by AuthMiddleware.
import "github.com/authplane/go-sdk/mcp/pkg/authplanemcp"
func writeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
claims, ok := authplanemcp.ClaimsFromContext(ctx)
if !ok {
return nil, fmt.Errorf("no claims in context")
}
if !claims.Scopes.Has("tools/write") {
return nil, fmt.Errorf("insufficient scope: tools/write required")
}
// ... your tool logic
return &mcp.CallToolResult{}, nil
}Whatever scopes you use in per-tool guards must also appear in the scopes=[...] array passed to the adapter — that’s what the SDK advertises in the PRM’s scopes_supported. If a client asks for a scope you never declared, it gets rejected at token mint by the AS.
Inbound DPoP enforcement — what makes it work
Two things must line up:
- The AS must issue DPoP-bound tokens. In the Quickstart you set
AUTHPLANE_DPOP_ENABLED=true. Any client that presents aDPoPheader on/oauth/tokengets a token with acnf.jktclaim binding it to that client’s key. - The SDK must verify the proof on every request. That’s
inbound_dpop=InboundDPoPOptions(required=True)(Python),inboundDPoP: { required: true }(TS),verifier.WithInboundDPoP(verifier.InboundDPoPOptions{Required: true})(Go).
Python has a third requirement: install_request_context(mcp). The official MCP Python SDK’s FastMCP doesn’t expose a middleware seam, so the adapter has to publish the active Request on a ContextVar before the verifier runs. Without this call, DPoP-bound requests fail closed with 401 — the misconfiguration surfaces immediately rather than as a silent bypass. See the DPoP guide for the full picture.
Registering the resource in AuthPlane
The SDK advertises your resource via PRM, but AuthPlane also needs to know about it so it can:
- Include your scopes in
scopes_supportedon/.well-known/oauth-authorization-server - Allow token-exchange calls that name your resource as
audience - Enforce per-resource policy (upstream token vending, runtime client binding, XAA policies)
Register it with the admin CLI or Admin UI:
$ authserver admin resource create \
--slug my-server \
--uri http://localhost:8080/mcp \
--backend-kind mint \
--scopes 'tools/read||Read tools' \
--scopes 'tools/write||Write tools'
Or via the Admin API — see Admin API guide.
What happens on a real request
Following one request end-to-end after everything is wired:
1. MCP client sends: POST /mcp
Authorization: DPoP eyJhbGci…
DPoP: eyJhbGci…
2. Your SDK's middleware runs:
a. Reads bearer + DPoP header
b. Fetches signing key from cached JWKS (no network hop)
c. Validates JWT signature (RS256 or ES256)
d. Validates exp, nbf, iat, iss (== issuer), aud (== resource)
e. Validates DPoP proof:
- JWT format correct, alg allowed
- htu matches your resource URL, htm matches request method
- jti not seen before (replay protection)
- ath equals SHA-256(access_token)
- jkt in access_token cnf matches JWK thumbprint of proof
f. Checks token scopes contain every scope in requiredScopes
g. Publishes VerifiedClaims on request context
3. Your tool handler runs:
a. require_scope("tools/read") passes (already checked, no-op)
b. Your logic executes
c. Returns response
All of steps 2a–2f are local. The only network calls the SDK makes are: (a) the initial metadata + JWKS fetch at startup, (b) periodic JWKS refresh in the background, (c) optional client.exchange(...) if you use token-exchange in your handler.
Extending the minimum
Common next steps and where to go for each:
- Vend upstream tokens (GitHub, Slack, Google) from a tool → Token Vault guide — uses
client.exchange()with a broker resource. - Federate login to your IdP → OIDC Federation guide.
- Enterprise-managed agent identity (skip per-user consent) → Enterprise-Managed Auth.
- Custom fetch settings, private CAs, SSRF policy — see the per-SDK page for your language.
- Structured logging, Prometheus metrics, OTEL traces → Monitoring guide.
Troubleshooting
401 invalid_tokenon the first request — your token was issued for a differentaudthan your resource URI. Check that the client sentresource=http://localhost:8080/mcpon the/oauth/authorizeand/oauth/tokencalls (RFC 8707 resource indicator). AuthPlane bindsaudfrom that parameter.403 insufficient_scopeon a tool that hasrequire_scope— the token’sscopeclaim doesn’t contain the required value. Check what the client requested and what AuthPlane granted (admin UI → Issuances).401 invalid_dpop_proofeven though the client sent a DPoP header — Python only: you forgotinstall_request_context(mcp). Any language: thehtuin the proof doesn’t match the request URL. Reverse proxies that rewrite Host or scheme are the usual cause; see Enable DPoP.- Background task not stopping cleanly on shutdown — you didn’t
await auth.aclose()(Python) /defer adapter.Close()(Go) /await auth.client.close()(TS).
More at Common errors and Debugging checklist.
Related
- Quickstart — the snippet this page walks through
- Choose your topology — decide the deployment shape
- Concepts: Grants & flows — auth-code, refresh, client-credentials, token-exchange, jwt-bearer
- SDK reference — Python · TypeScript · Go