Transports: SSE and streamable HTTP
- Source
- From the RAG & Agents Engineering course
- By
- Sourav Banerjee
- Last updated
- 9 Nov 2026
SSE
A Larkspur HTTP client gets an accepted POST but still has no tool answer. You will trace the SSE response stream, compare it with Streamable HTTP and choose a transport using session and deployment requirements.
Step 1: Follow the SSE request and reply channels
Lesson 6 of T12.1 compares the two HTTP transports side by side. Project 03 builds the SSE one by hand, which shows how it works. The server is a Starlette app with two routes around the SDK's SseServerTransport:
Implementation excerpt from the configured application. The practice material supplies its models, data and session setup:
sse = SseServerTransport("/messages/")
routes = [
Route("/sse", endpoint=handle_sse), # GET: one long-lived stream per session
Mount("/messages/", app=sse.handle_post_message), # POST: every message the client sends
]
handle_sse hands the stream to the low-level server, mcp._mcp_server.run(...), which serves one session for as long as the GET stays open. FastMCP builds the same app for you with mcp.run(transport="sse") or mcp.sse_app().
- The client sends
GET /sse. The response is an event stream that stays open for the whole session. - The server’s first event names where to POST:
/messages/with a session id in the query string. - Each request is a POST to that address. The server answers
202 Acceptedat once, with no result in the body. - The result arrives as an event on the stream. The initialized notification and
tools/listtake one more POST each. - A tool call is one more POST and one more
202. - Its result comes back on the same stream. Two servers and 8 held calls made 16 HTTP requests: 2 GETs and 14 POSTs.
- No message ends the session. The client closes the stream, and the server drops the session id.
Step 2: Match the asynchronous answer by request id
The POST never carries an answer. Every request gets 202 Accepted, and its result arrives later as an event on the GET stream, matched by JSON-RPC id. Recorded with SDK 1.30.0 on the two Larkspur servers with held sessions, setup took 4 requests per server (the GET and three POSTs) and each tool call 1 more, so 8 calls made 16 HTTP requests. Nothing closes the session but the stream: when the GET ends, the server drops the session id. That puts the whole session on one long-lived connection, which a proxy that closes idle connections will cut.
Step 3: Inspect disconnect and origin handling
The project's code has five problems to fix before you copy it:
handle_ssereturnsNone. The SDK's own example inmcp/server/sse.pyreturnsResponse()after the stream ends and says why: without it, a client disconnect raisesTypeError: 'NoneType' object is not callable.- No host checks. The app builds
SseServerTransportwith no security settings, so DNS-rebinding protection is off. FastMCP turns it on by itself when the host is127.0.0.1orlocalhost. - An open shell on every interface. It binds
0.0.0.0:8081by default, andrun_commandruns any command withshell=Trueand no timeout. - A README that describes another server. It lists
execute_shell_commandwith acommandparameter and promises "command validation"; the code definesrun_commandandadd_numbersand validates nothing. - Two workspaces. The Docker instructions mount the workspace at
/workspace, while the code runs commands in/root/mcp/workspace.
Streamable HTTP
Step 1: Read the response on the request channel
Project 04's temperature server is a FastMCP server on one endpoint, /mcp:
Implementation excerpt from the configured application. The practice material supplies its models, data and session setup:
mcp = FastMCP("Temperature Converter", host=host, port=port, stateless_http=True)
...
mcp.run(transport="streamable-http")
Step 2: Separate transport selection from stateless mode
The comment beside stateless_http=True in the project says it enables the streamable HTTP protocol. It does not: transport="streamable-http" in run() picks the transport, and stateless_http, in the words of FastMCP's settings, decides "if the server should create a new transport per request". Stateful is the default. The two modes cost this, recorded on the Larkspur servers with held sessions:
| Stateful (default) | stateless_http=True |
|
|---|---|---|
| Setup, per server | 4 requests: initialize (the reply sets mcp-session-id), the initialized notification (202), a GET for the server's own stream, tools/list |
3 requests: initialize, the notification, tools/list |
| Each tool call | 1 POST, answered as an event stream | 1 POST |
| Close, per server | DELETE /mcp |
nothing |
| 8 calls, 2 servers | 18 requests | 14 requests |
Step 3: Read Streamable HTTP responses and headers
Each POST gets its own answer in the response, as text/event-stream by default or JSON with json_response=True. The mcp-session-id header ties a stateful client's requests together, and the server checks it on every request. Probed against a stateful Larkspur server in SDK 1.30.0:
- A
GET /mcpwithouttext/event-streaminAcceptgets406"Not Acceptable: Client must accept text/event-stream". Project 04'sserver_launcher.pyuses exactly this as its health check, treating a 406 as "the server is up". - A
tools/listPOST with no session id gets400"Bad Request: Missing session ID". - A session id the server does not know, such as one from another replica or one that expired, gets
404"Session not found", and the client has to initialize again.
Step 4: Check the configured session bounds
Stateful sessions expire after 1,800 seconds with no request in flight, and one server holds at most 10,000 at once; both are FastMCP settings (session_idle_timeout, max_sessions). Stateless mode has neither, and no server-to-client stream either, so the server can only speak in answer to a request.
Project 04's client mixes transports behind one agent. config/servers.json lists the temperature server as "type": "http" with a URL and the terminal server as "type": "stdio" with a command, and Google ADK's MCPToolset opens each with the matching connection parameters (StreamableHTTPServerParams, StdioConnectionParams). That is the module's mental model in one file: the agent sees one tool list, and each tool goes back over its own server's transport.
Choosing a transport
Step 1: Match process ownership to connection lifetime
- stdio for a server on the same machine that the client starts and owns: a desktop assistant's file tools, a local retriever over the handbook. No network, no auth, and a process per session.
- SSE only to reach a server that speaks nothing else, such as T12.1's deployed research server. It needs two endpoints and one long-lived GET per session.
- Streamable HTTP, stateful for a shared server that keeps something per client or sends messages between requests. With several replicas, route by
mcp-session-id. - Streamable HTTP, stateless for tools that keep no per-client state and must scale out: Larkspur's
statussearch can run as many replicas as it needs, and any of them can answer any request.
A server written for local use often ends with mcp.run(). In SDK 1.30.0 run() defaults to transport="stdio", so as written the server reads its standard input instead of listening for HTTP. A server that a platform reaches by URL needs transport="streamable-http" and must listen on the host and port the platform expects.
Try it yourself
Send a request over SSE and identify where 202 Accepted ends and where the JSON-RPC result arrives. Repeat with Streamable HTTP and inspect the POST response. For the latter, test Accept headers rather than using a plain GET as a readiness probe. A 406 does not by itself mean the server process is down.
Practice notebook
Run the complete implementation from the practice material after checking the trace or calculation above. Use its stated environment for live model or server calls; recorded model choices and timings are observations, not guaranteed outputs.
Continue learning
Run the notebooks
The code for this lesson is in these notebooks. Colab runs one in your browser: install the packages its first cell imports and set the API keys it reads. To run every notebook as written, clone the repository and use its uv environment.
| Notebook | GitHub | Colab |
|---|---|---|
| SSE transport server client | Open on GitHub | Run locally |
| Streamable http client | Open on GitHub | Run locally |
Key Takeaways
- SSE answers every POST with
202and sends the result on one long-lived GET stream, so an idle-closing proxy can cut the whole session. - In Streamable HTTP,
transport="streamable-http"picks the transport andstateless_httppicks the mode. Stateful sessions expire after 1,800 idle seconds and need routing bymcp-session-idacross replicas. - Use stdio for a local server the client owns, stateful HTTP for per-client state, and stateless HTTP for tools that must scale out.
Check yourself
1. Over SSE, every POST the client sends gets 202 Accepted. Where does the answer arrive, and what does that mean for the session?
On the long-lived GET /sse stream, as an event matched by JSON-RPC id. The whole session lives on that one connection: when the GET ends, the server drops the session id, so a proxy that closes idle connections will cut it.
2. Two Larkspur servers with held sessions make 8 tool calls over Streamable HTTP. How many HTTP requests do stateful and stateless mode use, and where does the gap come from?
18 stateful and 14 stateless. Stateful setup takes 4 requests per server (initialize, the notification, a GET for the server's own stream, tools/list) and a DELETE /mcp to close; stateless takes 3 and has nothing to close. Each tool call is 1 POST in both modes.
3. A stateful Streamable HTTP client behind a load balancer starts getting 404 Session not found. What happened, and what do you change?
The server does not know the session id: it came from another replica, or it expired after 1,800 seconds with no request in flight. The client has to initialise again. With several replicas, route by mcp-session-id, or run stateless if the tools keep no per-client state.