MCP Authorization spec deep dive

Protected resource metadata, dynamic client registration, and the exact handshake an MCP client must perform.

🔌 Module 4 9 min read Not started

Cross-ref (Microsoft stack): In the Microsoft platform, MCP servers are onboarded through the Agent 365 SDK tool catalogue rather than raw MCP OAuth discovery. Admins publish approved MCP servers to the tenant; agents built with the SDK receive them via ToolCatalog.for_agent() and cannot reach unregistered servers. Auth to each server flows through the Entra Auth SDK sidecar. See Lesson 10.8 for the details.

Why this matters

The Model Context Protocol (MCP, Anthropic, late 2024 → 2025+) is now the dominant standard for connecting LLMs to tools. Its Authorization spec — published 2025-03-26 and revised since — turns MCP servers into proper OAuth 2.1 protected resources. Every agent product shipping in 2026 either implements MCP or fights it. Getting MCP auth right is table stakes.

Learning objectives

  1. Read and apply the MCP Authorization spec.
  2. Use OAuth 2.1 + Protected Resource Metadata (RFC 9728) for MCP servers.
  3. Implement Dynamic Client Registration so MCP clients self-onboard.
  4. Discover the right authorization server from an MCP server.
  5. Build a minimum-viable MCP client that handles all of the above.

1. What MCP gives you

MCP defines a JSON-RPC protocol between an MCP client (typically your LLM agent runtime) and an MCP server (which exposes tools, resources, and prompts). Transports: stdio (local), HTTP (remote, streaming), WebSocket.

Without authorization the spec was incomplete — anyone with the URL could invoke any tool. The MCP Authorization spec layers OAuth 2.1 on top so MCP servers can require authenticated, authorized callers.

