A frame to help you reason through the assignment — the concepts, diagrams, and API map. It deliberately does not contain the answers or a filled-in tool. Instead it gives you the questions to ask yourself and the method to get there. The work — and the learning — is in reading the server code, running it, connecting a client, and writing your own conclusions.
Source repo:
08_MCP/— entry pointserver.py, server packageapp/(server.py,tools.py,oauth.py,routes.py,db.py). There is no notebook this session — Q1/Q2 are answered inREADME.md, and your code deliverable is a new@mcp.tool()inapp/tools.py. Corpus: a seeded SQLite "cat shop" catalog (app/db.py→catshop.db) — 8 demo products, a teaching fixture, not real inventory.
| You want to… | Reach for | One-liner |
|---|---|---|
| Stand up an MCP server | FastMCP |
mcp = FastMCP("Cat Shop", auth_server_provider=..., auth=AuthSettings(...)) (app/server.py) |
| Serve it over the network | Streamable HTTP transport | mcp.run(transport="streamable-http") (server.py); binds 0.0.0.0:8000 |
| Expose a function as a tool | @mcp.tool() |
@mcp.tool() on an async def with typed args + docstring (app/tools.py) |
| Read DB inside a tool | the provider's connection | db = await oauth_provider._get_db() → await db.execute(sql, params) → await db.commit() |
| Know who is calling | the auth context | token = get_access_token() → await oauth_provider.get_username_for_token(token.token) |
| Gate access with OAuth | OAuthAuthorizationServerProvider subclass |
CatShopOAuthProvider implements authorize / exchange_authorization_code / load_access_token … (app/oauth.py) |
| Add a non-tool web route | @mcp.custom_route |
@mcp.custom_route("/login", methods=["GET","POST"]) renders the login page (app/routes.py) |
| Make it publicly reachable | ngrok + ISSUER_URL |
ngrok http 8000 then ISSUER_URL=https://… uv run server.py |
| Drive it from an AI | an MCP client | Claude Desktop, MCP Inspector, or a custom mcp SDK client over Streamable HTTP |
| Seed / inspect data | aiosqlite |
app/db.py init_db() creates tables + seeds PRODUCTS; cart_items is per-username |
The one sentence that anchors everything: the tool is the contract — a typed, documented async function the AI client calls by name; OAuth decides who may call it, the transport decides how the call arrives, and the DB is the only source of truth the tool may trust.
An MCP server publishes tools (typed functions) that an AI client discovers and calls. Three concerns are deliberately separated: OAuth (identity/authorization), the Streamable HTTP transport (how messages travel), and the tools + DB (the actual capability). The assignment adds a fourth tool to the existing six without breaking that separation.
flowchart LR
AC["AI client<br/>Claude Desktop · MCP Inspector · custom mcp client"]
OA["CatShopOAuthProvider<br/>+ /login custom route"]
MCP["FastMCP 'Cat Shop'<br/>mcp.run(transport='streamable-http')"]
T["@mcp.tool() functions<br/>list_products … checkout (+ yours)"]
DB[("aiosqlite catshop.db<br/>products · cart_items · tokens")]
AC -->|"1 · OAuth: authorize → /login → code → token"| OA
AC -->|"2 · Streamable HTTP + Bearer token"| MCP
MCP --> T
T -->|"get_access_token() → username"| OA
T --> DB
OA --> DB
ASCII fallback:
AI client ──OAuth (authorize → /login → code → access token)──► CatShopOAuthProvider ──► catshop.db
│ ▲
└──Streamable HTTP + Bearer token──► FastMCP "Cat Shop" ──► @mcp.tool() ─────────────────┘
(get_access_token → username)
Why this shape? The transport carries opaque tool calls; it does not know about cats or carts. OAuth runs beside the tools, not inside them — a tool just asks "who is this token?" and trusts the answer. The DB is shared by both planes (tokens live in the same SQLite file as products). Decoupling these is exactly what Q1/Q2 ask you to reason about.
uv sync # Python 3.13; installs mcp[cli], aiosqlite, uvicorn, …
cp .env.example .env # set OPENAI_API_KEY (used by client-side / advanced build)
uv run server.py # serves http://localhost:8000 (Streamable HTTP)
# public exposure:
ngrok http 8000 # in a 2nd terminal → copy the https forwarding URL
ISSUER_URL=https://xxxx.ngrok-free.app uv run server.py # ISSUER_URL MUST match the public URL| Component | File | Role |
|---|---|---|
FastMCP("Cat Shop") |
app/server.py |
The MCP server object; wires OAuth + tools + transport |
mcp.run(transport="streamable-http") |
server.py |
Boots the HTTP transport on 0.0.0.0:8000 |
CatShopOAuthProvider |
app/oauth.py |
Full OAuth authorization-server (clients, codes, tokens, refresh, revoke) |
@mcp.tool() functions |
app/tools.py |
The 6 catalog/cart tools the client can call |
@mcp.custom_route("/login") |
app/routes.py |
The human login page that mints the auth code |
init_db() + PRODUCTS |
app/db.py |
Creates tables and seeds the catalog into catshop.db |
ISSUER_URL |
env | Public base URL; OAuth metadata + redirects must use it |
No
ANTHROPIC_API_KEYand no LLM call inside the server. The only key in.env.exampleisOPENAI_API_KEY(for the advanced custom-client / LangChain build). The server itself is pure tools + OAuth + SQLite.
MCP is a client↔server protocol for exposing tools (and resources/prompts) to AI clients in a standard shape, so any compliant client (Claude Desktop, Inspector, your own agent) can discover and call them without bespoke glue. The server declares capabilities; the client lists and invokes them. Docs: https://modelcontextprotocol.io/
FastMCP is the high-level server. You give it a name, an auth provider, AuthSettings, and a
host/port; decorators register tools and routes onto it.
mcp = FastMCP(
"Cat Shop",
auth_server_provider=oauth_provider,
auth=AuthSettings(issuer_url=ISSUER_URL, resource_server_url=ISSUER_URL, ...),
host="0.0.0.0", port=8000,
)A transport is how MCP messages move between client and server. Two common choices:
stdio: client ⇄ subprocess pipe (local only, no network, no auth layer)
Streamable HTTP: client ⇄ HTTP(S) stream (remote-capable, SSE/chunked, OAuth-gateable)
This server uses mcp.run(transport="streamable-http") — a long-lived HTTP connection that
streams tool events, so a remote client can reach it (via ngrok/OAuth) instead of spawning the
server locally. Reasoning about this trade-off is the crux of Q2.
The decorator turns an async function into a callable tool. The signature is the schema: type hints become the input schema; the docstring becomes the description the client shows the model.
@mcp.tool()
async def get_product(product_id: int) -> dict:
"""Get full details of a single product by its ID."""
db = await oauth_provider._get_db()
cursor = await db.execute("SELECT id, name, price FROM products WHERE id = ?", (product_id,))
row = await cursor.fetchone()
return {"id": row[0], "name": row[1], "price": row[2]} if row else {"error": "Not found"}Conventions every tool in this repo follows: typed args, a one-line docstring, await oauth_provider._get_db() for data, and a JSON-able dict/list return.
Cart tools need to know who is calling. The auth middleware exposes the bearer token; the
provider maps it to a username (helper _get_username() in tools.py):
token = get_access_token() # from auth_context middleware
username = await oauth_provider.get_username_for_token(token.token)Catalog tools (list_products, get_product) are identity-free; cart tools (add_to_cart,
view_cart, remove_from_cart, checkout) all resolve username first.
CatShopOAuthProvider subclasses OAuthAuthorizationServerProvider and persists everything in
SQLite. The flow:
client registers ─► /authorize ─► /login (pick username) ─► auth code
─► exchange code ─► access_token (+ refresh_token, Bearer, 1h expiry) ─► call tools
Key methods to recognize when reading the code: register_client, authorize,
exchange_authorization_code, load_access_token, exchange_refresh_token, revoke_token. The
client proves possession via the PKCE code_challenge; tokens carry scopes (read,
write). MCP auth background: https://auth0.com/blog/mcp-specs-update-all-about-auth/
One async SQLite file (catshop.db) holds both OAuth state (clients, codes, access/refresh
tokens, token_users) and shop state (products, cart_items, users). init_db() creates
the tables and seeds PRODUCTS if empty. cart_items is unique on (username, product_id), so
add_to_cart upserts with ON CONFLICT … DO UPDATE.
- Local/dev: point Claude Desktop or MCP Inspector at the Streamable HTTP endpoint, or
write a custom
mcpSDK client. - Public:
ngrok http 8000gives an HTTPS URL; restart withISSUER_URL=<that url>so OAuth metadata and redirects match. A mismatch is the #1 "auth mysteriously fails" cause (README note).
Write your answers in
README.mdunder each question's#### Answerheading. 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 when
exposing tools to AI clients? Ask yourself: what does the server actually receive from the
client — the user's password, or something else? Trace the flow in §4.6: at what point does a
token replace a credential, and what does that buy you? Then name the trade-offs you'd worry
about in production: how narrow should a token's power be (look at valid_scopes in
app/server.py)? What happens when a token is stolen — does expires_at in app/oauth.py help?
What must the server still verify about a token before trusting it? A strong answer states the
mechanism and at least one concrete consideration.
Q2 — What is Streamable HTTP transport, and why expose a server publicly with OAuth instead of a
local stdio connection? Re-read §4.3 and the run line in server.py. Ask: where does the client
have to be for stdio to work, versus Streamable HTTP? What can a remote client (Claude Desktop on
someone else's machine) do over one that it can't over the other? And once the server is reachable
on the public internet, what stops anyone from calling your tools — what role does OAuth play
that the transport alone does not? Contrast it with a plain REST API to sharpen the distinction.
Deliverable: add at least one new @mcp.tool() to app/tools.py, beyond the 6 stubs
already there (list_products, get_product, add_to_cart, view_cart, remove_from_cart,
checkout). Wire it to the database and demo it through an MCP client in your Loom.
Pick something the shop is missing — e.g. search_products, update_cart_quantity, or
get_order_history. Then check your own work against the existing tools as a template:
- Does your function carry the
@mcp.tool()decorator and a genuinely new name (not a renamed stub)? - Are the arguments typed the way
get_product(product_id: int)is? Would the model know what to pass from your signature + docstring alone? - Does it actually touch the DB via
await oauth_provider._get_db()and a realexecute/commit— not just return a hard-coded list? - If it reads or changes the cart, does it resolve the caller with
_get_username()first, like the other cart tools? - Can you invoke it live through a client (Claude Desktop / MCP Inspector / your own) and show the call + result on screen in the Loom?
Don't copy another tool's body wholesale — open
app/tools.py, read howlist_productsandadd_to_cartare shaped, and write yours in that style. The learning is in matching the contract, not in the SQL.
Build a custom mcp SDK client that authenticates over OAuth + Streamable HTTP and runs a
multi-step flow (browse → add to cart → checkout). As you build it, note where MCP made the
integration easier or harder than hand-rolling REST calls — that comparison is the point. Include
a demo in your Loom. Optional and unscored — skip it freely if you're short on time.
If you want to go deeper: MCP docs https://modelcontextprotocol.io/ · MCP-UI https://mcpui.dev/ · MCP auth deep-dive https://auth0.com/blog/mcp-specs-update-all-about-auth/.