Part 4 of 7 · 7 min read
When the connection breaks after the work is done
Covers: When the connection breaks after the work is done; Notifications: `subscriptions/listen`; Talking to older servers; Securing an HTTP endpoint.
When the connection breaks after the work is done
This is the failure that matters most for agents that do things. A request goes out, the server runs the tool and changes something, and the connection dies before the reply arrives. The client cannot tell "never arrived" from "did it and I never heard". In the example, the server runs place_hold and then cuts the connection before replying:
| Client behaviour | Attempts | Holds created |
|---|---|---|
| retries the same call | 2 | 2 (a duplicate) |
| retries with an idempotency key | 2 | 1 |
retries a read-only search |
2 | same result both times |
So the rules are:
- A read-only call can be retried freely. That is part of why module 1 asks you to separate reads from writes.
- A call with a side effect can be retried only if the server can recognise a repeat. The pattern is an idempotency key: the model (or the host) generates a unique value for each intended action, and the server remembers the result for that key. A second call with the same key returns the first result and marks it as a repeat. The example's
place_holddoes exactly this. - Otherwise do not retry blindly. Return an error that tells the model what is uncertain: the connection dropped; the hold may or may not have been placed; check with list_holds before trying again.
What the earlier revision did, and what was lost
The earlier form let the server put event ids on its SSE events and let a client resume with an HTTP GET carrying Last-Event-ID. The server replays the messages that would have followed, on the stream that broke. The example imitates this: the server's connection to the client is cut after 2 events, the client reconnects, and the work was run once (6 steps, 5 events replayed). The new revision does not have resumability. The same break in the new form means the client must send the request again, and the work ran twice (12 steps for a 6-step report) unless the server remembers the request.
That is a real trade. Dropping sessions and resumption makes a server easy to scale: any instance can answer any request, and nothing has to be stored for a client that may never return. The cost moves to tool design: long-running or side-effecting tools need idempotency keys or a way to look up their state by a handle (module 2) or by the tasks extension, which I have not covered here.
Notifications: subscriptions/listen
Servers have things to say that are not replies: the tool list changed, a resource was updated. In the old form the server wrote them on a standalone GET stream. In the new form the client asks for a stream with a request called subscriptions/listen, and the response to that request never ends until someone closes it.
The request carries a filter: toolsListChanged, promptsListChanged, resourcesListChanged (booleans) and resourceSubscriptions (a list of URIs). The server must not send notification types the client did not ask for, and its first message is notifications/subscriptions/acknowledged, which says which of the requested types it will honour. Every message on the stream carries io.modelcontextprotocol/subscriptionId in _meta, whose value is the id of the listen request.
In the example the client asks for three kinds of notification and the server (which does not support prompt-list changes) acknowledges two:
| Requested | Granted |
|---|---|
resourceSubscriptions, toolsListChanged, promptsListChanged |
resourceSubscriptions, toolsListChanged |
A second client then restocks a book; the listener receives notifications/resources/updated with the same subscription id. A resource nobody subscribed to produces nothing. When the client closes the stream, the server's list of open subscriptions goes from 1 to 0: no leak.
Many streams, one pipe. On stdio all subscriptions share stdout, so the subscription id is what sorts them. The example opens two listeners, makes three changes (one to book 1, two to book 2), and the listeners receive 1 and 2 notifications respectively.
Ending. If the client closes the stream (HTTP) or sends notifications/cancelled (stdio), the subscription ends. If the server ends it on purpose (shutdown), it should send a normal successful response to the listen request, so the client can tell a clean end from a dropped connection. In the example, stopping the server makes every open listen stream finish with resultType: complete and its subscription id.
Nothing is replayed
If the connection drops while you are away, you miss the notifications, and nothing sends them again. The example shows it: a client reads a book (2 copies), disconnects, another client restocks it to 7, the first reconnects and listens again. It receives 0 notifications. It would still believe the book has 2 copies. Only reading the resource again shows 7.
The rule for a client is: after any reconnect, re-read what you cache. Do not assume that "no notification" means "no change". This also fits the cache hints from module 2: a ttlMs bounds how stale a list may be even if a notification is missed.
Talking to older servers
A client that must work with servers it did not choose has to detect which shape it is facing. Over HTTP the specification's order is:
- POST a modern request (
server/discover) with the headers above. If it answers200with a result: modern. If it answers400with a recognised modern error body: modern, retry with a supported version. If it answers404with-32601: modern, but without that method. - If it fails with
400,404or405and the body is not a recognised modern error: try the earlier Streamable HTTP: POSTinitialize; a server that supports it returns a session id in theMcp-Session-Idheader, and every later request must carry it. - If that fails too: send a GET to the same URL. A server on the deprecated HTTP+SSE transport answers with an event stream whose first event is
endpoint.
In the example, all of these are real. The earlier server issues an unguessable session id (32 hex characters, visible ASCII); answers a request with no session id with a bare 400; answers an ended session with 404, after which the client must start a new session; and lets a client end a session with DELETE (which a server may refuse with 405). The HTTP+SSE server is only built far enough for its endpoint event to be recognised: the example client does not implement that transport, and the specification says new implementations should not adopt it. A server that supports only the new revision should answer an older client's GET or DELETE with 405, ignore a session id header, and ignore Last-Event-ID; the example's server does all three.
Session ids are not authentication
In the earlier form a session id identifies a conversation; it was never meant to prove who you are. The specification's security guidance for that era warns about session hijacking (a stolen or guessed id), so ids must be unguessable and bound to the authenticated user. The new revision removes sessions, but handles (module 2) have the same risk and the same answer.
Securing an HTTP endpoint
A local HTTP server feels private and is not: any web page you open can try to talk to localhost, and with a trick called DNS rebinding a hostile site can even make the browser treat your local server as if it belonged to the hostile site's own address. The specification puts three requirements on a Streamable HTTP server:
- Validate the
Originheader on every incoming connection, to prevent DNS rebinding. If it is present and not allowed, respond 403. - Bind to localhost (127.0.0.1) when running locally, not all interfaces (0.0.0.0).
- Authenticate every connection (module 5 covers how).
The example shows the first one working. A server that placed holds for anyone who reached it, with the origin check off versus on:
| Caller | Origin check off | Origin check on |
|---|---|---|
a program (no Origin header) |
hold created | hold created |
a page on localhost |
hold created | hold created |
a page on evil.example |
hold created | 403, no hold |
Look at the first row: the Origin check does not stop a program, because programs do not have to send one. It stops browsers acting for a hostile page. That is why it is a requirement and not a substitute for the third rule. The server's default bind address in the example is 127.0.0.1, and a test asserts it.
Further points, from general knowledge rather than the pages I read: use TLS for any non-local endpoint; also check the Host header, since a rebinding attack arrives with the attacker's host name in it; and treat everything a remote server sends (descriptions, results) as untrusted, as module 2 and module 5 say.