Practical technical guides / AI engineering

One MCP Server Process, Many Agent Chats: Shared Connections and Per-Chat Leases

Pooling one stdio MCP server across chats needs per-chat leases, host-owned lease values, connection-wide request ids and cancel-before-release. An original model of each failure.

By Sandeep Belgavi · Published 2026-11-12

One connection, many chats: Chat leases, then Host-owned ids, then Cancel, then release
Original conceptual diagram by Sandeep Belgavi; no measured performance is represented.

Pooling one stdio MCP server across chats needs per-chat leases, host-owned lease values, connection-wide request ids and cancel-before-release. An original model of each failure.

Why share a server connection at all

A local MCP server over the stdio transport is a subprocess. The client launches it, writes JSON-RPC messages to its standard input and reads responses from its standard output. The simplest host gives every chat its own subprocess. That is easy to reason about and expensive in practice: a help-desk host at Belgavi's Book Store that keeps forty chats open would start forty copies of the case-management server, each with its own database pool, cache and start-up cost.

So hosts start to pool. One server process, one stdio connection, many chats sending tool calls through it. The OpenAI Codex CLI release 0.167.0, published on 6 November 2026, includes a change titled "Add per-chat leases for shared MCP connections" (pull request 57978). Its description says the host negotiates a lease extension for pooled stdio connections, opens a unique lease per chat for servers that advertise it, attaches the host-owned lease id to tool calls and resource reads, overrides any lease metadata the caller supplied, and, when a chat releases its lease, writes cancellations for that chat's pending requests before sending the release. Servers without the extension keep the old behaviour.

That description is a compact checklist of everything that goes wrong when you multiplex conversations over one connection. This guide works through each item with an original model of a stateful help-desk server. It is not Codex's code and the extension name in the examples is invented; the mechanisms are general to any host that pools MCP connections.

What a connection-scoped server assumes

Many MCP servers keep state per connection without saying so. A case-management server might offer an open_case tool that selects a case, and an add_note tool that writes to the selected case. With one chat per connection that is a reasonable design: the connection is the conversation. Put two chats on one connection and the assumption breaks.

The companion replays two chats over one connection. Geetha's chat opens CASE-4410. Jenya's chat opens CASE-4415. Geetha's chat then adds the note "customer sent the return label". Without leases the server has one selected case for the whole connection, which is now CASE-4415, so Geetha's note lands on Jenya's case. Nothing errors. Both chats see a success message. The note is simply in the wrong place.

Nothing in the MCP protocol is violated here. The specification gives each request an id and each connection a lifecycle; it does not say that a stdio connection represents one user conversation, and a host that pools has taken on the job of keeping conversations apart.

A lease is a conversation id the server can trust

The fix is to give the server something to scope state by. The host opens a lease for each chat and puts the lease id into every request it forwards. MCP reserves the _meta field on requests for exactly this kind of protocol-adjacent metadata and defines a key format with an optional reverse-DNS prefix, so a host-specific key such as com.example.store/lease is a legitimate place to carry it.

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "add_note",
    "arguments": {"text": "customer sent the return label"},
    "_meta": {"com.example.store/lease": "lease-1"}
  }
}

A server that supports the extension keys its selected case by lease instead of by connection. In the model, with leases on, Geetha's note goes to CASE-4410 and Jenya's to CASE-4415. The server advertises support during initialisation, and a host must not assume support from a server that did not advertise it: falling back to one connection per chat is the safe default for servers that do not.

The host owns the lease value

The second detail in the Codex description is easy to skip: the host overrides caller-supplied lease metadata. Tool calls in an agent host are shaped partly by the model. If the host lets arguments or metadata from the model or from a chat client pass through untouched, then a chat can name another chat's lease and act inside its scope.

The model makes this concrete. Jenya's chat sends add_note with _meta set to Geetha's lease. With overriding off, the note "asked for a replacement copy" lands on CASE-4410, Geetha's case. With overriding on, the host replaces the value with Jenya's own lease before forwarding, and the note lands on CASE-4415.

