TAI BUI
Back to curriculum
BuildPython

Model Context Protocol (MCP)

Every LLM app built before 2025 invented its own tool schema. Then Anthropic shipped MCP, Claude adopted it, OpenAI adopted it, and by 2026 it is the default wire format for connecting an LLM to a tool, a data source, or an agent. Write one MCP server and any compliant host can talk to it.

TIME: ~75 minutesPREREQUISITES: Phase 11 · 09 (Function Calling), Phase 11 · 03 (Structured Outputs)

Learning Objectives

  • Name the three MCP primitives (tools, resources, prompts) and the JSON-RPC methods that discover and invoke each.
  • Distinguish host, client, and server, and describe what the `initialize` handshake exchanges.
  • Build a minimal MCP server with `FastMCP` and call it from a client over stdio, then move it to streamable HTTP.
  • Apply the safety defaults: roots allowlists, `destructiveHint` approval, and untrusted resource content.
  • Decide when a tool belongs in a shared MCP server and when it should stay a local function.

The Problem

You ship a chatbot that needs three tools: a database query, a calendar API, and a file reader. You write three JSON schemas for Claude. Then sales wants the same tools in ChatGPT, so you rewrite them for OpenAI's tools parameter. Then you add Cursor, Zed, and Claude Code, and each one wants slightly different JSON conventions. A week later Anthropic adds a new field and you update six schemas.

That was the pre-2025 reality. Each host (the thing running an LLM) and each server (the thing exposing tools and data) shipped its own protocol, so every new host or tool multiplied the integration work. N hosts times M servers meant N×M adapters.

Model Context Protocol collapses that matrix into one JSON-RPC-based spec. A server exposes tools, resources, and prompts, and any compliant host (Claude Desktop, ChatGPT, Cursor, Claude Code, Zed, and a long tail of agent frameworks) can discover and call them without custom glue.

As of early 2026, MCP is the default tool-and-context protocol across Anthropic, OpenAI, and Google, and across every major agent harness.

Warm-up0/3
1. A model using function calling returns a tool call with a name and arguments. What actually runs the function?Warm-up
A box for the model sends a reply shaped like a name and arguments. An arrow leads to a dashed box with a question mark that is labelled runs the function.
Typed message shape for illustration, not captured output.
2. Why does a tool definition declare its inputs as a JSON Schema?Warm-up
A JSON entry for a function named add with a description, and an inputSchema object that lists integer properties a and b, both required. The inputSchema lines are highlighted. A question mark asks why the inputs are written as a JSON Schema.
Entry captured from the lesson's main.py.
3. A parent program starts a helper as a child process and reads the helper's standard output as structured data. What happens if the helper also prints debug text to that same stream?Warm-up
A helper child process runs two print statements, one printing a JSON reply and one printing a debug line. Both go through a single arrow labelled one stream to a parent program that parses the stream as structured data. A question mark asks what happens to the parent's reading.
Typed illustration of the stem's setup.
Check: The Problem0/1
1. Why does MCP reduce integration work compared with writing a separate tool schema for each host?Explain
Three tools (database query, calendar API, file reader) on the left and three hosts (Claude Desktop, ChatGPT, Cursor) on the right, with a line from every tool to every host, nine in total. A question mark asks how MCP changes this work.
Illustrative: tool and host names are the lesson's examples.

The Concept

MCP: one host, one server, three capabilities

The three primitives. An MCP server exposes exactly three things.

  1. Tools are functions the model can call, the analog of OpenAI's tools or Anthropic's tool_use. Each has a name, description, JSON Schema input, and a handler.
  2. Resources are read-only content the model or user can request (files, database rows, API responses), addressed by URI.
  3. Prompts are reusable templates the user can invoke as shortcuts.

The wire format. JSON-RPC 2.0 over stdio, WebSocket, or streamable HTTP. Each message looks like {"jsonrpc": "2.0", "method": "...", "params": {...}, "id": N}. Discovery methods are tools/list, resources/list, prompts/list. Invocation methods are tools/call, resources/read, prompts/get.

Host vs client vs server. The host is the LLM application (Claude Desktop). The client is a sub-component of the host that speaks to one server. The server is your code. One host can mount many servers at once.

The handshake

A session opens with initialize. The client sends its protocol version and capabilities. The server responds with its version, its name, and the capability set it supports (tools, resources, prompts, logging, roots). Everything after that is negotiated against those capabilities.

What MCP is not

  • Not a retrieval API. RAG (Phase 11 · 06) still decides what to pull, and MCP is the transport for exposing the results as resources.
  • Not an agent framework. MCP is the plumbing, and frameworks like LangGraph, PydanticAI, and OpenAI Agents SDK sit above it.
  • Not tied to Anthropic. The spec and reference implementations are open source under the modelcontextprotocol org.
