learn-claude-with-phoebe / Deep dive 6.6
Learn Claude with Phoebe · Deep-dive track 6.6

MCP: build servers and clients

Session 6 showed you MCP from the outside: connect a server, get tools. Today you build the other side. A working server in 25 lines, the inspector habit that saves hours, resources and prompts beyond plain tools, your own client, and the advanced layer - sampling, notifications, roots, and transports - that turns a demo server into something a team can run.

🔴 Deep dive DS & AI 45 min self-paced or live
0-5 · Setup 5-35 · Core 35-45 · Try it
Part 0

Why this page exists

Tool use (deep dive 6.3) taught you to define tools inside ONE application. MCP moves those tools into a standalone server that ANY client can connect to: Claude Code, Claude.ai, your own scripts, a teammate's agent. Write the warehouse integration once, and every AI surface in the company gets it. This page covers the full build-level MCP course plus the complete advanced topics course.

Core - build your first server and client Advanced - the protocol layer underneath ★ Runnable code Covers 2 official courses in full
★ What you walk out with A read-only warehouse server you built and inspected, a Python client that calls it programmatically, and a working mental model of sampling, notifications, roots, and the two transports - enough to design MCP as your team's governed data-access layer.
Part 1 · covers "introduction to MCP" at build level

Building servers 15 min

A server wraps a system you own and exposes three kinds of things: tools (functions the model calls), resources (context the client loads), and prompts (templates the user invokes).

CoreArchitecture recap: client, server, three primitives3 min

Two roles, one protocol:

  • The client is whatever hosts the model: Claude Code, the Claude apps, or your own program. It discovers what the server offers and brokers everything between the model and the server.
  • The server wraps your system - a warehouse, a catalog, a filesystem, an internal API - and exposes capabilities in a standard shape. It never talks to the model directly; it only answers the client.
  • Three primitives, by who initiates: tools are model-controlled (Claude decides to call them mid-task), resources are application-controlled (the client loads them as context, like attaching a file), prompts are user-controlled (a person picks a template, like a slash command).

Most teams ship tools first and forget the other two exist. That's a mistake we'll fix in this page: schemas and configs usually belong in resources, and your team's standard workflows belong in prompts.

Tool, resource, or prompt: who initiates Does the model decide to call it mid-task? yes no Tool model-controlled Does the client load it automatically? yes no Resource application-controlled Prompt user-controlled Most teams ship tools and forget resources and prompts exist at all.
🔍 Click to zoom - by who initiates: model calls tools, client loads resources, users pick prompts
CoreYour first server with FastMCP: a read-only warehouse5 min

The Python mcp package ships FastMCP: decorate typed functions and they become tools. The type hints become the JSON schema; the docstring becomes the description the model reads. You never hand-write schema JSON like you did in deep dive 6.3.

★ warehouse_server.py - a complete read-only serverfrom mcp.server.fastmcp import FastMCP import sqlite3 mcp = FastMCP("warehouse") DB = "analytics.db" # stand-in for your real warehouse connection def run_query(sql: str) -> list[tuple]: with sqlite3.connect(DB) as conn: return conn.execute(sql).fetchall() @mcp.tool() def list_tables() -> list[str]: """List every table available in the analytics warehouse.""" rows = run_query("SELECT name FROM sqlite_master WHERE type='table'") return [r[0] for r in rows] @mcp.tool() def row_count(table: str) -> int: """Return the row count for one table. Read-only.""" if table not in list_tables(): raise ValueError(f"Unknown table: {table}") return run_query(f"SELECT COUNT(*) FROM {table}")[0][0] @mcp.tool() def sample_rows(table: str, limit: int = 5) -> list[tuple]: """Return up to 20 sample rows from a table, for profiling.""" if table not in list_tables(): raise ValueError(f"Unknown table: {table}") return run_query(f"SELECT * FROM {table} LIMIT {min(limit, 20)}") if __name__ == "__main__": mcp.run() # STDIO transport by default
  • Notice the validation: table names are checked against an allowlist before touching SQL, and the sample size is capped. The model is a user - validate its input like any user's.
  • Docstrings are the interface. The model chooses tools by reading them, exactly like tool descriptions in 6.3. "Return the row count for one table. Read-only." beats "counts rows".
  • Connect it to Claude Code with claude mcp add warehouse -- python warehouse_server.py and your whole team's Claude can now profile the warehouse - through only these three doors.
CoreThe inspector: test before you connect anything3 min

Do not debug a server through a model. The inspector is a browser UI that connects to your server directly, so you can see and call everything yourself first.

