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:

  1. Discovers the authorization serverGET {issuer}/.well-known/oauth-authorization-server per RFC 8414. Result cached in memory with a periodic refresh (default 3600 s).
  2. Fetches the JWKSGET {issuer}/.well-known/jwks.json per RFC 7517. Cached with a shorter refresh (default 300 s) so key rotation is picked up quickly.
  3. Builds an AuthplaneResource — an in-memory object that knows this resource’s URI, its declared scopes, and its inbound DPoP policy.
  4. Wraps the resource in a token verifier — the object that MCP-framework authenticate callbacks call to verify each incoming JWT.
  5. 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 at http://host/mcp, that’s /.well-known/oauth-protected-resource/mcp), listing this resource’s URI, its authorization servers, and its supported scopes.
  6. Wraps the underlying OAuth client so that any client.exchange(...) call that raises ConsentRequiredError gets translated to MCP JSON-RPC -32042 UrlElicitationRequiredError — the wire format that prompts the MCP client to open a consent URL.
  7. Wires an on-shutdown hook — the aclose() / Close() you call in finally / defer releases 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 with 403 insufficient_scope before 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/read and another require tools/write.

Use both together (as the Quickstart does): request-layer sets the baseline; per-tool enforces the fine-grained rules.

server.py python
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}"

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:

  1. The AS must issue DPoP-bound tokens. In the Quickstart you set AUTHPLANE_DPOP_ENABLED=true. Any client that presents a DPoP header on /oauth/token gets a token with a cnf.jkt claim binding it to that client’s key.
  2. 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_supported on /.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 toolToken Vault guide — uses client.exchange() with a broker resource.
  • Federate login to your IdPOIDC 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 tracesMonitoring guide.

Troubleshooting

  • 401 invalid_token on the first request — your token was issued for a different aud than your resource URI. Check that the client sent resource=http://localhost:8080/mcp on the /oauth/authorize and /oauth/token calls (RFC 8707 resource indicator). AuthPlane binds aud from that parameter.
  • 403 insufficient_scope on a tool that has require_scope — the token’s scope claim doesn’t contain the required value. Check what the client requested and what AuthPlane granted (admin UI → Issuances).
  • 401 invalid_dpop_proof even though the client sent a DPoP header — Python only: you forgot install_request_context(mcp). Any language: the htu in 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.