Roles

  • Resource Server: the MCP server (e.g. https://github-mcp.acme.com).
  • Authorization Server: an IdP that issues access tokens (e.g. Keycloak, Auth0, GitHub OAuth).
  • Client: the MCP client (your agent) acting on behalf of the user (or itself).
  • Resource Owner: the user.

This is the standard OAuth picture. The MCP-specific contribution is how the client discovers the AS for a given RS and how it registers itself.


2. The discovery dance

A user pastes https://github-mcp.acme.com/mcp into their agent client. The client needs to figure out:

  1. Where do I authenticate?
  2. What scopes do I need?
  3. How do I register if I'm not already a known client?

The spec answers via two well-known documents.

Step A — Protected Resource Metadata (RFC 9728)

Client fetches:

GET https://github-mcp.acme.com/.well-known/oauth-protected-resource

Returns:

json
{
  "resource": "https://github-mcp.acme.com",
  "authorization_servers": ["https://auth.acme.com/realms/agents"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["repo:read","repo:write","gist"],
  "resource_documentation": "https://docs.acme.com/mcp/github"
}

This tells the client which authorization server(s) to use and what scopes the resource accepts.

Step B — Authorization Server Metadata (RFC 8414)

Client fetches:

GET https://auth.acme.com/realms/agents/.well-known/oauth-authorization-server

Returns endpoints (authorization_endpoint, token_endpoint, registration_endpoint, jwks_uri), supported grant types, supported PKCE methods, and (importantly) whether Dynamic Client Registration is supported.

Step C — Dynamic Client Registration (RFC 7591)

If the client isn't registered, it POSTs to registration_endpoint:

json
{
  "client_name": "Claude Desktop",
  "redirect_uris": ["mcp://localhost/cb"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code","refresh_token"],
  "response_types": ["code"],
  "scope": "repo:read"
}

Receives client_id (+ optional client_secret + registration access token).

This auto-onboarding is the MCP innovation — without it, every MCP server would need every client whitelisted in advance, which is impossible at marketplace scale.


3. The auth flow end-to-end

1. User adds the MCP server URL to the client.
2. Client GET /.well-known/oauth-protected-resource → finds AS.
3. Client GET AS metadata → finds endpoints + DCR support.
4. Client POST registration_endpoint → gets client_id.
5. Client triggers Authorization Code + PKCE flow.
6. User approves consent in browser.
7. Client exchanges code → access_token (audience = MCP resource).
8. Client opens MCP session, sends each JSON-RPC request with
   "Authorization: Bearer <access_token>".
9. MCP server validates the token (signature, iss, aud, exp, scope).
10. On scope mismatch: 401 with WWW-Authenticate header pointing back to AS.

The 401 response carries hints so the client knows exactly which scope is needed:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="github-mcp",
  error="insufficient_scope",
  scope="repo:write",
  resource_metadata="https://github-mcp.acme.com/.well-known/oauth-protected-resource"

The client re-runs the auth flow with the additional scope. This scope-elevation pattern is the MCP equivalent of "step-up" in classical apps.


4. Minimum-viable MCP server with OAuth (Python)

Using the mcp SDK + FastAPI for HTTP transport.

python
# server.py
import jwt, requests
from jwt import PyJWKClient
from fastapi import FastAPI, Request, HTTPException
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import StreamableHttpServerTransport

ISSUER = "https://auth.acme.com/realms/agents"
RESOURCE = "https://github-mcp.acme.com"
REQUIRED_SCOPE = "repo:read"

JWKS = PyJWKClient(requests.get(f"{ISSUER}/.well-known/openid-configuration").json()["jwks_uri"])

mcp = FastMCP("github")

@mcp.tool()
def list_repos(user: str) -> list[str]:
    """List GitHub repos for a user."""
    return [...]

app = FastAPI()

@app.get("/.well-known/oauth-protected-resource")
def prm():
    return {
        "resource": RESOURCE,
        "authorization_servers": [ISSUER],
        "scopes_supported": ["repo:read","repo:write","gist"],
        "bearer_methods_supported": ["header"],
    }

def require_token(request: Request) -> dict:
    auth = request.headers.get("authorization", "")
    if not auth.startswith("Bearer "):
        raise HTTPException(401, headers={"WWW-Authenticate": f'Bearer realm="github-mcp", resource_metadata="{RESOURCE}/.well-known/oauth-protected-resource"'})
    token = auth.removeprefix("Bearer ").strip()
    try:
        claims = jwt.decode(
            token, JWKS.get_signing_key_from_jwt(token).key,
            algorithms=["RS256","ES256"],
            audience=RESOURCE, issuer=ISSUER,
            options={"require":["exp","iat","sub","iss","aud"]},
        )
    except jwt.PyJWTError:
        raise HTTPException(401)
    if REQUIRED_SCOPE not in claims.get("scope","").split():
        raise HTTPException(403, headers={"WWW-Authenticate": f'Bearer error="insufficient_scope", scope="{REQUIRED_SCOPE}"'})
    return claims

transport = StreamableHttpServerTransport(path="/mcp")

@app.api_route("/mcp", methods=["GET","POST","DELETE"])
async def mcp_handler(request: Request):
    claims = require_token(request)         # authz happens here
    request.state.user = claims["sub"]
    request.state.actor = claims.get("act",{}).get("sub")
    return await transport.handle_request(request, mcp)

A real implementation should also:

  • Cache JWKS with periodic refresh.
  • Enforce per-tool scopes (each @mcp.tool may need its own scope claim check).
  • Log (sub, act.sub, tool, args_hash, decision) to your audit trail (lesson 7).

5. Minimum-viable MCP client with OAuth

python
# client.py
import requests, secrets, hashlib, base64, urllib.parse, webbrowser
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

SERVER = "https://github-mcp.acme.com/mcp"

# 1. Discover
prm = requests.get(f"{SERVER.rsplit('/',1)[0]}/.well-known/oauth-protected-resource").json()
asm = requests.get(f"{prm['authorization_servers'][0]}/.well-known/oauth-authorization-server").json()

# 2. Register
reg = requests.post(asm["registration_endpoint"], json={
    "client_name": "demo-agent",
    "redirect_uris": ["http://localhost:5173/cb"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code","refresh_token"],
    "scope": " ".join(prm["scopes_supported"]),
}).json()
client_id = reg["client_id"]

# 3. PKCE flow
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
auth_url = asm["authorization_endpoint"] + "?" + urllib.parse.urlencode({
    "client_id": client_id, "redirect_uri": "http://localhost:5173/cb",
    "response_type": "code", "scope": "repo:read",
    "code_challenge": challenge, "code_challenge_method": "S256",
    "state": secrets.token_urlsafe(16),
})
webbrowser.open(auth_url)
# ...local server captures `code`...
tok = requests.post(asm["token_endpoint"], data={
    "grant_type":"authorization_code","code":code,
    "redirect_uri":"http://localhost:5173/cb","client_id":client_id,
    "code_verifier":verifier,
}).json()

# 4. MCP call
async def main():
    async with streamablehttp_client(SERVER, headers={"Authorization": f"Bearer {tok['access_token']}"}) as (r, w, _):
        async with ClientSession(r, w) as s:
            await s.initialize()
            print(await s.list_tools())

That's the entire MCP-with-OAuth conversation: discover, register, PKCE, call.


6. Scope design for MCP tools

A common mistake is one giant mcp scope. Aim for purpose-coupled scopes per tool family:

ServerScopes
github-mcprepo:read, repo:write, gist, org:admin
gmail-mcpmail:read, mail:send, mail:modify
db-mcpdb:read:tenant, db:write:tenant, db:admin

Per-tool, declare which scope it needs. Resource server checks at each call. Clients can request only what's needed for the current task (downscoping via Token Exchange — lesson 3.1).

Hosted MCP marketplaces (Anthropic Apps, OpenAI Apps SDK, etc.) typically standardise scope nomenclature for popular servers — follow their pattern when applicable.


7. Auth for stdio MCP servers

stdio servers run as a child process of the client; the trust boundary is the OS user, not the network. Auth is typically handled by the server holding pre-configured credentials (e.g. a GitHub PAT) and the client trusting the host.

When you do need OAuth in stdio mode (e.g., a server that calls a cloud API on behalf of the user), the server typically:

  1. Opens a local browser via OAuth Device Flow on first use.
  2. Persists tokens in the OS keychain (keyring, macOS Keychain, Windows DPAPI).
  3. Refreshes silently.

The MCP client doesn't see those creds — they're scoped to the MCP server process.

This is the model used by Claude Desktop's GitHub MCP, Linear MCP, etc.


8. Common MCP-auth bugs to avoid

  1. No audience check — MCP server accepts any token signed by your IdP. Cross-resource confused deputy.
  2. One global scopemcp:access grants every tool. No principle of least privilege.
  3. Forgetting WWW-Authenticate headers — clients can't do scope-elevation; users get cryptic errors.
  4. Trusting sub without act — losing the agent identity in the chain.
  5. Per-request DCR — registering a new client per request instead of per-install creates client-spam and audit pollution.
  6. No rate limit on /register — anyone can spam-create clients.

9. Hands-on lab (4 h)

  1. Build a tiny MCP server exposing two tools (list_repos, delete_repo); enforce scopes repo:read and repo:admin respectively.
  2. Add the oauth-protected-resource discovery doc.
  3. Configure Keycloak with repo:read and repo:admin scopes + DCR enabled.
  4. Build a Python client that performs full discovery → DCR → PKCE → MCP call.
  5. Call list_repos (should succeed). Call delete_repo (should fail with insufficient_scope).
  6. Re-run with elevated scope; verify success and that audit log captures (sub, act, tool, decision).
  7. Stretch: add DPoP (lesson 3.1) end-to-end.

10. Self-check

  1. The two .well-known documents an MCP client fetches and why each.
  2. Why DCR matters for MCP.
  3. What WWW-Authenticate: error="insufficient_scope" enables.
  4. Difference between stdio and HTTP MCP auth.
  5. Five mistakes from the bugs list.

11. References

  • Model Context Protocol Authorization specification (modelcontextprotocol.io/specification → Authorization).
  • RFC 9728 (Protected Resource Metadata).
  • RFC 8414 (Authorization Server Metadata).
  • RFC 7591 (Dynamic Client Registration).
  • RFC 6749 + RFC 6750 + OAuth 2.1 draft.
  • Anthropic engineering blog on MCP authorization.
  • WorkOS / Auth0 / Stytch blog posts on "OAuth for MCP".

Sign in to save your progress and earn badges.