★ Launch the inspector against your servernpx @modelcontextprotocol/inspector python warehouse_server.py
  • What you check, in order: do all three tools appear? Are the descriptions and schemas what you meant? Does row_count with a real table return the right number? Does it fail cleanly with a fake table name?
  • The habit: inspector first, client second, model last. When a model misuses your tool, you then know the server itself is fine and the fix is in the description - a two-line change, not an evening of guessing.
Error messages are prompts Whatever your tool raises goes back to the model as text. "Unknown table: ordersx. Call list_tables to see valid names." lets the model self-correct on the next turn. A bare stack trace doesn't.
CoreResources and prompts: the other two primitives4 min

Resources expose read-only context at a URI - things the client should be able to LOAD rather than have the model fetch through a tool call. Prompts are parameterised templates a user invokes deliberately.

★ Add a resource and a prompt to the warehouse server@mcp.resource("schema://warehouse/{table}") def table_schema(table: str) -> str: """Column names and types for one warehouse table.""" rows = run_query(f"PRAGMA table_info({table})") return "\n".join(f"{r[1]}: {r[2]}" for r in rows) @mcp.resource("config://warehouse/conventions") def conventions() -> str: """Our naming and metric conventions, as loadable context.""" return open("docs/warehouse_conventions.md").read() @mcp.prompt() def quality_check(table: str) -> str: """Run the team's standard data quality review on a table.""" return ( f"Run our standard quality review on {table}: " "1) row count vs last known count, 2) null rate per column, " "3) duplicate keys, 4) freshness of the latest timestamp. " "Report as a pass/warn/fail table." )
  • Resources suit schemas, configs, docs, catalog entries - stable reference material. The client attaches them as context up front, which costs no tool-call round trips and keeps the model from "exploring" for basics.
  • Prompts encode workflows. In Claude Code they surface as slash commands, so quality_check becomes the team standard instead of everyone improvising their own version - the SOP-to-skill idea from Session 3, one layer down.
CoreImplementing a client: calling servers from Python4 min

Claude Code IS a client, so when do you write your own? When MCP servers need to plug into YOUR pipeline: a nightly job that calls the warehouse server, a Slack bot, an eval harness. The client speaks the protocol; there's no model involved unless you add one.

★ client.py - connect, list, callimport asyncio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client params = StdioServerParameters( command="python", args=["warehouse_server.py"] ) async def main(): async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() print("tools:", [t.name for t in tools.tools]) result = await session.call_tool( "row_count", {"table": "orders"} ) print("orders row count:", result.content[0].text) asyncio.run(main())
  • The lifecycle: connect over a transport, initialize() (a capability handshake), then list and call. Same pattern for list_resources / read_resource and list_prompts / get_prompt.
  • Wiring in a model: feed list_tools() output to the Messages API as tool definitions, and route Claude's tool_use blocks to call_tool. That loop is deep dive 6.3's agent loop with MCP as the tool backend.
Real world

A data platform team wrapped their metric store in one MCP server. Claude Code users got it via config, the on-call Slack bot used a Python client, and the eval harness called the same tools directly with no model at all. One integration, three consumers, and when a metric definition changed they fixed it in exactly one place.

Part 2 · covers "MCP: advanced topics" in full

The advanced layer 12 min

Everything above works for a local demo. Sampling, notifications, roots, and transports are what the advanced course adds - and they are what production servers actually use.

AdvancedSampling: the server borrows the client's model4 min

Sampling inverts the usual direction: the SERVER asks the CLIENT to run a model call on its behalf. The server gets intelligence without owning an API key, choosing a model, or paying the bill - all of that stays with the client, which can also show the user what's being requested and let them approve it.

What sampling adds to a plain server no API key owned client picks model user can approve SERVER + SAMPLING asks the client to run a model call on its behalf SERVER, TOOLS ONLY needs its own API key, model choice, and bill = borrowed intelligence The server gets intelligence without ever holding a key, choosing a model, or paying the bill.
🔍 Click to zoom - sampling borrows the client's model instead of owning one
★ A tool that samples the client's modelfrom mcp.server.fastmcp import Context from mcp.types import SamplingMessage, TextContent @mcp.tool() async def describe_table(table: str, ctx: Context) -> str: """Generate a plain-English description of a table for the catalog.""" schema = table_schema(table) rows = sample_rows(table, limit=5) result = await ctx.session.create_message( messages=[SamplingMessage( role="user", content=TextContent( type="text", text=f"Schema:\n{schema}\n\nSample rows:\n{rows}\n\n" "Write a 2-sentence catalog description of this table.", ), )], max_tokens=300, ) return result.content.text
  • The flow: client calls the tool, the tool sends a create_message request BACK up the wire, the client (optionally with user approval) runs the model, and the completion returns to the tool, which finishes its work.
  • The use case that sells it: a catalog server that auto-describes tables. Shipped as sampling, it works for every team that connects it, whatever model or key they use - the server stays a dumb, cheap process.
  • Design note: sampling calls are per-request and reviewable by the client. That's the governance posture you want: the server can ask for intelligence but never owns it.