def forward(self, chat, name, arguments, meta=None):
    params = {"name": name, "arguments": arguments, "_meta": dict(meta or {})}
    if chat in self.leases:
        params["_meta"][LEASE_KEY] = self.leases[chat]   # never trust the caller's value
    request_id = next(self.ids)                           # unique per connection
    self.owner[request_id] = chat
    self.server.send({"jsonrpc": "2.0", "id": request_id,
                      "method": "tools/call", "params": params})
    return request_id

The rule generalises: any identifier the server uses for isolation must be assigned by the component that owns isolation, never accepted from the component being isolated.

Request ids belong to the connection, not to the chat

JSON-RPC routes responses by id, and the MCP specification says a requestor must not reuse a request id within the same session. A host that lets each chat number its own requests from 1 will produce two requests with id 1 on the shared connection, and the second response will be routed to whichever chat the host's table happens to map. The host must allocate ids from one counter per connection and keep its own map from id to chat. The companion's test checks that every forwarded call on the connection has a distinct id.

Release: cancel first, then let go

Chats end while work is still in flight. In the model, Geetha's chat sends a slow add_note with the text "refund approved", and the chat is closed before the server finishes it. Two orderings are possible.

If the host simply releases the lease and forgets the chat, the server still completes the request. The note "refund approved" is written to CASE-4410 after the conversation that asked for it has ended, and the response arrives for a chat that no longer exists, so the host drops it. A side effect happened that no user can see or undo from the interface.

If the host first sends notifications/cancelled for each pending request of that chat, then the release, the server can stop the work. The MCP cancellation rules say a receiver should stop processing and not send a response, may ignore a cancellation it cannot honour, and that the sender should ignore any response that arrives afterwards. The ordering matters because the release can tell the server to drop the lease's state, and a request processed after that point may fall back to the connection-wide scope, which is the original bleed bug again.

def release(self, chat):
    pending = [rid for rid, c in self.owner.items() if c == chat and rid in self.inflight]
    for rid in pending:                                 # 1. cancel the chat's work
        self.server.send({"jsonrpc": "2.0", "method": "notifications/cancelled",
                          "params": {"requestId": rid, "reason": "chat closed"}})
    self.server.send(release_message(self.leases.pop(chat)))   # 2. then release
    for rid in pending:
        self.owner[rid] = None                          # 3. ignore late responses

Cancellation is advisory. A server may already have committed the write when the notification arrives, so tools with side effects still need idempotency keys and a way to look up what happened. The Codex description also mentions bounding the wait for unload and removing a connection from the pool when lease cleanup fails or times out. That is the conservative choice: a connection whose lease state you cannot confirm is no longer safe to share.

Failure modes to test

  • A server that does not advertise lease support is pooled anyway. Expect state bleed; the host should refuse to pool it.
  • Lease ids are predictable and accepted from callers. Expect cross-chat actions. Override on forward.
  • Request ids are per chat. Expect misrouted responses.
  • Release before cancel. Expect late writes after the user has left.
  • A crashed chat never releases. Leases need an expiry or a host-side sweep, and the server must bound per-lease state.
  • Resource subscriptions and notifications from the server are connection-wide. A server pushing notifications/resources/updated cannot address one chat unless the notification carries the lease, so a host must decide which chats see it.

When not to pool

Pooling saves process start-up and memory. It costs isolation guarantees that a separate process gives for free. If the server holds credentials scoped to one user, runs code on behalf of the conversation, or is third-party code you have not reviewed, give each chat its own process and accept the cost. Pool only servers you control, that advertise and honour lease scoping, and whose tools are idempotent or cheap to reconcile.

Run the example yourself

An in-process model of one shared connection, two chats, host-owned leases and cancel-before-release, with tests for each failure described above. No real MCP server or model is used. Companion code on GitHub; its README lists the prerequisites, exact commands, expected output and limits.

Primary references and authorship

Sources read 2026-11-12. Original explanation and synthetic examples by Sandeep Belgavi. Views are my own and do not represent any employer, past or present.