Learn/Claude Academy: Learn the productsguide

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
  1. Claude Code in action
  2. Introduction to Model Context Protocol
  3. Model Context Protocol: Advanced topics
  4. Introduction to subagents
  5. Introduction to agent skills
  6. The AI-native SDLC playbook
  7. Deploying Claude Enterprise with confidence

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

  1. The server does its work (e.g. fetches articles).
  2. The server builds a prompt and sends a sampling request (sampling/createMessage) to the client.
  3. The client calls Claude and returns the generated text.
  4. 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 Context object:
@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) and notifications/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_dir on 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/list and the client sends notifications/roots/list_changed when 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.py and pasting JSON into the terminal.
  • Required 3-message handshake (before anything else):
    1. initialize request (client)
    2. InitializeResult with capabilities (server)
    3. 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 False by default: stateless_http and json_response. Setting them to True can 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 an mcp-session-id header → the initialized notification 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=True removes 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=True disables 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

  1. 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.
  2. Q: Which SDK call does a server use to sample? → A: ctx.session.create_message(...). The client handles it with a sampling_callback.
  3. Q: Which two Context methods give real-time feedback? → A: context.info() for logs and context.report_progress(current, total).
  4. Q: Where are the logging and progress callbacks registered? → A: Logging on the ClientSession. Progress on each call_tool(...).
  5. Q: Does the SDK enforce roots? → A: No. You implement something like is_path_allowed() and call it in each file tool.
  6. Q: What are the two MCP message categories? → A: Request→Result pairs, and one-way notifications.
  7. Q: What is the stdio handshake? → A: Initialize request → Initialize result → Initialized notification.
  8. 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.
  9. 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.
  10. 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.
  11. Q: What does json_response=True do? → A: Returns only the final result as plain JSON, with no streaming of progress or logs.
  12. 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 summarize tool that uses sampling, plus a Python client with a sampling_callback that calls Claude.
  • Add context.info and report_progress to a slow tool and render a progress bar in a CLI client.
  • Implement a convert_video tool guarded by is_path_allowed(), then try accessing a path outside the roots.
  • Run a server with uv run server.py and hand-type the 3-message handshake plus a tools/call JSON message.
  • Deploy the same server over StreamableHTTP. Toggle stateless_http and json_response and record which features break.
  • Put two stateful instances behind a load balancer without sticky sessions, then watch sampling and progress fail.