AdvancedNotifications and roots3 min

Two smaller capabilities that make long-running and file-touching servers behave like good citizens.

★ Log and progress notifications in a slow tool@mcp.tool() async def profile_all_tables(ctx: Context) -> dict: """Profile every warehouse table. Slow: emits progress as it goes.""" tables = list_tables() report = {} for i, t in enumerate(tables): await ctx.info(f"Profiling {t}...") # log notification report[t] = {"rows": row_count(t)} await ctx.report_progress(i + 1, len(tables)) # progress bar return report
  • Notifications are one-way messages, no reply expected. Log notifications (ctx.info, ctx.warning) surface what a tool is doing; progress notifications drive the client's progress display. Without them a 3-minute tool is indistinguishable from a hung one, and users kill it at 40 seconds.
  • Roots flow the other way: the client tells the server which directories it may operate in (as file:// URIs). A filesystem server calls session.list_roots() and confines itself to those paths. It's client-granted scope: Claude Code working in your project grants the project directory, not your home folder.
AdvancedTransports: STDIO vs StreamableHTTP, and the wire underneath4 min

Everything in MCP is JSON-RPC 2.0 messages: requests (id, expects a response), responses (result or error for an id), and notifications (no id, no reply). The transport is just how those messages travel.

  • STDIO: the client launches the server as a subprocess and speaks over stdin/stdout. Zero network setup, one client per server process, dies with the client. The default and the right answer for local tools.
  • StreamableHTTP: the server is a real web service on an HTTP endpoint. Multiple clients, sessions via an Mcp-Session-Id header, server-to-client streaming over SSE, and resumability - a dropped connection can resume its event stream instead of losing state mid-task.
  • State lives with the session. Once a server is remote and multi-client, per-session state has to be keyed by session id, and horizontal scaling needs sessions pinned or shared - the same discipline as any stateful web service. Getting to one line of code, though, is easy: mcp.run(transport="streamable-http").
STDIOStreamableHTTP
Runs asSubprocess of the clientStandalone web service
ClientsOne, localMany, remote, session ids
Setup costNoneHosting, TLS, auth
ResumabilityNot needed (local pipe)Yes, via SSE event replay
Use whenPersonal and dev tools, anything on your machineTeam-shared servers, hosted integrations
AdvancedProduction notes: governance lives at this layer3 min
  • Auth on remote servers is non-negotiable. A StreamableHTTP server is an API to your systems; put OAuth or token auth in front of it and scope tokens per team. STDIO dodges this only because it never leaves the machine.
  • Least-privilege tools. Expose row_count and sample_rows, not run_sql. The tool surface IS the permission model: the model can only do what a tool allows, so design tools like you'd design IAM policies.
  • Version your servers. Tool names and schemas are a contract with every connected client. Additive changes are safe; renames and signature changes break agents silently, so version and announce like any API.
  • The sys-admin story from Session 2, resolved: "how do we let Claude touch the warehouse safely?" The answer is this layer. One audited server, read-only tools, client-granted roots, logged calls - governance enforced in code, not in a policy doc nobody reads.
Try it yourself

Three exercises ◐ 35-45 min

Source material

Official courses covered

This page teaches the MCP module shared by the three engineering courses at full build level, plus the complete standalone advanced course.

Introduction to MCPfull build level: architecture, clients, defining tools/resources/prompts, server inspector, building a client
MCP: Advanced Topics1.1 hr · all modules: sampling, notifications, roots, JSON-RPC message types, STDIO and StreamableHTTP transports, state and resumability

Deep dive 6.6 cheat sheet · pin this

Three primitivesTools = model-controlled · resources = client-loaded context · prompts = user-invoked templates. Ship all three, not just tools.
FastMCP@mcp.tool() on a typed, docstringed function. Type hints become the schema; the docstring is what the model reads. Validate inputs like user input.
Inspector firstnpx @modelcontextprotocol/inspector python server.py. Inspector, then client, then model - never debug a server through a model.
Samplingctx.session.create_message sends a model request UP to the client. Server gets intelligence; keys, cost, and approval stay client-side.
TransportsSTDIO for local subprocess tools; StreamableHTTP for remote multi-client servers with session ids and SSE resumability. All JSON-RPC underneath.
Governance layerLeast-privilege tools, auth on anything remote, client-granted roots, versioned schemas. The tool surface is the permission model.