Skip to content

Instantly share code, notes, and snippets.

@donbr
Created June 26, 2026 03:10
Show Gist options
  • Select an option

  • Save donbr/138c42ccd75be4e45eb4486db9a9e5dd to your computer and use it in GitHub Desktop.

Select an option

Save donbr/138c42ccd75be4e45eb4486db9a9e5dd to your computer and use it in GitHub Desktop.
Session 8 — MCP Learning Journey (Layered Edition)

Session 8 — MCP Learning Journey (Layered Edition): From One Tool to a Public OAuth Server

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 → run top-down: tiny loop → DB → auth → public
Driving 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.


Map of the layers

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.

The destination (so you know where the layers lead)

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
Loading

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.


Layer 0 — The smallest loop that works

The wall

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.

The picture

flowchart LR
    C["client.py<br/>ClientSession"] -->|"Streamable HTTP · localhost"| S["FastMCP 'Tiny Shop'<br/>@mcp.tool(): ping, list_products"]
Loading

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.

Build it

# 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())

Validate it

uv run python tiny_server.py        # terminal 1
uv run python client.py             # terminal 2

Verified 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.

Understand

You called list_products without 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 at list_tools().)


Layer 1 — Real data: add a database

The wall

list_products lies — it returns one hardcoded dict. A real shop reads a source of truth.

The picture

flowchart LR
    C["client.py"] -->|Streamable HTTP| S["FastMCP server<br/>tools"]
    S -->|aiosqlite| DB[("toyshop.db<br/>products")]
Loading

A third box appears: the database the tools read.

Build it

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]

Validate it

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 rows

Understand

Your tool went from def to async def. Why must a tool that talks to aiosqlite be async — and what does the @mcp.tool() decorator do differently with it? (You already saw both ping (sync) and this one (async) register fine.)


Layer 2 — "Whose cart is this?": add OAuth

The wall

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.

The picture — architecture grows a whole new plane

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
Loading

The picture — how a token is earned (the handshake)

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
Loading

Build it — switch to the real server, see the gate appear

# Run the PROVIDED server (it adds OAuth on top of everything you built in Layers 0–1):
PORT=8123 uv run server.py

The 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 obtain headers does. Auth and tool-calling stay cleanly separate.

Validate it — prove the gate is real

# 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.

Understand

In Layer 0 an identical request returned 200; here it returns 401. The transport didn't change — so what, exactly, is sending the 401, and which line in app/server.py switched it on?


Layer 3 — Tokens don't live forever

The wall

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.

The picture — a token's life

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
Loading

Build it

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.

Validate it

# 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.

Understand

load_access_token (in app/oauth.py) does two jobs on every single call: it validates and it evicts. Trace the if expires_at and time.time() > expires_at: branch — why delete the row right there instead of waiting for a cleanup job?


Layer 4 — Escape localhost: ngrok, the cloud forwarder

The wall

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.

The picture — three nodes, one local port

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
Loading

Build it

# 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.py

The one rule that ties it together: three things must agree on the same local portngrok http <port>, the server's PORT, and the right-hand side of ngrok's Forwarding line. ISSUER_URL is the public identity (no :port — the public side is always 443). The full alignment, plus the two failure modes (Errno 98 and 502 Bad Gateway), is in the STUDENT cheatsheet §3a.

Validate it

# 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.

Understand

ISSUER_URL must equal the ngrok URL, but PORT must equal the local port. Explain in one sentence why setting ISSUER_URL alone (without PORT) still fails to start when 8000 is busy — i.e., what ISSUER_URL does and does not control.


Layer 5 — Any compliant client now fits

The wall

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.

The picture

flowchart LR
    I["MCP Inspector"] --> S
    CD["Claude Desktop"] --> S
    YC["your client.py"] --> S
    S["Cat Shop /mcp<br/>(local or via ngrok)"]
Loading

Three different clients, one unchanged server. That interchangeability is MCP's value.

Build it / Validate it

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 → checkout

This is also how you produce the token your Layer-2 client borrows: completing the Inspector login writes a row into access_tokens.

Understand

The Inspector connects with the same http://localhost:8123/mcp URL 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?


Layer 6 — Add a capability: your own tool (Activity 1 — graded)

The wall

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 renamed list_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 real execute/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, like add_to_cart) — and write it in that style. Demo it in your Loom.


Layer 7 — Explain it: the questions (Q1/Q2 — graded)

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?


What you assembled — toy ↔ real

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.pycatshop.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.


Where to go next

  • 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/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment