One page. Written against MCP 2026-07-28 / Python SDK mcp 2.0.
make setup # create the workshop virtualenv
make check # verify this machine is ready
make server # run the MCP server over stdio
make client # module 4a — client, no LLM
make agent Q="..." # module 4b — the hand-written loop
make web # module 5 — browser UI on :7932
make inspector # MCP Inspector (needs Node 22.19+)
make jsonrpc METHOD=tools/list # raw JSON-RPC
./scripts/raw_jsonrpc.sh tools/call '{"name":"get_weather","arguments":{"city":"Tokyo"}}'One virtualenv: .venv runs the MCP 2 server, clients, agent loop, and web UI.
from typing import Annotated, Literal
from mcp.server import MCPServer
from pydantic import BaseModel, Field
mcp = MCPServer("travel", instructions="What this server is for.")
class Weather(BaseModel): # -> outputSchema
city: str
temperature_c: int = Field(description="Degrees Celsius.")
@mcp.tool() # model-controlled
def get_weather(
city: Annotated[str, Field(description='City name, e.g. "Tokyo".')],
days: Annotated[int, Field(ge=1, le=7)] = 3,
units: Literal["celsius", "fahrenheit"] = "celsius",
) -> Weather:
"""Description the model reads when choosing this tool."""
raise ValueError("Readable message") # -> isError result, not a crash
@mcp.resource("travel://destinations") # app-controlled
def catalog() -> str: ...
@mcp.prompt() # user-controlled
def plan_a_trip(city: str, nights: int = 3) -> str: ...
mcp.run(transport="stdio") # or "streamable-http"Docstring Args: sections do not become parameter descriptions — use Annotated[..., Field(description=...)].
from mcp import Client, StdioServerParameters, stdio_client
async with Client(stdio_client(StdioServerParameters(command=..., args=[...]))) as c:
c.protocol_version # "2026-07-28"
tools = (await c.list_tools()).tools
r = await c.call_tool("get_weather", {"city": "Tokyo"})
r.structured_content # typed dict
r.content[0].text # text block
r.is_error # errors are results
await c.read_resource("travel://destinations")
await c.get_prompt("plan_a_trip", {"city": "Tokyo", "nights": "4"})[{"type": "function",
"function": {"name": t.name,
"description": t.description or "",
"parameters": t.input_schema}} # snake_case in v2
for t in tools]from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from agent_raw import run
async def chat(request):
question = (await request.json())["question"]
calls = []
answer = await run(question, lambda name, args: calls.append((name, args)))
return JSONResponse({"answer": answer, "tools": calls})
app = Starlette(routes=[Route("/api/chat", chat, methods=["POST"])])| v1 | v2 |
|---|---|
from mcp.server.fastmcp import FastMCP |
from mcp.server import MCPServer |
stdio_client + ClientSession + initialize() |
Client(...) |
tool.inputSchema / result.isError |
tool.input_schema / result.is_error |
initialize handshake |
none — _meta on every request |
server → client sampling / roots |
MRTR (resultType: "input_required") |
| HTTP+SSE transport | Streamable HTTP |
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}Methods: server/discover · tools/list · tools/call · resources/list · resources/read · prompts/list · prompts/get
| Controlled by | Like | |
|---|---|---|
| Tools | the model | POST |
| Resources | the application | GET |
| Prompts | the user | a slash command |