What this is. A client-first, problem-driven path through the Cat Shop server. Instead of building the finished server bottom-up, you start with the smallest thing that works — one tool and a local client you write yourself — and add one capability per layer, each because you hit a wall the previous layer couldn't pass. Every layer ends with a command you run to validate it, and a diagram that grows as the system does.
Two editions, pick your style. This is the companion to
08_MCP_LEARNING_JOURNEY.md(the Build-From-Scratch edition, which reconstructs every server file top-to-bottom). They teach the same code from opposite directions:
Build-From-Scratch edition Layered edition (this doc) Direction bottom-up: db → oauth → tools → runtop-down: tiny loop → DB → auth → publicDriving question "how is each file built?" "why does each layer exist?" You write every server file a toy server + your own client; then adopt app/Best if you like seeing the whole machine feeling each limit before you fix it Teaching boundary (both editions). The cat-shop infrastructure is given scaffolding — shown and wired freely. The graded work stays yours: Q1/Q2 (in
README.md) and your own new tool (Activity 1). Those layers give you questions and a method, not a solution.
| Layer | The wall you hit | What you add | Validate by |
|---|---|---|---|
| 0 | (start) | a 1-tool server + a local client | client prints pong |
| 1 | hardcoded data is fake | a database | client lists 8 real products |
| 2 | "whose cart is this?" | OAuth (identity) | 401 without a token, success with one |
| 3 | tokens can't live forever | expiry · refresh · revoke | delete a token → watch it 401 |
| 4 | localhost is unreachable | ngrok (cloud forwarder) | hit your public URL from anywhere |
| 5 | bespoke clients don't scale | real MCP clients | Inspector / Claude Desktop connect |
| 6 | the shop is missing features | your tool (Activity 1) | invoke it through a client |
| 7 | can you explain it? | answers (Q1/Q2) | written in README.md |
Each layer follows the same rhythm: The wall → The picture → Build it → Validate it → Understand.
flowchart LR
AC["AI client<br/>Inspector · Claude Desktop · your code"]
NG["ngrok :443<br/>(Layer 4)"]
OA["OAuth routes<br/>/register /authorize /login /token<br/>(Layer 2)"]
MCP["FastMCP 'Cat Shop'<br/>Streamable HTTP (Layer 0)"]
T["@mcp.tool() functions<br/>(Layers 0–1, your tool in 6)"]
DB[("catshop.db<br/>products · carts · tokens<br/>(Layer 1)")]
AC -->|public HTTPS| NG --> OA
AC -->|Bearer token| NG --> MCP
MCP --> T
T -->|"who is this token?"| OA
T --> DB
OA --> DB
You'll build this diagram one box at a time. Right now it looks like a lot; by Layer 4 you'll have placed every box yourself and know what breaks if you remove it.
You've never seen an MCP call happen. Before auth, databases, or tunnels, prove the irreducible core: a client calls a typed function on a server, by name, over the network.
flowchart LR
C["client.py<br/>ClientSession"] -->|"Streamable HTTP · localhost"| S["FastMCP 'Tiny Shop'<br/>@mcp.tool(): ping, list_products"]
Two boxes. No auth, no DB. That's the entire MCP idea: the box on the left invokes a tool in the box on the right.
# tiny_server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Tiny Shop") # <-- NO auth, NO provider: the open, minimal form
@mcp.tool()
def ping() -> str:
"""Health check — returns pong."""
return "pong"
@mcp.tool()
def list_products() -> list[dict]:
"""The catalog (hardcoded for now — no database yet)."""
return [{"id": 1, "name": "Whisker Wand", "price": 9.99}]
if __name__ == "__main__":
mcp.settings.host = "127.0.0.1"
mcp.settings.port = 8611
mcp.run(transport="streamable-http")# client.py — the client you'll grow through the whole journey
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
MCP_URL = "http://localhost:8611/mcp"
async def main():
async with streamablehttp_client(MCP_URL) as (read, write, _): # NO auth header yet
async with ClientSession(read, write) as session:
await session.initialize() # MCP handshake
tools = await session.list_tools()
print("tools:", [t.name for t in tools.tools])
print("ping ->", (await session.call_tool("ping", {})).content[0].text)
print("list_products ->", (await session.call_tool("list_products", {})).content[0].text)
asyncio.run(main())uv run python tiny_server.py # terminal 1
uv run python client.py # terminal 2Verified output:
tools: ['ping', 'list_products']
ping -> pong
list_products -> {"id": 1, "name": "Whisker Wand", "price": 9.99}
The client sent no token and the call went through — because the server declared no auth. Remember this: MCP itself does not require OAuth. Auth is a layer you'll add on purpose (Layer 2), not a built-in tax.
You called
list_productswithout ever writing its URL or its parameter list — only its name. Where did the client learn that the tool exists and what it returns? (Look atlist_tools().)
list_products lies — it returns one hardcoded dict. A real shop reads a source of truth.
flowchart LR
C["client.py"] -->|Streamable HTTP| S["FastMCP server<br/>tools"]
S -->|aiosqlite| DB[("toyshop.db<br/>products")]
A third box appears: the database the tools read.
You don't need to invent the schema — reuse the provided seed. Point your tool at SQLite:
# tiny_server.py (Layer 1 changes)
import aiosqlite
from app.db import init_db # provided: creates tables + seeds 8 products
_db = None
async def get_db():
global _db
if _db is None:
_db = await aiosqlite.connect("toyshop.db")
await init_db(_db) # idempotent: safe every call
return _db
@mcp.tool()
async def list_products() -> list[dict]: # now async — it awaits the DB
"""Browse the catalog from the database."""
db = await get_db()
rows = await (await db.execute(
"SELECT id, name, price, category FROM products")).fetchall()
return [{"id": r[0], "name": r[1], "price": r[2], "category": r[3]} for r in rows]uv run python tiny_server.py # restart
uv run python client.py
# tools: ['ping', 'list_products']
# list_products -> [ … 8 products: Whisker Wand, Catnip Mouse, … Scratching Post Tower … ]Inspect the data the tool now trusts:
sqlite3 toyshop.db "SELECT name, price, category FROM products;" # → 8 rowsYour tool went from
deftoasync def. Why must a tool that talks toaiosqlitebeasync— and what does the@mcp.tool()decorator do differently with it? (You already saw bothping(sync) and this one (async) register fine.)
Add a cart and the toy breaks. add_to_cart must store items for a specific person — but your
server has no idea who is calling. Every client looks identical. You need per-caller identity,
and you do not want to handle anyone's password. That is exactly the problem OAuth solves: the
client presents a scoped, expiring token, and the server maps that token to a user.
At this layer your toy graduates: rebuilding the full OAuth machinery by hand is the other
edition's job. Here you adopt the provided app/ server (which already wires OAuth) and spend
the layer (a) understanding that wiring and (b) upgrading your client to authenticate.
flowchart LR
C["your client<br/>(now must send a Bearer token)"]
O["OAuth routes<br/>/register /authorize /login /token"]
S["FastMCP 'Cat Shop'<br/>tools (now token-gated)"]
DB[("catshop.db<br/>products · carts · tokens")]
C -->|"1 · earn a token"| O
C -->|"2 · Bearer token + tool call"| S
O --> DB
S -->|"get_username_for_token"| O
S --> DB
sequenceDiagram
actor U as You (human)
participant C as Your client code
participant O as Cat Shop · OAuth routes
participant M as Cat Shop · /mcp tools
participant DB as catshop.db
Note over C,O: OAuth half — earn a token
C->>O: POST /register (Dynamic Client Registration)
O-->>C: client_id (public client, no secret)
C->>O: GET /authorize (PKCE code_challenge)
O->>DB: store pending_authorization
O-->>C: 302 to /login?req=…
U->>O: open /login, submit username
O->>DB: mint authorization_code (+ username)
O-->>U: 302 to redirect_uri?code=…
C->>O: POST /token (code + code_verifier)
O->>DB: store access_token + token_users
O-->>C: access_token (Bearer, 1h)
Note over C,M: MCP half — unchanged from Layer 0, plus one header
C->>M: initialize (Authorization Bearer …)
M->>DB: load_access_token → valid?
M-->>C: session ready, tool calls flow
# Run the PROVIDED server (it adds OAuth on top of everything you built in Layers 0–1):
PORT=8123 uv run server.pyThe auth lives in three provided files — read them now that you know why they exist:
app/server.py (the AuthSettings/valid_scopes wiring), app/oauth.py (the provider that mints
and validates tokens), app/routes.py (the /login page that turns a human into an auth code).
Then upgrade your client. The Layer-0 client still works for discovery, but tool calls now need a token. Two ways to get one:
# client.py (Layer 2) — borrow a live token (the simplest upgrade)
import sqlite3, time
def newest_token() -> str:
db = sqlite3.connect("catshop.db")
row = db.execute("SELECT token FROM access_tokens WHERE expires_at > ? "
"ORDER BY expires_at DESC LIMIT 1", (time.time(),)).fetchone()
if not row:
raise SystemExit("Log in once via the Inspector first (Layer 5), then rerun.")
return row[0]
# ...then add the header to the transport:
# headers = {"Authorization": f"Bearer {newest_token()}"}
# async with streamablehttp_client(MCP_URL, headers=headers) as (read, write, _):Borrowing a token is a learning shortcut. Earning one in code (the full handshake above) is the optional Advanced Activity — and notice the payoff: your four
session.…lines from Layer 0 never change. Only how you obtainheadersdoes. Auth and tool-calling stay cleanly separate.
# No token → the server refuses (THIS is the wall, now enforced):
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8123/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# → 401 (with WWW-Authenticate: Bearer … pointing at the OAuth metadata)Contrast that 401 with Layer 0's open call. Same transport, same tools — the only new thing is the auth plane standing in front of them. With a token (your upgraded client), the cart tools work and are scoped to the username you logged in as.
In Layer 0 an identical request returned
200; here it returns401. The transport didn't change — so what, exactly, is sending the401, and which line inapp/server.pyswitched it on?
A token you minted is a liability if it lasts forever. Real systems make tokens expire, allow a refresh so users aren't kicked out hourly, and allow revoke to kill one on demand.
stateDiagram-v2
[*] --> Issued: exchange_authorization_code
Issued --> Active: client sends it as Bearer
Active --> Active: tool call (load_access_token OK)
Active --> Refreshed: exchange_refresh_token
Refreshed --> Active: brand-new access token
Active --> Expired: 1 hour elapses
Active --> Revoked: revoke_token
Expired --> [*]: deleted on next use
Revoked --> [*]: row removed
Nothing to write — the provided app/oauth.py already implements every transition
(exchange_authorization_code, exchange_refresh_token, revoke_token, and the
expiry-eviction inside load_access_token). This layer is about seeing them fire.
# Pull a live token, then KILL it and watch the next call fail:
TOKEN=$(sqlite3 catshop.db "SELECT token FROM access_tokens ORDER BY expires_at DESC LIMIT 1;")
sqlite3 catshop.db "DELETE FROM access_tokens WHERE token = '$TOKEN';"
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8123/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# → 401 (no row → load_access_token returns None → rejected)That manual DELETE simulates exactly what expiry does automatically after an hour.
load_access_token(inapp/oauth.py) does two jobs on every single call: it validates and it evicts. Trace theif expires_at and time.time() > expires_at:branch — why delete the row right there instead of waiting for a cleanup job?
Everything so far is localhost. A teammate's machine, or Claude Desktop running elsewhere, cannot
reach your laptop's localhost. Streamable HTTP is remote-capable — you just need a public
address that forwards to your local port. That's ngrok: a tunnel from a public HTTPS URL to your
running server.
flowchart LR
RC["remote client<br/>Claude Desktop · teammate"]
NG["ngrok<br/>https://abcd.ngrok-free.app"]
S["your server<br/>localhost:8123"]
RC -->|"HTTPS · port 443"| NG
NG -->|"forwards to localhost:8123"| S
# 1. Tunnel a public URL to your LOCAL port (must equal the server's PORT):
ngrok http 8123
# Forwarding https://abcd.ngrok-free.app -> http://localhost:8123
# 2. Restart the server advertising that public URL as its identity:
PORT=8123 ISSUER_URL=https://abcd.ngrok-free.app uv run server.pyThe one rule that ties it together: three things must agree on the same local port —
ngrok http <port>, the server'sPORT, and the right-hand side of ngrok'sForwardingline.ISSUER_URLis the public identity (no:port— the public side is always 443). The full alignment, plus the two failure modes (Errno 98and502 Bad Gateway), is in the STUDENT cheatsheet §3a.
# Hit your server through the public URL — from anywhere:
curl -s https://abcd.ngrok-free.app/.well-known/oauth-authorization-server
# → {"issuer": "https://abcd.ngrok-free.app/", "authorization_endpoint": ".../authorize", ...}If issuer echoes your ngrok URL, the public plane is live and OAuth redirects will line up.
ISSUER_URLmust equal the ngrok URL, butPORTmust equal the local port. Explain in one sentence why settingISSUER_URLalone (withoutPORT) still fails to start when8000is busy — i.e., whatISSUER_URLdoes and does not control.
You hand-wrote a client to learn. But the point of a protocol is that you shouldn't have to — any MCP client should connect to your server with zero bespoke glue.
flowchart LR
I["MCP Inspector"] --> S
CD["Claude Desktop"] --> S
YC["your client.py"] --> S
S["Cat Shop /mcp<br/>(local or via ngrok)"]
Three different clients, one unchanged server. That interchangeability is MCP's value.
npx @modelcontextprotocol/inspector
# Transport: Streamable HTTP URL: http://localhost:8123/mcp (or your ngrok URL + /mcp)
# Connect → browser opens YOUR /login page → pick a username → token minted
# List Tools → call list_products → add_to_cart → view_cart → checkoutThis is also how you produce the token your Layer-2 client borrows: completing the Inspector login
writes a row into access_tokens.
The Inspector connects with the same
http://localhost:8123/mcpURL your own client uses, and runs the same OAuth handshake from Layer 2's diagram. So what did writing your own client teach you that using the Inspector alone would have hidden?
The shop is missing something — search? quantity edits? order history? You now understand the tool contract from both sides (you've built tools and called them), so you can extend it.
This is the graded deliverable, and the journey stops handing you code here. Add at least one new
@mcp.tool() to app/tools.py, beyond the six provided. Hold it against the existing tools and ask:
- Does it carry
@mcp.tool()and a genuinely new name (not a renamedlist_products)? - Are its arguments typed like
get_product(product_id: int)— would the model know what to pass from your signature + docstring alone? - Does it touch the DB via
await oauth_provider._get_db()+ a realexecute/commit? - If it reads or changes the cart, does it resolve the caller with
_get_username()first? - Can you invoke it through a client (Layer 5) and watch the result?
Decide which family yours joins — catalog-style (identity-free, like
list_products) or cart-style (calls_get_username()first, likeadd_to_cart) — and write it in that style. Demo it in your Loom.
You didn't read about these mechanisms — you layered them in and watched them work. Now write the
answers in README.md (don't leave the _(insert your answer here)_ placeholder — that grades as not
answered).
Q1 — Why is OAuth important for MCP servers, and what security considerations apply?
Layer 2 is your evidence: the server never saw a password, only a scoped, expiring Bearer token it
validates by lookup (Layer 3) and can revoke. Name the mechanism and one concrete consideration —
scope minimization (valid_scopes), token expiry/refresh, or what a stolen token can and can't do.
Q2 — What is Streamable HTTP transport, and why expose publicly with OAuth vs. local stdio?
Layers 0 and 4 are your evidence: the same HTTP transport served a local client and a remote one over
ngrok, gated by OAuth (the Layer-2 401). Contrast with stdio (a local subprocess pipe — no network, no
auth). Where must the client be for each? What does OAuth add that the transport alone cannot?
Your Layer 0–1 toy was a stepping stone. Here's how each toy piece maps to the production server you adopted at Layer 2:
| Your toy (Layers 0–1) | The provided server | The layer that added it |
|---|---|---|
FastMCP("Tiny Shop"), no auth |
app/server.py (FastMCP + AuthSettings) |
0, then 2 |
@mcp.tool() ping/list_products |
app/tools.py (6 tools + _get_username) |
0–1 |
init_db on toyshop.db |
app/db.py → catshop.db |
1 |
| (none — open access) | app/oauth.py + app/routes.py |
2 |
client.py (no header) |
your client + Inspector / Claude Desktop | 0 → 2 → 5 |
localhost only |
ngrok public URL | 4 |
You finished the journey when you can point at any box in the destination diagram and say (1) which layer added it, (2) what wall it solved, and (3) what breaks if you delete it.
- Advanced Activity (optional): turn your Layer-2 client's
newest_token()shortcut into the real thing — implement the OAuth handshake in code (register → authorize → login → token), keep the browse → add → checkout flow, and compare the developer experience to hand-rolled REST. - Companion editions:
08_MCP_LEARNING_JOURNEY.md(build every file bottom-up) ·08_MCP_CHEATSHEET_STUDENT.md(concept/API map + setup & troubleshooting). - Reference: MCP docs https://modelcontextprotocol.io/ · MCP auth deep-dive https://auth0.com/blog/mcp-specs-update-all-about-auth/