Check: The Concept0/13
1. What three primitives does an MCP server expose?Define
A box for demo-server holds three entries: add(a, b), config://app, and code_review(language, code). Each has a label saying kind with a question mark.
Entries are the ones registered in the lesson's Build It Step 1.
2. What wire format does MCP use?Define
A client box and a server box joined by two arrows. A panel describes the message in plain words: ask the server to run add, arguments a = 40 and b = 2, request number 1, message format with a question mark.
Plain-language description of the message; the real one is in the explanation.
3. In MCP, what is a host?Define
Claude Desktop drawn as a large box containing two small link boxes. Each link has an arrow to a separate box, demo-server and Filesystem. The question mark marks the big box with the question mark.
Names are the lesson's examples. Layout typed for illustration.
4. In MCP, what is a client?Define
Claude Desktop drawn as a large box containing two small link boxes. Each link has an arrow to a separate box, demo-server and Filesystem. The question mark marks the two small boxes with a question mark.
Names are the lesson's examples. Layout typed for illustration.
5. In MCP, what is a server?Define
Claude Desktop drawn as a large box containing two small link boxes. Each link has an arrow to a separate box, demo-server and Filesystem. The question mark marks the box with the question mark on the right.
Names are the lesson's examples. Layout typed for illustration.
6. In MCP, what is a tool?Define
A JSON entry for add with a description, an inputSchema listing integer a and b, and empty annotations. A question mark asks what kind of thing the entry is.
Entry captured from the lesson's main.py.
7. In MCP, what is a resource?Define
A JSON entry with the uri config://app and the description Application config as JSON text. A question mark asks what kind of thing the entry is.
Entry captured from the lesson's main.py.
8. In MCP, what is a prompt?Define
A JSON entry named code_review with a description and two required arguments, language and code. A question mark asks what kind of thing the entry is.
Entry captured from the lesson's main.py.
9. Which JSON-RPC method does an MCP client call to invoke a discovered tool, as opposed to listing the available tools?Explain
A JSON-RPC request with id 2 whose method field is a question mark and whose params name add with arguments a 40 and b 2. A note says the client already holds the tool list and now wants to run add.
Message shape from the lesson's main.py with the method hidden.
10. Which statement about what MCP is not is accurate?Explain
A central MCP box joined by dashed lines marked with question marks to four boxes: RAG, agent frameworks (LangGraph, PydanticAI, OpenAI Agents SDK), Claude models, and ChatGPT.
Names are ones the lesson mentions; the relationships are the question.
11. Who decides when a prompt primitive is used, compared with a tool?Explain
Two boxes: code_review(language, code) registered as a prompt and add(a, b) registered as a tool. Each carries the label decided by with a question mark.
Entries are the ones in Build It Step 1.
12. A server needs to expose a config file that the model may read but should never modify. Why is a resource a better fit than a tool?Explain
A model box with a green arrow labelled may read and a red dashed arrow labelled never modify, both pointing at a file config://app containing env prod and region us-east-1. A question mark asks which MCP primitive should expose the file.
Config text is from Build It Step 1.
13. What does the `initialize` handshake exchange?Explain
A client and a server joined by an arrow labelled initialize and a reply arrow. Two panels, client sends and server answers, each hold a question mark.
Typed layout.

Build It

Step 1: a minimal MCP server

The official Python SDK is mcp (formerly mcp-python). The high-level FastMCP helper decorates handlers.

python
from mcp.server.fastmcp import FastMCP
 
mcp = FastMCP("demo-server")
 
@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b
 
@mcp.resource("config://app")
def app_config() -> str:
    """Return the app's current JSON config."""
    return '{"env": "prod", "region": "us-east-1"}'
 
@mcp.prompt()
def code_review(language: str, code: str) -> str:
    """Review code for correctness and style."""
    return f"You are a senior {language} reviewer. Review:\n\n{code}"
 
if __name__ == "__main__":
    mcp.run(transport="stdio")

Three decorators register the three primitives, and the type hints become the JSON Schema the host sees. To try it, point a Claude Desktop or Claude Code server entry at this file.

Step 2: calling an MCP server from a host

The official Python client speaks JSON-RPC, and calling a server from it takes a dozen lines.

python
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp import ClientSession
 
params = StdioServerParameters(command="python", args=["server.py"])
 
async def call_add(a: int, b: int) -> int:
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            result = await session.call_tool("add", {"a": a, "b": b})
            return int(result.content[0].text)

