labs | 04 | oauth
lab 04 | ~10 min | segment 4

The handshake you skip in dev and pay for in prod.

An open MCP server is fine on localhost. The instant it has a public URL, anyone can call your tools. MCP does not invent its own auth. It rides OAuth 2.0, and the one piece you must ship is Protected Resource Metadata: a small JSON file at a fixed well-known path (RFC 9728) plus a 401 that points a client at it. That is the whole discovery half of the handshake, and tonight we build the smallest version that actually works, with zero extra dependencies.

why auth, in one paragraph

The risk is the confused deputy. Your server holds real credentials to a database or a paid API. If it executes any caller's tool requests without checking who is asking, an attacker borrows your server's authority to do things they could never do directly. OAuth fixes this by forcing every caller to present a token that says who they are and what they are allowed to do. RFC 9728 is the part that tells a brand-new client where to even get such a token.

step 1

Serve the Protected Resource Metadata.

This is the file the spec requires, at exactly /.well-known/oauth-protected-resource. It is plain JSON. It names your resource and lists the authorization servers a client should go to for a token. We use the Python standard library so this runs with no install at all. Save as oauth_stub.py.

oauth_stub.py
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

# In prod these are real URLs. For the lab they just have to be well-formed.
BASE = "http://127.0.0.1:8000"
# The canonical resource identifier carries the trailing slash, matching the
# path a real FastMCP server mounts the MCP endpoint at.
RESOURCE = BASE + "/mcp/"
AUTH_SERVER = "https://auth.example.com"
METADATA_URL = BASE + "/.well-known/oauth-protected-resource"

# RFC 9728 Protected Resource Metadata. The minimum useful set of fields.
PRM = {
    "resource": RESOURCE,
    "authorization_servers": [AUTH_SERVER],
    "bearer_methods_supported": ["header"],
    "scopes_supported": ["notes:read", "notes:write"],
}

class Handler(BaseHTTPRequestHandler):
    def _send(self, code, body, extra_headers=None):
        payload = json.dumps(body).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        # CORS so a browser-side agent can read these cross-origin.
        self.send_header("Access-Control-Allow-Origin", "*")
        for k, v in (extra_headers or {}).items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(payload)

    def do_GET(self):
        if self.path == "/.well-known/oauth-protected-resource":
            # Anyone may read the metadata. This is public by design.
            self._send(200, PRM)
            return
        # Accept both /mcp and /mcp/ so the demo works regardless of slash.
        if self.path in ("/mcp", "/mcp/"):
            # The resource itself. No token -> 401 that POINTS at the metadata.
            self._send(
                401,
                {"error": "unauthorized", "detail": "present a bearer token"},
                {"WWW-Authenticate": 'Bearer resource_metadata="' + METADATA_URL + '"'},
            )
            return
        self._send(404, {"error": "not_found"})

    def log_message(self, fmt, *args):
        print("[oauth-stub] " + (fmt % args))

if __name__ == "__main__":
    print("listening on http://127.0.0.1:8000")
    print("try:   GET /mcp   and   GET /.well-known/oauth-protected-resource")
    HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()
step 2

Boot it and read the metadata.

Run the stub, then fetch the well-known file. This is what any compliant client reads to learn where your tokens come from.

terminal A (leave running)
python oauth_stub.py

Fetch the metadata (terminal B):

terminal B
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource
what you'll see (HTTP 200, with an access-control-allow-origin header)
{"resource": "http://127.0.0.1:8000/mcp/",
 "authorization_servers": ["https://auth.example.com"],
 "bearer_methods_supported": ["header"],
 "scopes_supported": ["notes:read", "notes:write"]}
step 3

Hit the resource with no token. Read the 401.

Now request the protected resource itself, without a token, and watch the -i flag print the response headers. The WWW-Authenticate header is the breadcrumb: it tells the client exactly where the metadata lives.

terminal B
curl -i http://127.0.0.1:8000/mcp
what you'll see (headers + body)
HTTP/1.0 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"}

That is the entire RFC 9728 discovery contract: a 401 that names where to find the metadata, and metadata that names the authorization server. A client that has never seen your server before can now bootstrap from a single dead end.

step 4

Walk the full handshake (what happens next).

You built the discovery half. Here is where it sits in the complete flow, so you know what the authorization server and client do with what you just served.

1client -> resource Calls /mcp with no token. Gets your 401 + WWW-Authenticate.
2client -> metadata Follows the pointer, reads /.well-known/oauth-protected-resource, learns the authorization server.
3client -> auth server Runs the OAuth 2.1 flow there (PKCE, user consent) and gets back an access token.
4client -> resource Retries /mcp with Authorization: Bearer <token>. Your server validates it and serves the tool call.

You own rungs 1 and 2: discovery. Rungs 3 and 4 (issuing tokens, validating them) are the authorization server's job, and that is exactly the part you should not hand-build.

step 5

Put the same route on your real FastMCP server.

The stdlib stub made the wire shape obvious with zero dependencies. In practice you do not run a second server: you hang the same well-known route directly off the FastMCP server you already built, with @mcp.custom_route. This is the form the tonight's handout repo ships, so your lab and your handout agree.

on your FastMCP server
from fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse

mcp = FastMCP("oauth-stub")

@mcp.tool
def whoami() -> str:
    """Return the caller identity. In a real server, read it from the verified token."""
    return "anonymous (this stub does not verify tokens)"

@mcp.custom_route("/.well-known/oauth-protected-resource", methods=["GET"])
async def protected_resource(request: Request) -> JSONResponse:
    """Serve the RFC 9728 metadata next to the MCP endpoint itself."""
    body = {
        "resource": "http://127.0.0.1:8000/mcp/",
        "authorization_servers": ["https://auth.example.com"],
        "bearer_methods_supported": ["header"],
        "scopes_supported": ["mcp.read", "mcp.write"],
    }
    return JSONResponse(body, headers={"Access-Control-Allow-Origin": "*"})

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

Now /.well-known/oauth-protected-resource and your MCP endpoint live on one server, one port. The discovery contract is identical to the stub you ran; only the host framework changed.

do not roll your own (the number-one production fail)

Issuing and validating tokens correctly is where teams ship vulnerabilities. The agent-readiness pitfalls catalog ranks hand-rolled MCP auth as the top production failure. In real life you wire the discovery file you just built to a real authorization server. Cloudflare's workers-oauth-provider handles the OAuth 2.1 flow, token issuance, and validation for you, and their Agents Week recipe pairs it with one isolated session per tenant. FastMCP accepts an auth provider so your server validates the bearer token without you writing crypto. Build the well-known file by hand to understand it. Do not build the token endpoint by hand to ship it.

the CORS reminder, again

If a browser-based agent does this discovery, your /.well-known/oauth-protected-resource and /mcp both need CORS headers or the preflight fails and the whole handshake dies silently. Same trap as Lab 02, new surface. Allow the origin, the methods, and the auth-related headers.

you did it

Four stops. You wrote a six-line MCP server, pivoted it to production transport, put it on the public internet, wired three of your own functions with schemas a small model can read, and stood up the OAuth discovery handshake. Your repo is an MCP server, and you are in the top one percent of the agentic web that actually ships one. Now go wire your real functions in. Keep the code ASCII, keep stdio out of prod, and let a managed provider hold your tokens.