Beyond tools
What is Model Context Protocol? covers the half of MCP most people meet first: a host connects to servers, servers publish tools, resources, and prompts, and the model calls the tools. Every one of those messages travels the same direction - client to server, with a result coming back. This article is about the other direction.
Three features let the server speak first. Sampling lets a server ask the client to run a model call on its behalf. Notifications let a server report progress and log lines while a long tool is still running. Roots let a server ask the client which folders it is allowed to touch. None of them are exotic. They are what turns a wrapper around an API into something that feels finished.
They share one dependency, and it is the reason the second half of this article is about transports. Each of them needs a channel the server can write to without being asked. Over stdio that channel is free. Over HTTP it has to be built - and two configuration flags on the Streamable HTTP transport quietly take it away again. If a server works perfectly on a laptop and loses its progress bars the day it is deployed, this is why.
Sampling
Picture a research tool. It takes a topic, pulls a dozen Wikipedia articles, and should hand back a readable report. The fetching is ordinary code. The summarizing needs a language model - and now the server has a decision to make.
Option one: give the server its own model access. It needs an API key, an SDK, error handling, and a bill that grows with every user who calls the tool. For a private server that might be acceptable. For a public one it is a way to pay for strangers' token usage.
Option two is sampling. The server builds the prompt and sends the client a sampling/createMessage request: could you run this through your model for me? The client already has a model connection and credentials - it is the AI application the user is sitting in front of. It makes the call and returns the completion. The server never sees a key.
- The tool finishes its own work - the Wikipedia fetches.
- It assembles the prompt it wants completed.
- The server sends
sampling/createMessageto the client. - The client calls the model it already uses - Claude, in the Anthropic stack.
- The client returns the generated text.
- The tool folds it into its result and returns as usual.
The consequences are worth spelling out. The server carries no model integration and no credentials. The client pays for the tokens, which is exactly right: it is the client's user who asked. A public server can offer a genuinely smart tool with no cost model attached. And the client stays in charge throughout - it can show the user the prompt before it goes out, pick the model, and cap the token budget.
On the server it is one call on the tool's context. In the Python SDK:
On the client it is a callback registered when the session opens. Whatever the callback does to produce text - the Anthropic SDK, another vendor, a local model - is the client's business:
Log and progress notifications
A tool that takes thirty seconds looks exactly like a tool that has hung, unless it says something. By default the user sees nothing between the call and the result. Notifications fix that with two one-way messages the server can send while the tool is still running: a log message (notifications/message) carrying a string and a level, and a progress update (notifications/progress) carrying a current value and an optional total.
In the Python SDK both hang off the Context argument the SDK injects into a tool:
The client side is symmetric, with one asymmetry worth noticing. A logging_callback is registered once when the session opens; a progress_callback is attached to each individual call_tool. Logs belong to a connection, progress belongs to a call.
What the client does with them is its business. A CLI prints them. A web app pushes them to the browser over its own SSE or WebSocket channel. A desktop app drives a progress bar. A client can also ignore them entirely - notifications are advisory, and a server that emits them works unchanged against a client that drops them. That is the whole design: the server narrates, the host decides how much of the narration the user hears.
Roots
Roots answer a question every filesystem tool eventually hits: which part of the disk is this conversation about?
Take a convert_video tool that turns an MP4 into a MOV and needs a path. The user types "convert biking.mp4 to mov". The model has a filename and no idea where it lives - Movies, Movies/Sports, a Downloads folder, an external drive. Without help it has two bad options: guess, or make the user type the full path every time.
A root is a directory the client declares in scope - typically the project folder open in the IDE, or a folder the user picked in a dialog. The server asks for the list with a roots/list request; the client answers with URIs. With that in hand the workflow changes:
- The user asks for the conversion by filename.
- The server's tools call
roots/listand learn which folders are in play. - A directory-listing tool searches those folders - and only those - for
biking.mp4. - The conversion tool is called with the full path it found.
The user still typed a filename. The search was fast because it was narrow. And the same list is a boundary: a path outside every root is refused with an error the model can relay - that file is not reachable from this server's roots - instead of silently opening something it should not.
One thing to be clear about: the SDK does not enforce roots for you. A root is information the client shares, not a sandbox the runtime imposes. The server owns the check - a small helper that resolves a requested path and confirms it sits under one of the roots, called at the top of every tool that touches the disk:
Skip that call in one tool and the boundary has a hole exactly the size of that tool.
The message vocabulary
Everything above is a JSON-RPC 2.0 message, and there are only two shapes.
A request carries an id and expects a result with the same id. initialize, tools/list, tools/call, resources/read, and prompts/get are all requests - and so are sampling/createMessage and roots/list, which are the same shape pointed the other way. A notification has no id and gets no reply: notifications/initialized, notifications/progress, notifications/message, notifications/tools/list_changed, notifications/resources/updated, notifications/cancelled.
The specification organizes the vocabulary by who sends what, which is the useful way to hold it:
| Shape | Examples | |
|---|---|---|
| Client → server | Request, expects a result | initialize, tools/list, tools/call, resources/read, prompts/get |
| Server → client | Request, expects a result | sampling/createMessage, roots/list |
| Server → client | Notification, no reply | progress, message (log), tools/list_changed, resources/updated |
| Client → server | Notification, no reply | initialized, cancelled, roots/list_changed |
The schema itself lives in the specification repository, separate from every SDK, and is written in TypeScript - not to be executed, but because TypeScript is a compact way to describe data shapes. When a Python or C# SDK disagrees with it, the spec wins.
The insight to carry into the next three sections: two of the four rows have the server initiating. A transport that cannot carry those rows cannot carry sampling, roots, progress, or logs.
stdio: the baseline
The transport everyone starts with is also the one where the whole vocabulary works without effort.
The client launches the server as a child process. It writes messages to the server's stdin; the server writes to its stdout. Two pipes, one per direction, and either side can write at any moment. The only constraint is that both processes live on the same machine.
Every connection opens with the same three messages, in order:
initializerequest - the client introduces itself and lists the capabilities it supports: sampling, roots, and so on.initializeresult - the server answers with its own capabilities: tools, resources, prompts, logging.notifications/initialized- the client confirms, and nothing comes back.
Only after that handshake do tools/call and friends become legal. The capability exchange is why it matters: a server should not send sampling/createMessage to a client that never said it supports sampling.
Because stdio is just two pipes, the four traffic patterns a transport has to handle are all trivial - client request and client reply go down stdin, server reply and server request come back up stdout. You can watch it happen: run a server directly in a terminal, paste the initialize JSON, and the result prints back. That transparency is the reason stdio is the right development transport, and the reason it is the baseline for judging every other one.
Streamable HTTP: the workaround
The moment the server moves to another machine, the pipes are gone and HTTP takes over - and HTTP has an opinion about direction. A client can call a server because the server has a URL. A server cannot call a client, because the client does not. Requests flow one way; only responses flow back. That breaks exactly the rows of the vocabulary table that make MCP interesting.
Streamable HTTP is the workaround, built on Server-Sent Events. Three moves make it work.
A session. The client POSTs initialize. The result comes back with an mcp-session-id header, and every request the client makes from then on carries that header. The id is how the server knows which client is which across what are otherwise independent HTTP requests.
A standing stream. After the handshake the client opens a GET to the same endpoint, and the server holds it open as an SSE stream. That long-lived response is the channel the server writes to whenever it needs to speak first - a roots/list, a sampling/createMessage, a progress update. In effect the client has lent the server a URL.
A stream per call. Each tools/call POST gets its own SSE response, which stays open while the tool runs and closes when the result is written. So during a tool call there are two streams alive: the primary GET stream carrying progress notifications, and the call's own stream carrying log messages and, at the end, the result.
That dual-stream shape is the thing to hold in your head when debugging a remote server. If progress bars work but logs are missing, or the other way round, you are looking at two different connections.
Two flags that switch it off
The Python SDK's Streamable HTTP transport has two switches, both off by default. Both exist for good reasons. Both remove pieces of the workaround above.
Why anyone would want that starts with scale. One server instance handles a few clients; a popular server needs several instances behind a load balancer. Now the client's GET stream may be held by instance A while its tools/call POST lands on instance B. If that tool wants to sample, B has to find A and hand it the request. That coordination problem is real, and stateless mode simply refuses to have it.
No session, no reverse channel
- No session id is issued, so the server cannot tell clients apart
- No standing GET stream, so no server-initiated requests at all
- Therefore no sampling, no progress, no resource subscriptions or update notifications
- Any instance can serve any request - the point of the flag
- Clients may skip the initialize handshake entirely
One plain answer per call
- The POST response is a single JSON body, not an SSE stream
- No intermediate progress, no log lines during the call
- The final tool result arrives as before
- For consumers that expect ordinary request and response HTTP
- Independent of stateless mode, though often set together
The lesson buried in this pair: a server that works perfectly in development over stdio, and works perfectly against a single Streamable HTTP instance, can lose its progress bars, its logging, and its sampling the day it is deployed behind a load balancer with stateless_http=True. Nothing errored. The feature set changed. Test with the transport and the flags you will ship with, not the ones that are convenient on a laptop.
Which transport, when
- A local tool on the user's machine - a filesystem, git, a build runner - stdio. Full vocabulary, no network, no auth to design.
- A remote server on one instance, or behind sticky sessions, with tools that want sampling, roots, or progress - Streamable HTTP with the defaults. The SSE workaround gives you the whole protocol over the network.
- A remote server that has to scale out or run serverless - Streamable HTTP with
stateless_http=True, and design for it: tools return finished results, any model call a tool needs is the server's own, and nothing depends on hearing from the client mid-call. Addjson_response=Truewhen the consumer wants plain JSON.
One way to summarize the whole article: MCP's advanced features are all the same feature - the server gets to speak first. The transport decides whether it can, and the flags decide whether you have kept that ability or traded it for scale. Know which trade you made before the first user notices.
References
- Claude Academy - Model Context Protocol: Advanced Topicsacademy.claude.com/courses
- Samplingacademy.claude.com · lesson 1
- Log and progress notificationsacademy.claude.com · lesson 3
- Rootsacademy.claude.com · lesson 5
- StreamableHTTP in depthacademy.claude.com · lesson 10
- State and the StreamableHTTP transportacademy.claude.com · lesson 11
- MCP specificationmodelcontextprotocol.io/specification
- MCP Python SDKgithub.com/modelcontextprotocol/python-sdk