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.
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.
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.
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()
Run the stub, then fetch the well-known file. This is what any compliant client reads to learn where your tokens come from.
python oauth_stub.py
Fetch the metadata (terminal B):
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource
{"resource": "http://127.0.0.1:8000/mcp/",
"authorization_servers": ["https://auth.example.com"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["notes:read", "notes:write"]}
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.
curl -i http://127.0.0.1:8000/mcp
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.
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.
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.
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.
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.
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.
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.
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.