labs | 05 | scoped auth
lab 05 | ~10 min | masterclass

Serve the breadcrumb, then gate every tool behind a scope.

Your server is discoverable now, which means strangers can reach it. A server with no auth (no sign-in check) is an open database. You are not going to build a sign-in system from scratch. You are going to do the two things that actually matter. One: tell each caller where to go get a token (a token is just a string that proves who you are). Two: refuse any call whose token is missing the right scope (a scope is one permission attached to a token, like read-only). Serve a small file that points a caller at the place that hands out tokens. Return a 401 (the HTTP code for "you are not signed in") that names that place. Then check the scope before any tool runs. This is the step everyone skips in development and pays for in production.

the two jobs, and the one you must not build

Auth splits into two halves. Discovery: tell a brand-new caller where to get a token. That is a small metadata file plus a 401 that points at it, and it is yours to ship. The file format is set by RFC 9728 (the published rule for where a server tells callers to get a token). Enforcement: refuse calls that present the wrong token, no token, or a token missing the scope. That is yours too. The part in the middle, handing out the tokens and checking they are real, is the job you give to an identity provider, or IdP (an outside service that owns sign-in, like Clerk, Auth0, or your own Cloudflare workers-oauth-provider). Build the breadcrumb and the gate by hand so you understand them. Do not build the token-issuing part by hand for real use.

step 1

Serve the protected-resource metadata.

This is the small file the rule requires, at exactly /.well-known/oauth-protected-resource (metadata just means data about your service). It is plain JSON, a simple text format of keys and values. It names your service and lists the authorization servers a caller should go to for a token (an authorization server is the thing that hands out tokens). Hang it directly off the FastMCP server you already built, using @mcp.custom_route, so the breadcrumb lives next to the main endpoint on one port. Save as scoped_server.py.

scoped_server.py
from fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse

mcp = FastMCP("scoped-notes")

BASE = "http://127.0.0.1:8000"
RESOURCE = BASE + "/mcp/"
# In prod this is a real IdP issuer URL (Clerk, Auth0, your own). Not built here.
AUTH_SERVER = "https://your-idp.example.com"
METADATA_URL = BASE + "/.well-known/oauth-protected-resource"

@mcp.custom_route("/.well-known/oauth-protected-resource", methods=["GET"])
async def protected_resource(request: Request) -> JSONResponse:
    """RFC 9728 metadata. Public by design: anyone may read where to authenticate."""
    body = {
        "resource": RESOURCE,
        "authorization_servers": [AUTH_SERVER],
        "bearer_methods_supported": ["header"],
        "scopes_supported": ["notes:read", "notes:write", "admin:notes"],
    }
    # CORS so a browser-side agent can read this cross-origin.
    return JSONResponse(body, headers={"Access-Control-Allow-Origin": "*"})

if __name__ == "__main__":
    mcp.run(transport="http", host="127.0.0.1", port=8000)

The scopes_supported list is the promise you are about to enforce in step 3. A caller reads this file to learn two things: which server hands out tokens, and which scopes those tokens can carry.

step 2

The 401 handshake. Point the client at the metadata.

When a tool is called with no token or a bad one, you do not just say no. (The token rides in as a "bearer token", which means whoever bears it gets in, so you send it over a secure connection and never log it.) You return 401 with a WWW-Authenticate header that names the metadata file (a header is an extra line of info attached to an HTTP response). That header is the breadcrumb a caller follows when it hits the dead end. Add this guard plus a tiny demo endpoint to the same file, above if __name__.

add to scoped_server.py
CHALLENGE = 'Bearer resource_metadata="' + METADATA_URL + '"'

@mcp.custom_route("/whoami", methods=["GET"])
async def whoami(request: Request) -> JSONResponse:
    """Demo guard: no/invalid bearer -> 401 that POINTS at the metadata."""
    auth = request.headers.get("authorization", "")
    if not auth.startswith("Bearer "):
        return JSONResponse(
            {"error": "unauthorized", "detail": "present a bearer token"},
            status_code=401,
            headers={"WWW-Authenticate": CHALLENGE,
                     "Access-Control-Allow-Origin": "*"},
        )
    return JSONResponse({"ok": True, "detail": "token present (validated in step 3)"})

Start the server (terminal A). Then call the guarded endpoint with no token and read the headers. The -i flag tells curl to print the headers, not just the body. The command differs by platform, so here is each.

terminal A (leave running)
python scoped_server.py
terminal B | macOS / Linux
curl -i http://127.0.0.1:8000/whoami
terminal B | Windows PowerShell
curl.exe -i http://127.0.0.1:8000/whoami
what you'll see (headers + body)
HTTP/1.1 401 Unauthorized
content-type: application/json
www-authenticate: Bearer resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource"

{"error": "unauthorized", "detail": "present a bearer token"}

A caller that has never seen your server can now start from a single dead end. It reads the header, follows it to the metadata, learns which server hands out tokens, and goes to get one there. Note what you did not do: you did not hand out the token yourself. You pointed at a real IdP and let it own that part.

step 3

Scope-gate the tools.

A valid token is not automatically a yes. Open the token to read its claims (the facts packed inside it, like who it belongs to and which scopes it carries). Read the scope list. Before any tool runs, check that the scope it needs is in that list. Map each tool to one named scope. Then refuse with 403 (the HTTP code for "you are signed in, but not allowed to do this") when the token falls short. The way we open the token here is just for show. A real server checks the token's signature with the IdP's public key first, to be sure it is genuine and untampered, but the gate logic is the same. Add this to the same file.

