MCP connection pool — investigation notes (2026-04-30)
TL;DR
The MCP plugin already pools connections within a session DO. Production
telemetry attributing 17% of plugin.mcp.connection.acquire calls to a
fresh plugin.mcp.connection.handshake represents cold-start sessions,
not redundant in-session handshakes. No structural change is required;
this PR ships a strict regression test that pins the existing contract
plus a plugin.mcp.cache_hit span attribute so future telemetry can
distinguish hits from misses without cross-referencing handshake counts.
What's already there
packages/plugins/mcp/src/sdk/plugin.ts#makeRuntime:
const connectionCache = yield* ScopedCache.make({
lookup: (key: string) =>
Effect.acquireRelease(
Effect.suspend(() => {
const connector = pendingConnectors.get(key);
...
}),
(connection) => Effect.promise(() => connection.close().catch(() => {})),
),
capacity: 64,
timeToLive: Duration.minutes(5),
}).pipe(Scope.extend(cacheScope));
Properties:
- One
ScopedCacheper plugin instance, captured by the closureruntimeRef. Lifecycle is the executor (one executor per session DO, built inapps/cloud/src/services/executor.ts#createScopedExecutor). - LRU with
capacity: 64,timeToLive: 5 min. - Lookup runs inside a Scope held by
cacheScope, so the pooled connection survivesEffect.scopedboundaries insideinvokeMcpTool. - Cache key (
invoke.ts#connectionCacheKey) isremote:${invokerScope}:${endpoint}for remote sources andstdio:${command}for stdio. IncludesinvokerScopeso per-user OAuth/header secrets never collapse onto a shared connection. invokeMcpToolretries once on connection error: invalidates the cache entry and re-acquires. Stale connections are caught here and transparently re-handshaked.
Why the pool is plausibly correct
packages/plugins/mcp/src/sdk/elicitation.test.ts already had a test
"connection is reused across multiple tool calls to the same source"
that asserts the underlying MCP server sees no new HTTP sessions across
three sequential executor.tools.invoke calls — passes against current
code.
This PR adds two stricter regression cases under
packages/plugins/mcp/src/sdk/connection-pool.test.ts:
- Five sequential invokes of the same tool — one handshake total.
- Different tools on the same source — still one handshake, different tool ids hit the same cache key.
Both pass without any source change. They serve as a deterministic
contract pin so a future refactor that breaks pooling (e.g., scoping
the cache to the per-invoke scope, dropping Scope.extend(cacheScope),
mutating the cache key per call) trips immediately in CI.
Re-reading the production trace
Trace b7102047bed975da461c0519d1251de4:
mcp.plugin.resolve_connector2.72splugin.mcp.connection.acquire2.72splugin.mcp.connection.handshake2.52sexecutor.storage.transaction1.02s (token persist)plugin.mcp.client.call_tool1.48s
Span nesting plus resolve_connector ≈ acquire ≈ handshake durations is
the cache-miss path: resolveConnector only runs (and emits its span)
when the cache lookup actually invokes the lookup closure, which only
happens on a miss.
8h aggregate:
plugin.mcp.connection.handshakecount 4plugin.mcp.connection.acquirecount 24mcp.plugin.resolve_connectorcount 4
Acquire count is 6× handshake count. The cache is hitting on 20 of 24 calls. The 4 misses are cold-start sessions (each MCP session DO's first tool call, plus any session where the connection went stale and was invalidated by the retry path). Six tool calls per session is consistent with normal MCP usage.
Why we can't drive misses below the cold-start floor
Every miss observed in prod is structurally unavoidable inside a single DO:
- First tool invocation in a fresh DO.
init()builds the executor and a freshScopedCache. The first invoke must handshake. - Stale-connection retry.
invokeMcpToolinvalidates the cache entry onclient.callToolfailure and re-acquires; that re-acquire is necessarily a miss. - TTL expiry. Set to 5 minutes. The session DO's idle alarm fires at 5 minutes too, so any cache entry that out-survives a session was going to be discarded with the DO anyway.
To go below this floor we'd need either:
- Pre-warm in
init(). Plausible but speculative — we don't know which sources the user will invoke, and warming all of them bottlenecksinit()on the slowest server. Out of scope; would also reverse the savings if the user never calls those tools. - Cross-DO connection pool. Forbidden by the task — and the
per-DO scope of
runtimeRefis the right home for a per-user-org connection pool given the cache key includesinvokerScope.
What this PR ships
packages/plugins/mcp/src/sdk/connection-pool.test.ts— two strict regression tests pinning the per-session pooling contract.packages/plugins/mcp/src/sdk/invoke.ts— addsplugin.mcp.cache_hit: booleanattribute to theplugin.mcp.connection.acquirespan. Future Axiom queries can read the hit rate directly without comparing acquire vs handshake counts.
No behavior change.
Follow-ups
- If the cold-start p99 still bites tail latency, consider warming the
most-recently-used source(s) for a session DO during
init()— scoped to the user's recent activity, e.g., on session restore. - Watch
plugin.mcp.cache_hit=falserate over a longer window. If it exceeds the implied cold-start floor (≈ 1 / avg-calls-per-session), there's a real bug to chase.