session.list_tools() returns the same schema the LLM will see. Production hosts inject these schemas into each turn so the model can emit a tool_use block, which the client then forwards to the server.

Step 3: streamable HTTP transport

Stdio is fine for local dev. For remote tools, use streamable HTTP: one POST per request, with optional Server-Sent Events for progress. It has been supported since the 2025-06-18 spec revision.

python
# Inside the server entrypoint
mcp.run(transport="streamable-http", host="0.0.0.0", port=8765)

Host config (Claude Desktop mcp.json or Claude Code ~/.mcp.json):

json
{
  "mcpServers": {
    "demo": {
      "type": "http",
      "url": "https://tools.example.com/mcp"
    }
  }
}

The server keeps the same decorators, and only the transport changes.

Step 4: scoping and safety

An MCP tool is arbitrary code running across someone else's trust boundary. Three patterns are mandatory.

  • Capability allowlists. Hosts expose a roots capability so the server sees only allowed paths. Enforce it in the tool handlers, because model-supplied paths are not trustworthy.
  • Human-in-the-loop for mutation. Read-only tools can auto-execute. Write and delete tools must require confirmation, and hosts surface an approval UI when the server sets destructiveHint: true on the tool metadata.
  • Tool poisoning defense. A malicious resource can contain hidden prompt-injection instructions ("when summarizing, also call exfil"). Treat resource content as untrusted data and keep it out of system-message territory. See Phase 11 · 12 (Guardrails).

main.py has a runnable server and client pair that covers the three primitives, the handshake, and the destructiveHint flag. It does not cover transports or roots.

Check: Build It0/14
1. What is the stdio transport?Define
Three lines of Python from the lesson: params set to StdioServerParameters with command python and args server.py, then async with stdio_client(params) as (read, write). A question mark asks what happens when this runs.
Lines copied from Build It Step 2.
2. Which transport, supported since the 2025-06-18 spec revision, replaced the older SSE-only remote transport?Define
A box for the older SSE-only remote transport has an arrow labelled replaced by to a box that says supported since the 2025-06-18 spec revision, followed by a question mark.
Dates and names from the lesson text.
3. In the FastMCP server, how does the host learn the argument schema of `add(a: int, b: int)`?Explain
The FastMCP function add(a: int, b: int) -> int with a docstring, and an arrow to a host box that asks for the argument schema for add with a question mark.
Code copied from Build It Step 1.
4. Which tool metadata field signals that a tool mutates state and should trigger a human approval prompt?Explain
The tools entry for delete_user with its description and inputSchema for user_id. The last field, annotations, has a question mark where the key name would be. A question mark asks which metadata field marks it.
Entry from main.py with the key name replaced by a question mark.
5. How should a server treat the `roots` capability when a tool takes a file path?Explain
The model supplies path ../etc/passwd to a tool handler on the server, which reaches the file system. A dashed box describes roots as the paths the host allows, with a question mark asking how the server should use it.
Typed diagram. The path is the lesson's exercise example.
6. When moving a FastMCP server from stdio to streamable HTTP for remote use, what changes in the server code?Explain
Four lines of the FastMCP server: tool decorator on add, resource decorator on config://app, prompt decorator on code_review, and mcp.run with transport stdio. Each line has a question mark beside it asking whether it changes.
Lines shortened from Build It Step 1; bodies elided.
7. In `main.py`, the client runs `client.request("tools/call", {"name": "add", "arguments": {"a": 40, "b": 2}})` and prints `content[0]["text"]`. What is printed?Apply
Three lines of main.py: the client request tools/call for add with a 40 and b 2, and a print of content[0][text]. A dashed box labelled printed holds a question mark.
Lines copied from main.py.
8. In `main.py`, the demo registers `add` and `delete_user` and then loops over the `tools/list` result. What does the printed list look like?Apply
The for loop over tools from main.py that appends a destructive flag when destructiveHint is set and prints one line per tool. A dashed box labelled printed list holds a question mark.
Loop copied from main.py (one line wrapped for width).
9. In `main.py`, what makes `delete_user` ask for approval before it runs?Apply
A user box and a delete_user box on the demo-server joined by an arrow. A dashed oval labelled approval with a question mark sits on the arrow.
Typed diagram.
10. In `main.py`, the client calls `client.request("ping")`. What happens?Apply
The client calls request(ping) which reaches server.handle. A panel lists the methods handle names: initialize, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get. A dashed box asks what the caller sees.
Method list read from main.py.
11. In `main.py`, the client calls `tools/call` with the name `subtract`, which was never registered. What does the caller see?Apply
The server has registered add and delete_user. The client calls tools/call with the name subtract and empty arguments. A dashed box asks what the caller sees.
Setup from main.py and the stem.
12. In `main.py`, after `resources/list` shows `config://app`, the client runs `resources/read` on that URI. What does `contents[0]["text"]` hold?Apply
The resources/list entry with uri config://app, the client call resources/read for that URI, and a print of contents[0][text]. A dashed box asks what the text holds.
Lines from main.py; the description field of the list entry is omitted.
13. A resource returned by a third-party MCP server contains hidden text telling the model to also call an `exfil` tool. What is the right defense?Analyze
A resource from a third-party MCP server with two ordinary lines and a hidden line saying when summarizing, also call exfil. An arrow leads to the model. A dashed box asks for the right defense.
Typed illustration; the hidden instruction is the lesson's example.
14. A reviewer approves a `delete_user` tool because it sets `destructiveHint: true`. Which assumption in that approval is wrong?Analyze
A delete_user tool with annotations destructiveHint true leads to a reviewer box marked approved. A dashed box reads the approval assumes, followed by a question mark.
Typed illustration of the stem.