add to scoped_server.py
import base64
import json
from fastmcp.exceptions import ToolError

# tool -> the single scope a caller must hold to run it.
TOOL_SCOPES = {
    "read_notes": "notes:read",
    "add_note": "notes:write",
    "admin_purge": "admin:notes",
}

# Illustrative bearer store. In prod the IdP issues these; you VERIFY the
# signature with its public key and read scopes from the verified claims.
TOKENS = {
    "tok_reader": {"sub": "ray", "scope": "notes:read"},
    "tok_writer": {"sub": "mich", "scope": "notes:read notes:write"},
}

def claims_for(token: str) -> dict:
    """Return the token's claims. Demo: dict lookup. Real: decode + verify JWT."""
    if token in TOKENS:
        return TOKENS[token]
    # Shape a JWT would take: header.payload.signature, base64url middle segment.
    try:
        payload = token.split(".")[1]
        payload += "=" * (-len(payload) % 4)            # restore base64 padding
        return json.loads(base64.urlsafe_b64decode(payload))
    except Exception:
        return {}

def require_scope(token: str, tool_name: str) -> dict:
    """Gate: return claims if the token carries the tool's scope, else 403."""
    needed = TOOL_SCOPES[tool_name]
    claims = claims_for(token)
    held = claims.get("scope", "").split()
    if needed not in held:
        raise ToolError(
            "403 forbidden: tool '" + tool_name + "' requires scope '"
            + needed + "'; token scopes are " + (str(held) or "[]")
        )
    return claims

@mcp.tool
def read_notes(token: str, user_id: str) -> list:
    """Read a user's notes. Requires scope notes:read."""
    require_scope(token, "read_notes")
    return ["(notes for " + user_id + ")"]

@mcp.tool
def add_note(token: str, user_id: str, note: str) -> str:
    """Append a note for a user. Requires scope notes:write."""
    require_scope(token, "add_note")
    return "stored note for " + user_id

@mcp.tool
def admin_purge(token: str, user_id: str) -> str:
    """Delete all notes for a user. Requires scope admin:notes."""
    require_scope(token, "admin_purge")
    return "purged all notes for " + user_id

Here is the map made plain. This table is the whole security model on one screen. A token carrying only notes:read can reach exactly one row.

toolrequired scopekind
read_notesnotes:readread
add_notenotes:writewrite
admin_purgeadmin:noteswrite

Now prove the boundary holds. Call a write tool with the read-only token, and watch it refuse. We talk to the server over stdio here (plain text in and out through the terminal, no network), which is the quickest way to poke at it.

terminal B | macOS / Linux
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add_note","arguments":{"token":"tok_reader","user_id":"ray","note":"hi"}}}' \
  | python scoped_server.py
terminal B | Windows PowerShell
@(
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
  '{"jsonrpc":"2.0","method":"notifications/initialized"}'
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add_note","arguments":{"token":"tok_reader","user_id":"ray","note":"hi"}}}'
) | python scoped_server.py

The tok_reader token carries only notes:read. It hits the add_note gate, which requires notes:write. The call comes back as an error, not a write.

what you'll see in the id-2 reply (the write never runs)
"error":{
  "code": -32602,
  "message": "403 forbidden: tool 'add_note' requires scope 'notes:write'; token scopes are ['notes:read']"
}

Swap the token to tok_writer (it holds notes:read notes:write) and the same call succeeds. Nothing about the user changed. The token changed. That is the entire point.

the bearer in your CI log is a master key, unless it is scopedIC rings, in production

A leaked token with no scope is a breach. It can do everything the server can do, so the damage covers the whole surface. A scoped token is a boundary. A token carrying only notes:read literally cannot call add_note, no matter who holds it or where it leaks to. The gate checks the token, not the person holding it. So the worst a leaked read token can do is read, and you decided that in advance when you set its scope.

We run this in production on Immersive Commons, where every agent token is pinned to a ring (a tier of access) and every tool is gated by a scope:

public ft-member ic-member ai-floor operator

Two hard rules that cost real incidents to learn:

1. Scopes are frozen at mint. (Minting a token means creating and signing a new one.) Upgrading a user's tier does NOT widen a token you already gave them. The old token keeps its old scopes until it expires. To grant more, you mint a fresh token. A token is a frozen set of permissions, not a live pointer to the user's current rank.

2. Do not merge tier scopes at the gate. The gate checks the scope in the token, never the user's current tier. The instant you reason "well, this user is an operator now, so let it through," you have built a confused deputy. That is the classic bug where a trusted middleman is tricked into using its own authority on someone else's behalf: the token said one thing, you honored another. Set the scope floor so the worst a leaked token can do is bounded, and let the gate trust only what the token literally carries.

your agent, at the gate token scope is notes:read, add_note requires notes:write -> refusing, requesting re-consent.
hand this to your coding agent

You have the shape of it. Now have your agent put the same two halves on the real server you are building, and confirm the boundary with its own eyes.

prompt | paste into your coding agent
Add RFC 9728 protected-resource metadata to <server> and a 401 +
WWW-Authenticate handshake that points clients at my IdP. Then gate
each @mcp.tool behind a named scope, decode the bearer, and reject
any call whose token is missing the scope with a 403. Give me the
tool-to-scope map as a table and confirm that a notes:read token
cannot reach any write tool.

checkpoint

Your server tells signed-out callers exactly where to get a token, and refuses any call whose token lacks the scope. The metadata is the breadcrumb, the 401 points at it, and the scope gate runs before every tool. Auth is now a boundary, not a hope. Next: give the agent a memory that survives, then learn to poison it on purpose and catch the bad write.