Model Context Protocol: Advanced topics
Sampling, logging and progress, roots, JSON-RPC messages and the stdio and HTTP transports.
Series Claude Academy: Learn the products Part 3 of 7
A walkthrough of the Claude Academy course Model Context Protocol: Advanced topics (Learn the products track, 11 lessons + quiz, about 1.5 hours). Each lesson is summarised with my own tables and examples; take the course for the full lessons. All the courses are listed in the Claude Academy course index.
Note: The text lessons were read in full. The three “walkthrough” lessons are interactive and their content isn’t public, so they’re summarized from the matching concept lessons. The JSON-RPC method names and raw message examples were added from the MCP spec (modelcontextprotocol.io) and should be checked against the lessons.
⚠️ Spec version: The course teaches MCP as it worked before the 2026-07-28 spec revision. That revision deprecates sampling, logging, and roots, and drops the initialize handshake and session IDs. Everything in the course still works on MCP Python SDK v1, which the walkthroughs use.
Prerequisites / Audience
- Engineers building production MCP servers.
- Basic understanding of MCP servers and clients (tools, resources, prompts).
- Familiarity with async programming (Python
async/await).
Module 1 — Core MCP features
Lesson 1–2: Sampling (+ walkthrough)
- Sampling lets a server ask the client to call an LLM on its behalf. The client already has the Claude connection, so the cost and credentials shift to the client.
- Problem it solves: a research tool fetches Wikipedia pages and needs a summary. Without sampling, the server needs its own API key, auth, cost management, and Claude integration code.
Flow
- The server does its work (e.g. fetches articles).
- The server builds a prompt and sends a sampling request (
sampling/createMessage) to the client. - The client calls Claude and returns the generated text.
- The server uses that text in its tool result.
| Benefit | Why |
|---|---|
| Less server complexity | No LLM integration code on the server |
| Cost shifts to the client | Each client pays for its own tokens |
| No API keys on the server | The server never holds Claude credentials |
| Ideal for public servers | Random users can’t run up your AI bill |
Server side (Python SDK v1)
@mcp.tool()
async def summarize(text_to_summarize: str, ctx: Context):
prompt = f"Please summarize the following text:\n{text_to_summarize}"
result = await ctx.session.create_message(
messages=[SamplingMessage(role="user",
content=TextContent(type="text", text=prompt))],
max_tokens=4000,
system_prompt="You are a helpful research assistant",
)
if result.content.type == "text":
return result.content.text
raise ValueError("Sampling failed")
Client side
async def sampling_callback(context: RequestContext, params: CreateMessageRequestParams):
text = await chat(params.messages) # call Claude via the Anthropic SDK
return CreateMessageResult(role="assistant", model=model,
content=TextContent(type="text", text=text))
async with ClientSession(read, write, sampling_callback=sampling_callback) as session:
await session.initialize()
Lesson 3–4: Log and progress notifications (+ walkthrough)
- Long-running tools otherwise show nothing until they finish, so users can’t tell whether a tool is working or stuck. Notifications give real-time feedback.
- In the Python SDK, the tool receives an auto-injected
Contextobject:
@mcp.tool(name="research", description="Research a given topic")
async def research(topic: str = Field(description="Topic to research"), *, context: Context):
await context.info("About to do research...") # log message
await context.report_progress(20, 100) # progress, total
sources = await do_research(topic)
await context.info("Writing report...")
await context.report_progress(70, 100)
return await generate_report(sources)
Client callbacks: the logging callback goes on the session, and the progress callback goes on each tool call.
async def logging_callback(params: LoggingMessageNotificationParams):
print(params.data)
async def print_progress_callback(progress: float, total: float | None, message: str | None):
if total is not None:
print(f"Progress: {progress}/{total} ({progress/total*100:.1f}%)")
else:
print(f"Progress: {progress}")
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write, logging_callback=logging_callback) as session:
await session.initialize()
await session.call_tool(name="add", arguments={"a": 1, "b": 3},
progress_callback=print_progress_callback)
- Presentation is up to the client: a CLI prints, a web app pushes updates via WebSockets/SSE/polling, a desktop app updates progress bars. Handling notifications is optional.
- Wire format (from the spec):
notifications/message(log) andnotifications/progress(progressToken,progress,total).
Lesson 5–6: Roots (+ walkthrough)
- Roots grant a server access to specific directories. They act as both a permission boundary and context for finding files.
- Problem: “convert biking.mp4 to mov”. Claude only knows the filename, can’t search the whole disk, and making users type full paths is unfriendly.
- Flow with roots: the user asks → Claude calls
list_roots→read_diron the allowed dirs → finds the file → calls the conversion tool with the full path. - Security: if only Desktop is a root, Documents and Downloads are off-limits. Out-of-root access returns an error that Claude can explain to the user.
- ⚠️ The SDK does NOT enforce roots automatically. Implement a check yourself and call it in every file-touching tool:
# Illustrative sketch; file_url_to_path is a helper you write (file:// URI -> local path)
async def is_path_allowed(requested: Path, ctx: Context) -> bool:
roots = await ctx.session.list_roots()
requested = requested.resolve()
for root in roots.roots:
root_path = Path(file_url_to_path(root.uri)).resolve() # roots are file:// URIs
if requested.is_relative_to(root_path):
return True
return False
- Benefits: no full paths needed, faster focused search, security, and flexibility (roots can be exposed via tools or injected into prompts).
- In the spec, the server requests
roots/listand the client sendsnotifications/roots/list_changedwhen roots change.
Module 2 — Transports and communication
Lesson 7: JSON message types
- All MCP traffic is JSON (JSON-RPC 2.0) messages. The authoritative list lives in the MCP specification repo (separate from the SDKs), written in TypeScript purely as a schema notation.
| Category | Examples | Response? |
|---|---|---|
| Request → Result | CallTool, ListPrompts, ReadResource, Initialize | Yes, always paired |
| Notification | Progress, LoggingMessage, ToolListChanged, ResourceUpdated | No (one-way) |
- Messages are grouped by sender: client messages (requests and notifications the client sends) and server messages (requests the server sends to clients, plus notifications).
- Key insight: MCP is bidirectional, so servers can initiate requests too. That matters when picking a transport.
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"add","arguments":{"a":1,"b":3}}}
{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"4"}]}}
{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t1","progress":20,"total":100}}
Lesson 8: The STDIO transport
- The client launches the server as a subprocess. Client → server goes over stdin, server → client over stdout. It only works on the same machine.
- Either side can send at any time, so this is the ideal/baseline bidirectional case and is great for development.
- You can test a server by running
uv run server.pyand pasting JSON into the terminal. - Required 3-message handshake (before anything else):
initializerequest (client)InitializeResultwith capabilities (server)notifications/initialized(client, no response)
| Scenario | Channel |
|---|---|
| Client → Server request | client writes to stdin |
| Server → Client response | server writes to stdout |
| Server → Client request | server writes to stdout |
| Client → Server response | client writes to stdin |
Lesson 9: The StreamableHTTP transport
- Lets clients connect to remote servers over HTTP, which enables public MCP servers.
- Plain HTTP problem: clients can easily call servers (servers have known URLs), but servers can’t easily call clients.
- Message types affected by this:
- Server-initiated requests: CreateMessage (sampling), ListRoots
- Notifications: progress, logging, initialized, cancelled
- Two flags, both
Falseby default:stateless_httpandjson_response. Setting them toTruecan break progress, logging, and server-initiated requests. This is a common reason a server works over stdio locally but breaks once deployed.
Lesson 10: StreamableHTTP in depth
- Session setup:
initialize→ the result carries anmcp-session-idheader → theinitializednotification includes the ID. Every later request must include the session ID. - SSE workaround: the client opens a long-lived GET request as a Server-Sent Events stream that the server can push messages on at any time.
- Dual SSE connections during a tool call:
| Connection | Lifetime | Carries |
|---|---|---|
| Primary SSE (GET) | Stays open indefinitely | Server-initiated requests, progress notifications |
| Tool-specific SSE (POST response) | Closes when the tool result is sent | Logging messages and the tool result |
mcp = FastMCP("my-server", stateless_http=False, json_response=False) # defaults
mcp.run(transport="streamable-http")
Lesson 11: State and the StreamableHTTP transport
- Scaling problem: many clients → horizontal scaling behind a load balancer. The client’s GET SSE stream and its POST tool calls may land on different instances, so a tool that needs sampling would require cross-instance coordination.
stateless_http=Trueremoves that problem, at a cost:- No session IDs (the server can’t track clients)
- No server → client requests (the GET SSE path is gone)
- No sampling, no progress, no subscriptions (resource-update notifications)
- ✅ Upside: no initialization handshake required
json_response=Truedisables streaming of POST responses. You get only the final result as plain JSON, with no intermediate progress or logs.
| Use… | When |
|---|---|
stateless_http=True |
Horizontal scaling and load balancers; no server→client needs; no sampling; minimal connection overhead |
json_response=True |
No streaming needed; integrating with systems that expect plain JSON |
| stdio | Local dev and tools on the same machine; full bidirectional features |
| Stateful StreamableHTTP | Remote server that needs sampling, progress, logs, or roots (sticky sessions or a single instance) |
- Dev vs. prod: test with the transport you’ll deploy with, because stateful and stateless behavior differ a lot.
Quick-Review Flashcards
- Q: What is sampling? → A: A server asks the client to run an LLM call for it, so cost and credentials move to the client.
- Q: Which SDK call does a server use to sample? → A:
ctx.session.create_message(...). The client handles it with asampling_callback. - Q: Which two
Contextmethods give real-time feedback? → A:context.info()for logs andcontext.report_progress(current, total). - Q: Where are the logging and progress callbacks registered? → A: Logging on the
ClientSession. Progress on eachcall_tool(...). - Q: Does the SDK enforce roots? → A: No. You implement something like
is_path_allowed()and call it in each file tool. - Q: What are the two MCP message categories? → A: Request→Result pairs, and one-way notifications.
- Q: What is the stdio handshake? → A: Initialize request → Initialize result → Initialized notification.
- Q: Why is stdio the “ideal” transport? → A: Either side can send at any time over stdin/stdout. But it only works on the same machine.
- Q: How does StreamableHTTP allow server→client messages? → A: A long-lived GET SSE stream, plus per-tool-call SSE responses, tied together by the
mcp-session-id. - Q: What do you lose with
stateless_http=True? → A: Session IDs, server→client requests, sampling, progress, and subscriptions. In return, no handshake is needed and you can scale horizontally. - Q: What does
json_response=Truedo? → A: Returns only the final result as plain JSON, with no streaming of progress or logs. - Q: What changed in the 2026-07-28 spec revision? → A: Sampling, logging, and roots are deprecated, and the handshake and session IDs are dropped.
Practice Ideas
- Build a FastMCP
summarizetool that uses sampling, plus a Python client with asampling_callbackthat calls Claude. - Add
context.infoandreport_progressto a slow tool and render a progress bar in a CLI client. - Implement a
convert_videotool guarded byis_path_allowed(), then try accessing a path outside the roots. - Run a server with
uv run server.pyand hand-type the 3-message handshake plus atools/callJSON message. - Deploy the same server over StreamableHTTP. Toggle
stateless_httpandjson_responseand record which features break. - Put two stateful instances behind a load balancer without sticky sessions, then watch sampling and progress fail.