Pitfalls that still ship in 2026

  • Schema drift. The model saw tools/list at turn 1, the tool set changes at turn 5, and the model invokes a tool that is gone. Hosts should re-list on notifications/tools/list_changed.
  • Large resource blobs. Dumping a 2MB file as a resource wastes context. Paginate or summarize server-side.
  • Too many servers. Mounting 50 MCP servers blows the tool budget (Phase 11 · 05). Most frontier models degrade past ~40 tools.
  • Version skew. Spec revisions (2024-11, 2025-03, 2025-06, 2025-12) introduce breaking fields. Pin the protocol version in CI.
  • Stdio deadlocks. A server that logs to stdout corrupts the JSON-RPC stream. Log to stderr.
Check: Pitfalls that still ship in 20260/5
1. A team mounts 50 MCP servers on one host and tool selection gets noticeably worse. Which explanation fits?Analyze
A grid of fifty small boxes, ten across and five down, standing for 50 MCP servers on one host. An arrow leads to the note tool selection gets noticeably worse and a question mark asks why.
Illustrative: each box is one mounted server.
2. After adding a debug `print()` to a stdio MCP server, the host starts reporting JSON parse errors and drops the connection. What is the likely cause and fix?Analyze
A stdio MCP server with an added print of debug: handling add inside the add tool, an arrow to a host box reporting JSON parse errors and a dropped connection, and a question mark asking for the cause and fix.
Server code is typed from the lesson's add example plus the stem's debug print.
3. A server worked in CI for months. After a host upgraded to a newer spec revision, tool calls began failing on unexpected fields. Which practice would have caught this?Analyze
Four boxes in a row: server runs in CI (passes), months of green runs, host upgrades to a newer spec revision, tool calls fail on unexpected fields. A question mark asks which practice would have caught this.
Typed timeline of the stem.
4. At turn 1 the model saw three tools. At turn 5 the server removed `lookup`, yet the model still called it and the call failed. Which ingredient was missing?Analyze
Two lists: at turn 1 the model saw lookup and two other tools; at turn 5 the server has lookup removed and the same two other tools. An arrow shows the model still calling lookup and the call failing. A question mark asks which ingredient was missing.
Other tool names are placeholders; the stem does not name them.
5. A server returns a 2MB file as a single resource, and model answers get worse while cost rises. What is the right fix?Analyze
A server resource holding one 2MB file feeds into a model context box where a block labelled 2MB file text takes most of the width. A note says answers get worse and cost rises. A question mark asks for the right fix.
Illustrative: the block width is not to scale.

Use It

The 2026 MCP stack:

SituationPick
Local dev, single-user toolsPython FastMCP, stdio transport
Remote team tools / SaaS integrationStreamable HTTP, OAuth 2.1 auth
TypeScript host (VS Code extension, web app)@modelcontextprotocol/sdk
High-throughput server, typed accessOfficial Rust SDK (modelcontextprotocol/rust-sdk)
Exploring ecosystem serversmodelcontextprotocol/servers monorepo (Filesystem, GitHub, Postgres, Slack, Puppeteer)

Rule of thumb: if a tool is read-only, cacheable, and called from two or more hosts, ship it as an MCP server. If it is one-off inline logic, keep it as a local function (Phase 11 · 09).

