Chapter 7 of 7 · 16 min read
The state a connection carries
On this page
A common host design creates one MCP client at process start and keeps it for the life of the application, swapping its transport when the user picks a different server. The reasoning behind it is a real observation about a real product — reconnecting is expensive and users switch often — and the shape it produces looks like connection pooling done frugally.
It is worth taking the observation seriously before taking the design apart, because the cost it names is real and the alternative has to answer it. What the design gets wrong is not the goal but the object it economises on: a client is not a socket wrapper that can be re-pointed, it is the thing holding everything the last handshake produced, and the expensive part of a reconnect turns out to be somewhere else entirely.
class Session {
private client = new Client({ name: "acme-ide", version: "5.2.0" }, { capabilities });
async switchTo(spec: ServerSpec) {
await this.client.transport?.close();
this.client.transport = new StdioClientTransport(spec); // same object, new pipe
await this.client.transport.start();
// capabilities and protocol version carried over from the previous server
}
}That design is wrong in three separate ways, and they surface on three very different timescales, which is what makes the pattern durable: the first problem gets fixed, the code keeps its shape, and the other two stay.
Three failures, ordered by when anyone notices
| Breaks | Noticed | Symptom | Silent? |
|---|---|---|---|
| Stale capabilities and tool inventory | Minutes | Model calls a tool the new server does not have; a live panel is dead | No, loud |
| Roots and subscriptions carried across | Hours to days | New server is told about the old workspace; notifications arrive for URIs from the wrong server | Partly |
| Pending request ids reused across servers | Weeks, or never | A response from server A resolves a call made to server B | Entirely |
The first is the one that gets the bug report. Server B is a build from three weeks earlier
that declares resources: {} where A declared subscribe: true, so a panel that worked
before the switch shows nothing after it, and the model — still holding A's tool array,
since nothing re-ran tools/list — emits a call for a tool B has never heard of and gets
-32602 back. Every part of that is visible within a few minutes of the first switch, and
every part of it is repaired by re-running initialize and rediscovering, which is why
re-running initialize feels like the fix.
The second is subtler because it is not an error. Roots are the workspace directories
the client tells a server it may operate within, and a host that scopes them per server —
the review tool gets the repository, the notes server gets one folder under ~/Documents —
has that scoping stored on the client object. Reconnecting the object without clearing it
means the next roots/list from server B is answered with the directories that were
configured for A, so B is told about a repository it was never granted. Nothing errors; B is
simply working with a wider view than the host intended, and the only way to see it is to
read a trace and notice the paths. The subscription list carries in the same way: entries
registered against A's deploy://prod/status are still in the client's map, and when B
sends notifications/resources/updated for a URI that happens to match, the client
dispatches it into a handler that was set up for a different server's data.
The third is the one that survives every review, and it is worth being exact about the mechanism because "it might get confused" is not an argument anyone acts on. JSON-RPC ids are unique within a connection and nowhere else, so both servers can legitimately have a request with id 41. The client keeps a map from id to pending promise, and reusing the object means reusing that map.
Reading that diagram as a sequence of individually reasonable steps is what makes the outcome believable, since nothing in it is a bug in the ordinary sense. Sending a request under an id and remembering the id is correct. Closing a transport when the user switches away is correct. Accepting whatever the old server had already written into the pipe is unavoidable, because those bytes were in flight before the close and no close reaches backwards. Matching an incoming response to the pending entry with the same id is correct, and it is correct precisely because that is the only thing an id is for. Every step is right, and the outcome is wrong, because one assumption held by the matching step — that an id identifies a request uniquely — was true of the object it was written for and stopped being true when that object was made to outlive its connection.
The window is small and it is not zero. A run_query against staging that takes 40 seconds,
a user who switches server while waiting, and a response that was already written into the
pipe before the close — that is a resolved promise carrying data from a server the user has
just left, handed to UI code that will label it with the current server's name. Closing the
transport does not settle the pending map, so unless something explicitly rejects those
promises they sit there waiting for an id that the new connection will eventually issue on
its own. The version of this that costs money is a tool result from a staging database
rendered under a production server's heading.
The request-id counter deserves a sentence of its own, because the obvious remedy makes the collision more likely rather than less. Resetting the counter to 1 on each reconnect guarantees that the new connection will re-issue exactly the low ids the old one was still waiting on, so a design that carefully resets its counter has arranged for ids 1 through 40 to be reused within seconds of the switch. Letting the counter run on — 41, 42, 43 across both servers — narrows the window to the ids that were genuinely outstanding at the moment of the swap, which is a smaller set but never empty, and it is the reason this bug reproduces on a laptop about once in two hundred switches and never in a test suite.
Working through why the reset makes things worse is the clearest small example in this chapter of an intuition that inverts under inspection, so it is worth doing rather than taking on the sentence above. The danger is an id being reused while an old request under that id is still outstanding. Which ids are outstanding at the moment of a swap? Only the ones the old connection issued and has not had answers for — a handful, and they are whichever numbers the counter happened to be around. Now consider the two policies. Letting the counter run on means the new connection's next id is one past the highest the old connection ever issued, so it cannot collide with anything outstanding until it has run all the way around, and by then the old responses are long gone. Resetting to the start means the new connection's first ids are the lowest numbers, which are precisely the ones the old connection issued first — and therefore precisely the ones most likely to still be waiting, since a long-running call is one that was issued early. The reset does not merely fail to help. It picks, out of every possible starting point, the one with the highest chance of overlap. The general lesson is that "start clean" is a good instinct about state you own and a bad one about identifiers shared with something that is still talking.
Re-running initialize is half the work
The instinct to re-negotiate is correct and it addresses exactly one row of that table.
initialize replaces the protocol version and both capability objects, and it does nothing
to the pending map, the subscription list, the roots, the request-id counter, the cached
tool array, or any per-session context the client built from the previous server's
serverInfo.
| State on the client | Replaced by initialize? | Must be cleared |
|---|---|---|
| Negotiated protocol version | Yes | — |
| Server capabilities | Yes | — |
| Client capabilities offered | Yes, as sent | — |
| Pending request map | No | Rejected, one by one |
| Request id counter | No | Reset, or made per-connection |
| Active subscriptions | No | Dropped |
| Roots configured for the previous server | No | Recomputed for the new one |
Cached tools/list and resources/list | No | Discarded |
| Progress tokens, the ids a server reports interim progress against on long-running calls | No | Dropped |
| Logging level set on the old server | No | Re-sent if wanted |
The Yes column and the No column in that table are not an arbitrary split, and knowing what
separates them lets you place a row the table does not have. initialize is a message
exchange, so the only things it can replace are the things carried in that exchange: a
protocol version and two capability objects, which is exactly the three rows marked Yes.
Everything else on the client came into existence some other way — a counter incremented by
sending, a map written to by awaiting, a list built by subscribing, a cache filled by
discovering — and a message exchange has no reach into any of them, because nothing in the
handshake mentions them. So the rule is not "initialize replaces some state and not other
state", which sounds like a quirk. It is that a handshake can only replace what a handshake
carries, and everything the client accumulated by operating is untouched by definition.
Any future row belongs in the No column unless the negotiation itself starts carrying it.
Writing a reset() that walks that list is possible and it is the wrong shape, because the
list grows. Every future protocol feature that puts state on a connection — the next
capability, the next notification stream — adds a row, and the person adding it will not
know that a reset() exists in a file two directories away. Constructing a fresh object has
no such maintenance surface, since a new object has no state by definition.
What a reconnect actually costs
The defence of the reuse design is quantitative, so it deserves a quantitative answer rather than a rule. Reconnect cost is not one number; it is four, and only one of them is large.
| Step | Cold npx server | Warm local binary | Remote HTTP |
|---|---|---|---|
| Resolve and spawn the process | 900–1,400 ms | 120–200 ms | none |
| Transport ready | ~5 ms | ~5 ms | 30–60 ms (TLS) |
initialize round trip | 3–10 ms | 3–10 ms | 40–90 ms |
tools/list + resources/list | 5–20 ms | 5–20 ms | 50–120 ms |
| Total | ~1.0–1.4 s | ~150–235 ms | ~120–270 ms |
Each column in that table is the four steps added down, and checking one of them is what makes the conclusion below something you have verified rather than accepted. The remote column is the easiest: no process is spawned, so the first step contributes nothing, and the remaining three added at their low ends and again at their high ends reproduce the stated range exactly. Doing the same on the warm column shows the totals are rounded rather than exact, which is the right amount of precision for figures quoted as bands. What the addition buys is the ability to ask which step would have to change to move the total, and in every column that question has one answer.
The protocol is not the expense. The handshake is a single round trip on an already-open
pipe, and even against a remote server the whole negotiation is under a tenth of a second;
what costs a second is npx resolving a package before spawning it. That reframes the
problem, because the thing worth avoiding is the process spawn, and the client object was
never what was holding the process open — the transport was.
The design that follows is a pool keyed by server id, holding one client per server, with idle eviction:
class ConnectionPool {
private clients = new Map<string, { client: Client; lastUsed: number }>();
async acquire(spec: ServerSpec): Promise<Client> {
const existing = this.clients.get(spec.id);
if (existing) {
existing.lastUsed = Date.now();
return existing.client; // no spawn, no handshake
}
const client = new Client(this.identity, { capabilities: this.capsFor(spec) });
await client.connect(new StdioClientTransport(spec));
this.clients.set(spec.id, { client, lastUsed: Date.now() });
return client;
}
// Runs every 60 s. 10 minutes idle, minus whatever the user is on now.
async evictIdle(now = Date.now()): Promise<void> {
for (const [id, entry] of this.clients) {
if (id === this.activeId || now - entry.lastUsed < 600_000) continue;
this.clients.delete(id);
await entry.client.close(); // full teardown
}
}
}The eviction window is policy rather than mechanism, and it should be set from how long a user typically takes to come back to a server rather than from a round figure. The exempt check on the active connection is the line in that method most likely to be dropped in review, because it looks like a special case rather than what it is: the difference between an idle pool and a pool that closes the connection currently in use.
Measuring the collision before arguing about it
Whether the id collision is happening in a real product is answerable, and the instrumentation is a single integer. Stamp every pending entry with the epoch of the connection that issued it, increment the epoch on every transport swap, and refuse to settle an entry whose epoch does not match the transport the response arrived on:
type Pending = { epoch: number; method: string; sentAt: number; settle: Settler };
private onResponse(msg: JSONRPCResponse, epoch: number): void {
const p = this.pending.get(msg.id);
if (!p) return;
if (p.epoch !== epoch) {
log.warn("cross_connection_response", {
id: msg.id, issuedOn: p.epoch, arrivedOn: epoch,
method: p.method, ageMs: Date.now() - p.sentAt,
});
return; // drop it: never resolve across epochs
}
this.pending.delete(msg.id);
p.settle(msg);
}The line it produces is the evidence a review needs:
WARN cross_connection_response id=41 issuedOn=7 arrivedOn=8 method=tools/call ageMs=2841A host that ships this check and sees the warning a handful of times a week has measured the
failure instead of arguing about it, and a host that never sees it has ruled the mechanism
out. What the check does not do is make the design correct, because dropping the response
leaves the caller's promise unsettled and it will hang until whatever timeout the host wraps
around callTool expires — thirty seconds in most implementations, and forever in the ones
that never added a timeout. The epoch guard belongs beside the rejection of the pending map
on close, not in place of it.
If the pool is more than the moment allows, the minimum correct change is to make the client object's lifetime equal to the session's: close the old client fully, discard it, and construct a new one for the new server. That is a handful of lines and it removes every row of the state table at once, because there is no surviving object for state to survive on. It does not fix the cost the author raised, since every switch still pays the spawn — closing properly is not free either, being a five-second grace period in the worst case, though a healthy server exits on stdin close in a few tens of milliseconds.
More importantly, it fixes nothing above the client. The aggregated tool array, the consent grants scoped to "this session", cached resource contents in the interface, and any panel bound to the old server's data all live in the host, and a fresh client object does not clear any of them. A host that swaps clients correctly and keeps showing the previous server's tool list has moved the bug up one layer, where it is harder to see because there is no object lifecycle to point at. What removes that class of bug above the client is the same move that removed it below: give the host's derived state the lifetime of the connection it was derived from. In practice that means every cached entry carries the connection id it was built against — the aggregated tool array, the consent grants, the contents backing each panel — and every read is keyed by the currently active connection rather than by the server's display name. A grant recorded against connection 7 is simply not found when connection 8 asks for it, so the stale value is unreachable instead of merely being wrong.
The rule that comes out of all of this is a lifetime rule rather than a protocol one. A connection carries version, capabilities, ids, subscriptions, roots, caches and progress tokens, and every one of those is meaningful only against the server it was negotiated with. Tie each of them to an object whose life is the connection's life and they cannot outlive it; keep one object alive across two connections and every single one becomes something a future contributor has to remember to clear.
That is also why the three failures arrive on three timescales rather than together. The
loud one is loud because the model and the interface both read the capability set on the
next turn, so a stale one is contradicted within minutes. The quiet ones are quiet because
roots, subscriptions and pending ids are consulted only when something specific happens — a
roots/list from the new server, a notification whose URI happens to match, a response that
arrives after a swap — and none of those is guaranteed to happen during the week the change
is being reviewed. A design whose worst failure requires a coincidence to surface is a
design that ships.
The boundaries, the handshake, the declaration, the trace, the fallback and the teardown are all structure — the shape a connection has and the rules it lives by. What travels across it is a different subject: the tools with their input schemas and annotations, the resources with their URIs, the prompts, and the design decisions that make a server worth connecting to at all. Every one of those definitions is negotiated, discovered and torn down by the machinery in this topic.
Architecture — read the current version at https://daviddominguez.dev/learn/en/architecture/the-state-a-connection-carries