Skip to content
Chapter 7 of 7 · ArchitectureThe state a connection carries

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.

ts
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

BreaksNoticedSymptomSilent?
Stale capabilities and tool inventoryMinutesModel calls a tool the new server does not have; a live panel is deadNo, loud
Roots and subscriptions carried acrossHours to daysNew server is told about the old workspace; notifications arrive for URIs from the wrong serverPartly
Pending request ids reused across serversWeeks, or neverA response from server A resolves a call made to server BEntirely

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.

Sequence: Host → Client (reused) (callTool("run_query")); Client (reused) → Server A (id 41); Host → Client (reused) (switchTo(B)); Client (reused) → Server A (close transport); Client (reused) → Server B (connect); Server A → Client (reused) (result for id 41 (buffered, arrives late)).

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 clientReplaced by initialize?Must be cleared
Negotiated protocol versionYes—
Server capabilitiesYes—
Client capabilities offeredYes, as sent—
Pending request mapNoRejected, one by one
Request id counterNoReset, or made per-connection
Active subscriptionsNoDropped
Roots configured for the previous serverNoRecomputed for the new one
Cached tools/list and resources/listNoDiscarded
Progress tokens, the ids a server reports interim progress against on long-running callsNoDropped
Logging level set on the old serverNoRe-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.

StepCold npx serverWarm local binaryRemote HTTP
Resolve and spawn the process900–1,400 ms120–200 msnone
Transport ready~5 ms~5 ms30–60 ms (TLS)
initialize round trip3–10 ms3–10 ms40–90 ms
tools/list + resources/list5–20 ms5–20 ms50–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:

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

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

text
WARN cross_connection_response id=41 issuedOn=7 arrivedOn=8 method=tools/call ageMs=2841

A 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

Back to top