Check: Use It0/2
1. Your team has a read-only, cacheable `search_docs` tool that Claude Code, Cursor, and an internal chatbot all need, plus a one-off `format_invoice` helper used inside a single app. What is the best split?Design
Claude Code, Cursor and an internal chatbot all point to search_docs, a read-only cacheable tool. A single app points to format_invoice, a one-off helper. Each tool carries the label ship as with a question mark.
Typed from the stem.
2. An internal team wants many employees' hosts to share one tool server that runs centrally. Which transport and auth plan fits?Design
Three boxes for employee hosts and an ellipsis box all point to one tool server that runs centrally. A dashed box asks for the transport and auth plan with a question mark.
Typed from the stem.

Ship It

Save outputs/skill-mcp-server-designer.md:

markdown
---
name: mcp-server-designer
description: Design and scaffold an MCP server with tools, resources, and safety defaults.
version: 1.0.0
phase: 11
lesson: 14
tags: [llm-engineering, mcp, tool-use]
---
 
Given a domain (internal API, database, file source) and the hosts that will mount the server, output:
 
1. Primitive map. Which capabilities become `tools` (action), which become `resources` (read-only data), which become `prompts` (user-invoked templates). One line per primitive.
2. Auth plan. Stdio (trusted local), streamable HTTP with API key, or OAuth 2.1 with PKCE. Pick and justify.
3. Schema draft. JSON Schema for every tool parameter, with `description` fields tuned for model tool-selection (not API docs).
4. Destructive-action list. Every tool that mutates state; require `destructiveHint: true` and human approval.
5. Test plan. Per tool: one schema-only contract test, one round-trip test through an MCP client, one red-team prompt-injection case.
 
Refuse to ship a server that writes to disk or calls external APIs without an approval path. Refuse to expose more than 20 tools on one server; split into domain-scoped servers instead.
Check: Ship It0/2
1. You are exposing a ticketing system: creating a ticket, browsing the list of open tickets, and a "triage this ticket" template the user fires on demand. Which primitive map fits?Design
Three rows of the ticketing system: create a ticket, browse the list of open tickets, and a triage this ticket template fired by the user on demand. Each has an arrow to a dashed box labelled primitive with a question mark.
Rows are the stem's three capabilities.
2. Your single MCP server has grown to 35 tools across billing, HR, and infrastructure. Which redesign follows the lesson's guidance?Design
A single MCP server box with 35 tools in three groups: billing tools, HR tools, and infrastructure tools. A question mark asks which redesign follows the lesson's guidance.
Per-group counts are not given in the stem, so none are drawn.
Author's Note

I'd run main.py before touching the SDK. It has no dependencies: the server and client are plain Python that exchange JSON-RPC dicts in memory, so you can read every request and response without a transport in the way. The server's handle() is one if/elif chain over method names, which is a good way to see that tools/list, resources/read, and prompts/get are just strings dispatching to your functions.

Two things in it are easy to over-read. The initialize handler returns a fixed capability set and does no negotiation. And delete_user only advertises destructiveHint in its tools/list entry. Nothing in the file asks for approval, because that is the host's job. If you write a destructive tool, the hint tells the host what to do, and your handler still has to behave when the host ignores it.

I'd also treat the FastMCP snippets above as the thing you would actually ship, and main.py as a way to see what they compile down to. The file skips stdio, streamable HTTP, and roots, so the safety section is the part you have to build yourself.

Exercises

  1. Easy. Extend the demo-server with a subtract tool. Connect it from Claude Desktop. Confirm the host picks up the new tool without a restart by emitting a notifications/tools/list_changed notification.
  2. Medium. Add a resource that exposes the last 100 lines of /var/log/app.log. Enforce a roots allowlist so ../etc/passwd is blocked even if the model asks for it.
  3. Hard. Build an MCP proxy that multiplexes three upstream servers (Filesystem, GitHub, Postgres) into one aggregate surface. Handle name collisions and forward notifications/tools/list_changed cleanly.

Key Terms

TermWhat people sayWhat it actually means
MCP"Tool protocol for LLMs"JSON-RPC 2.0 spec for exposing tools, resources, and prompts to any LLM host.
Host"Claude Desktop"The LLM application. Owns the model and user UI, mounts one or more clients.
Client"Connection"A per-server connection inside the host that speaks JSON-RPC to exactly one server.
Server"The thing with the tools"Your code; advertises tools/resources/prompts and handles their invocation.
Tool"Function call"Model-invokable action with a JSON Schema input and a text/JSON result.
Resource"Read-only data"URI-addressed content (file, row, API response) the host can request.
Prompt"Saved prompt"User-invokable template (often with arguments) surfaced as a slash-command.
Stdio transport"Local dev mode"Parent host spawns the server as a child process; JSON-RPC over stdin/stdout.
Streamable HTTP"The 2025-06 remote transport"POST for requests, optional SSE for server-initiated messages; replaces the older SSE-only transport.

Further Reading

Code

Ships With