Skip to content
Chapter 6 of 7 · ArchitectureClosing an MCP session

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:

http
DELETE /mcp HTTP/1.1
Host: docs.internal
Mcp-Session-Id: 5f2c9a1e-7b40-4c1a-9a2f-0d1c3e8a6b77

A server that has already forgotten the session answers 404, which is a successful outcome for this request and should not be retried.

Sequence: User → Host (disable postgres server); Host → Host (stop dispatching to server 2); Host → Client 2 (close()); Client 2 → Server process (notifications/cancelled (id 41)); Client 2 → Server process (close stdin (EOF)); Server process → Client 2 (process exits, code 0); Client 2 → Host (closed); Host → Host (drop tools, clear timers, rebuild tool array).

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.

ResourceOwnerIf it is skipped
In-flight requests awaiting a responseClientPromises never settle; the caller hangs indefinitely
Outbound cancellation for those requestsClientServer keeps doing work nobody will read
Active resource subscriptionsClientReleased with the transport; harmless on close
Poll timers and watchdogs from feature codeHostReads 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)ClientFile descriptors accumulate; EMFILE at around 1,024
The child processClientOrphaned processes, 60 MB and a database connection each
That server's tools in the aggregated arrayHostModel calls a tool with no connection behind it
UI surfaces backed by the serverHostPanels showing data that will never refresh
Consent decisions remembered for the sessionHostA "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.

ts
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
}
ts
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

Back to top