Resource servers & PRM

TL;DR — In OAuth 2.1 language, your MCP server is a resource server. AuthPlane is the authorization server. The MCP client is the client. RFC 9728 Protected Resource Metadata (PRM) is how your resource server tells clients “here’s where to authenticate, here’s what scopes I know about, here’s the audience to bind tokens to.” Every AuthPlane SDK publishes PRM for you; understanding what it contains + how discovery works is essential for debugging.

The three OAuth roles in MCP

sequenceDiagram
    participant Client as MCP Client<br/>(Claude, Cursor, ...)
    participant RS as Resource Server<br/>(Your MCP server)
    participant AS as Authorization Server<br/>(AuthPlane)

    Client->>RS: 1. Where do I authenticate?
    RS-->>Client: PRM: authorization server = <AS URL>
    Client->>AS: 2. Auth flow
    AS-->>Client: access token
    Client->>RS: 3. Call with Bearer
    RS->>AS: 4. JWKS lookup (cached)
    AS-->>RS: JWKS
    RS-->>Client: response

Everything the client needs to know about how to authenticate lives in your PRM. Everything the client needs to actually authenticate lives in the AS metadata. The client discovers one from the other.

What PRM says

The PRM document served at /.well-known/oauth-protected-resource/<resource-path> on your MCP server (RFC 9728 §3 places the well-known suffix between the host and the resource’s path, so a resource at https://mcp.example.com/mcp publishes at https://mcp.example.com/.well-known/oauth-protected-resource/mcp). MCP clients try the path-scoped form first and only fall back to the root /.well-known/oauth-protected-resource when the path-scoped form 404s:

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": ["https://auth.example.com"],
  "scopes_supported": ["tools/read", "tools/write"],
  "bearer_methods_supported": ["header"]
}

Field by field:

  • resource — the URI clients must bind tokens to (aud claim). Must match the URL clients use to reach your MCP endpoint, byte-for-byte. See Configuration: Resources → URI matching.
  • authorization_servers — where clients go to obtain tokens. AuthPlane’s issuer URL.
  • scopes_supported — every scope your MCP server accepts. Clients use this to know what to request on /oauth/authorize.
  • bearer_methods_supported — OPTIONAL per RFC 9728 §2 (valid values are header|body|query). The SDKs default to ["header"] for MCP because the MCP spec mandates the Authorization header for token transmission and forbids the query form — publishing this field just makes that convention explicit.

What the SDKs publish for you

Every AuthPlane SDK builds and serves this document automatically:

  • Python authplane-mcp — auto-mounted via the middleware authplane_mcp_auth() returns.
  • Python authplane-fastmcp — auto-mounted via the oauth return field.
  • TypeScript @authplane/* — you mount with app.get(auth.protectedResourceMetadataPath, auth.protectedResourceMetadataHandler).
  • Go authplanemcp — you mount with http.Handle(adapter.WellKnownPRMPath(), adapter.ProtectedResourceMetadataHandler()).

The SDK reads resource, scopes, and the AS metadata to fill in every field. You don’t build the JSON yourself.

Discovery flow — how a client finds the AS

MCP 2025-11-25 makes the flow challenge-triggered: the client speaks first, gets a 401 with a WWW-Authenticate header carrying resource_metadata, and follows that. The well-known probe only happens as a fallback.

sequenceDiagram
    participant Client
    participant Resource as Resource Server
    participant AS

    Client->>Resource: 0. POST /mcp (no Authorization)
    Resource-->>Client: 401 WWW-Authenticate: Bearer realm="…",<br/>resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
    Client->>Resource: 1. GET <resource_metadata URL from step 0><br/>(falls back to /.well-known/oauth-protected-resource/<resource-path>,<br/>then to /.well-known/oauth-protected-resource, if step 0 omits the URL)
    Resource-->>Client: 2. PRM {authorization_servers: [<AS URL>], ...}
    Client->>AS: 3. GET <AS URL>/.well-known/oauth-authorization-server
    AS-->>Client: 4. AS metadata
    Client->>AS: 5. CIMD / DCR / pre-registered → authorize → token

Clients MUST use the resource_metadata URL from the WWW-Authenticate challenge when it’s present (RFC 9728 §5.1). Clients cache both the PRM and the AS metadata; TTL is configurable per SDK, defaulting to minutes to hours.

Failure modes on this path:

  • No WWW-Authenticate on the initial 401 → the SDK didn’t wire the challenge helper. Every AuthPlane SDK adds this automatically; if you’re getting bare 401s, verify you’re using the auth middleware rather than open-coding the token check.
  • PRM 404 → the SDK didn’t mount the PRM handler (Go only — Python/TS auto-mount). Or the client only tried the root form and the SDK only publishes the path-scoped form; both should be handled.
  • PRM present but authorization_servers empty → the client has nowhere to authenticate. Fix: pass the correct issuer to your adapter.
  • AS metadata 404 → the URL in authorization_servers is wrong. Fix: check your adapter’s issuer config.

Audience binding — the resource parameter (RFC 8707)

resource in the PRM is the value clients echo back on /oauth/authorize and /oauth/token as the resource parameter (RFC 8707 Resource Indicator). AuthPlane binds the token’s aud claim to this value. Your SDK verifies aud on every request.

Why it matters: a token issued for MCP-A cannot be replayed against MCP-B, because MCP-B’s SDK rejects any token whose aud doesn’t match its own resource URI. This is the audience-binding guarantee that makes multi-resource topologies safe.

The catch: the resource string must be identical everywhere:

  1. In your PRM document,
  2. In AuthPlane’s resource registration (admin resource create --uri ...),
  3. In what the client sends on /oauth/authorize,
  4. In the aud claim on the token.

A one-character mismatch → every token rejected. See Configuration → Resources for the trap in detail.

scopes_supported — what makes clients ask for the right scopes

scopes_supported in your PRM tells the client what scopes your server accepts. Well-behaved clients read this and only ask for scopes on this list — reduces invalid_scope errors at /oauth/authorize.

At the AuthPlane SDK level, scopes_supported is only populated when you pass enforce_scopes_on_all_requests=true (Python) / requiredScopes (TS) / Scopes (Go). Without one of these, PRM advertises an empty scopes array, and clients fall back to the AS’s global scopes_supported.

PRM lives at your MCP server, NOT at AuthPlane

/.well-known/oauth-protected-resource is on YOUR server (port 8080 typically). It’s how your server identifies itself to clients.

/.well-known/oauth-authorization-server (and /.well-known/openid-configuration) is on AUTHPLANE (:9000). It’s how AuthPlane identifies itself.

Every resource server serves its own PRM. If you have multiple MCPs on the same host, each mounts its own PRM at its own path prefix (e.g., mcp-a.example.com/.well-known/oauth-protected-resource and mcp-b.example.com/.well-known/oauth-protected-resource).