Chapter 6 of 7 · 14 min read
Closing an MCP session
On this page
Opening a connection is three messages and a negotiated result. Closing one has no messages
at all: there is no shutdown request and no notifications/closing in MCP, and the
sequence differs by transport. Over stdio the client closes the child's stdin, which a
well-behaved server treats as end of input and exits; if it has not exited within a grace
period, the client sends SIGTERM, and if it still has not, SIGKILL. Over streamable
HTTP the client issues an HTTP DELETE to the endpoint carrying the Mcp-Session-Id
header, which tells the server the session is over and lets it release whatever it was
holding, then closes any open stream.
That asymmetry with the opening is easy to misread as sloppiness in the specification and is closer to the opposite. An orderly in-band shutdown handshake would need its own state, its own timeouts and its own failure modes, and it would still not help in the case that matters most, which is a server that has crashed or a host that was killed. Making teardown a transport concern means the same code path handles a deliberate disconnect and an unexpected one, differing only in whether the host expected it.
The argument underneath that is worth making explicit, because "the specification left it out" and "the specification decided against it" look the same from outside. A shutdown handshake would be a message the client sends and a reply the server owes, which immediately raises the question of what the client does when the reply does not come. Something has to time out, and after the timeout something has to close the transport anyway — so the transport-level teardown has to exist regardless, as the fallback beneath the handshake. Now compare the two designs against the failure that actually happens most, which is a server that has crashed. Under the handshake design that case skips the handshake entirely and falls through to the transport path. Under the transport-only design there is only ever the one path. The handshake would therefore have added a state, a timeout and a second code path, and would have been used in precisely the cases where nothing was wrong. Left out is the right description of what happened, and it is a decision rather than an omission.
The DELETE, and the state it releases
The HTTP side has a detail that stdio does not, and skipping it produces a leak on someone
else's machine rather than your own. A remote server holding a session — its negotiated
version, its subscriptions, whatever per-tenant context it built from clientInfo — has no
way to know a client is gone if the client simply stops sending requests, so it holds that
state until an idle timeout expires, which for many implementations is fifteen minutes or
more. The DELETE is what releases it promptly, and a host that skips it works correctly
while quietly holding a few hundred abandoned sessions open across a fleet:
DELETE /mcp HTTP/1.1
Host: docs.internal
Mcp-Session-Id: 5f2c9a1e-7b40-4c1a-9a2f-0d1c3e8a6b77A server that has already forgotten the session answers 404, which is a successful outcome
for this request and should not be retried.
The two boxes on the host's own lane are the ones that get skipped, and the diagram puts them either side of the close deliberately: something has to stop new work reaching a connection that is going away, and something has to clean up downstream of it once it has gone.
The ordering there is not cosmetic. If the host closes first and stops dispatching second, there is a window in which a tool call is handed to a client whose transport is already gone, and what the caller gets back depends entirely on how the client happens to fail — an exception from a closed pipe, a promise that never settles, or a write to a file descriptor that has been reused. Stopping dispatch first makes that window empty, and it costs one boolean on the host's side of the map. The same argument applies at the far end of the sequence: the tool array is rebuilt after the client reports closed, not before, because a rebuild that races the close can put the server back into the array it was just removed from.
Both of those orderings come from the same rule, which is worth stating once so the third case is derivable rather than needing its own paragraph. Anything that feeds a connection has to stop before the connection goes away, and anything that reads from a connection has to be rebuilt after. Dispatch feeds it, so dispatch stops first: the interval between stopping dispatch and closing is a window in which no call can be issued, which is exactly the window you want, whereas the reverse ordering opens a window in which a call can be issued to a transport that is already gone. The tool array reads from it, so it is rebuilt last: rebuilding before the close means rebuilding from a set of connections that still includes the one being removed. Sort every other teardown obligation by that question — does this thing send into the connection, or derive from it — and its position in the sequence falls out without a second thought.
What has to be released, and by whom
Teardown is a list of resources with owners, and the owners split along the line between a connection and the things built out of one: the client owns everything that is part of the connection, and the host owns everything derived from it.
That split is a single question applied nine times, and it is worth checking it against the rows rather than taking it on trust, because a table of owners is exactly the kind of thing that looks authoritative and can be assembled carelessly. The question is: would this thing still exist if the connection had never been opened? A pending request, a cancellation to send, a subscription, a socket and a child process all came into being because the connection did, so they are the client's. A poll timer registered by feature code, an entry in an aggregated array, a panel on screen and a consent the user granted all exist in the host and merely refer to the connection, so they survive its closing and are the host's to remove. Every row answers that question one way or the other and none answers it both ways, so there is no row that could sensibly be moved and no tenth row hiding — which is the property that makes the table usable as a checklist instead of as a reminder. Nor is misplacing a row a filing error: it puts the responsibility on a component that will have been disposed of before the work needs doing, or on one that has no handle to do it with.
| Resource | Owner | If it is skipped |
|---|---|---|
| In-flight requests awaiting a response | Client | Promises never settle; the caller hangs indefinitely |
| Outbound cancellation for those requests | Client | Server keeps doing work nobody will read |
| Active resource subscriptions | Client | Released with the transport; harmless on close |
| Poll timers and watchdogs from feature code | Host | Reads issued against a closed client; errors every 30 s |
| The transport: pipes, socket, SSE stream (server-sent events, the long-lived HTTP response a remote server pushes notifications down) | Client | File descriptors accumulate; EMFILE at around 1,024 |
| The child process | Client | Orphaned processes, 60 MB and a database connection each |
| That server's tools in the aggregated array | Host | Model calls a tool with no connection behind it |
| UI surfaces backed by the server | Host | Panels showing data that will never refresh |
| Consent decisions remembered for the session | Host | A "always allow" from a previous session silently applies |
The subscriptions row is the one that surprises people, and it is genuinely free: closing
the transport ends the subscription, because a subscription is per-session state on the
server and the session is over. Sending resources/unsubscribe before closing is polite and
unnecessary, and a client that awaits those unsubscribes before proceeding has added a way
for shutdown to hang on a server that is already unresponsive.
Cancellation is not free and is worth doing. A tools/call that is running a 40-second
Postgres query has a server on the other end that will keep running it, and
notifications/cancelled carrying the request id gives the server the chance to abort. It
is a notification, so nothing is awaited and nothing is confirmed, and the server may have
finished already or may ignore it entirely — it is a hint, not a control. What the client
must do unconditionally is settle its own pending promises, because the caller awaiting
callTool is host code that will otherwise sit forever on a connection that no longer
exists.
async close(reason: string): Promise<void> {
this.state = "closing"; // reject new requests immediately
for (const [id, pending] of this.inFlight) {
void this.notify("notifications/cancelled", { requestId: id, reason });
pending.reject(new ConnectionClosed(reason)); // settle OUR side regardless
}
this.inFlight.clear();
await this.transport.close(); // stdio: closes the child's stdin
}async close(): Promise<void> {
this.child.stdin.end();
const exited = once(this.child, "exit");
if (await withTimeout(exited, 5_000)) return;
this.child.kill("SIGTERM");
if (await withTimeout(exited, 2_000)) return;
this.child.kill("SIGKILL");
await exited; // reap it: no zombies
}The five seconds and two seconds are choices rather than constants from anywhere, and they
encode an expectation: a server that has drained its work and is flushing a log should
finish well inside five seconds, and one that has not responded to SIGTERM in two more is
not going to. Awaiting the exit after SIGKILL is what actually reaps the process; a
kill() that is not followed by a wait leaves a zombie in the process table on Linux, which
shows up as a defunct entry rather than as 60 MB, and confuses the next person reading ps
about how many servers are really running.
The escalation in those two functions is a sequence of increasingly rude requests, and each
step exists because the one before it can be ignored. Closing stdin is not a request at all
— it is an end-of-input condition the server discovers on its next read, and a server that
is blocked somewhere else will not discover it. SIGTERM is a signal the process can catch,
so it is a request to shut down that a handler is expected to honour, and a process wedged
in a handler or ignoring the signal will not. SIGKILL cannot be caught, so it is not a
request; the kernel removes the process. Each rung is stronger and less graceful than the
one above it, which is why the order is fixed. And the wait afterwards is not politeness: a
process that has been killed still occupies an entry in the process table until its parent
collects the exit status, so the last step of killing something is asking for the news that
it died.
The part that lives above the connection
Everything up to here is the client's work, and it is the smaller half. The host's obligations begin the moment the client reports closed, and the one with the largest blast radius is the aggregated tool array.
A host with three connections sends the provider one array of tool definitions per turn,
built by merging three lists. When one server goes away, that array has to be rebuilt before
the next request to the model, and the failure mode of forgetting is specific: the model,
seeing a postgres__run_query tool in its context, emits a call for it, and the host looks
up a client that is no longer in the map. If the host's dispatch throws on the missing
entry, the turn dies with an internal error the user cannot act on. If it returns a clean
tool result saying the server is not connected, the model reads that, apologises, and moves
on, which is the difference between a crash and a mild inconvenience. It is worth writing
the second one on purpose, because a call for a removed tool will also arrive from a
provider response that was already in flight when the user hit the toggle, and no amount of
prompt-side hygiene closes that window.
The rest of the host's list is smaller and just as capable of producing a resource leak. Timers registered by feature code — a resource poll loop is exactly this — keep firing against a closed client unless something unregisters them, and each firing produces an error that a well-meaning catch block swallows. Panels have to be dismissed or marked disconnected rather than left displaying data that has stopped updating. Consent decisions scoped to the session need clearing, because "allow this server to write files for the rest of the session" is a grant the user made to a session that has ended, and carrying it into a reconnect means a server can be swapped underneath a permission the user granted to a different one.
When shutdown is not a decision
The same machinery runs when the server dies on its own, and the entry point is different. A
crashed stdio server produces an exit event and a closed pipe; a dropped HTTP stream
produces a connection reset. The client's job is identical — settle in-flight promises,
release the transport — and cancellation is pointless because there is nobody to hear it.
What changes is the host's response afterwards: an unexpected close may warrant a reconnect,
and a deliberate one never does, so the two paths have to be distinguishable at the point
where the host decides. Hosts that route both through one handler and reconnect
unconditionally are how a server the user explicitly disabled comes back three seconds
later.
None of this is enforced by anything. There is no error when a host forgets, no failing request, and no protocol violation to detect, which is the whole reason it is the failure mode that survives longest. It is visible only as memory, file descriptors, database connections or fan noise minutes later, and by then the toggle that caused it is forty minutes in the past.
So a session ends as deliberately as it opens, and the deliberation is entirely on your side of the wire. The client's half is mechanical and bounded: reject the pending map, hint a cancellation, close the transport, reap the child. The host's half is the one that needs writing down, because it is a list of things that were derived from a connection and now outlive it — an array the model still believes in, timers that keep firing, panels that keep showing, and grants the user gave to a session that no longer exists.
A clean teardown is also the baseline against which a shortcut has to be measured. Closing a connection properly costs a few hundred milliseconds and a process spawn to get back, and that cost is exactly what tempts hosts into keeping one client object alive and pointing it somewhere new instead.
Architecture — read the current version at https://daviddominguez.dev/learn/en/architecture/closing-an-mcp-session