Skip to content

refactor(mcp): give pooled connections an explicit lifetime (#563) - #622

Merged
Weegy merged 3 commits into
mainfrom
issue/563
Aug 7, 2026
Merged

refactor(mcp): give pooled connections an explicit lifetime (#563)#622
Weegy merged 3 commits into
mainfrom
issue/563

Conversation

@Weegy

@Weegy Weegy commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What

Closes #563. Gives pooled MCP connections an explicit lifetime.

The MCP pool kept its state in two parallel maps keyed by server id plus a hash of
the caller's bearer token. Since a stdio child process never sees that token, N callers
with N tokens spawned N identical child processes for the same server. This replaces the
two maps with one entry map and one set of rules:

  • Key carries exactly what the transport consumes — stdio by server id alone,
    http/sse by server id + token hash.
  • Dropped on failed connect, failed tool call, rejected stale token, explicit
    close(serverId), idle-TTL expiry, and closeAll().
  • Idle eviction is lazy (inside getOrConnect, never a timer, so nothing keeps the
    process or node --test alive).
  • Server delete / config save / token revoke now invalidate the runtime pool too, and
    SIGTERM/SIGINT no longer leaves stdio children behind.

Rationale and rejected alternatives: docs/adr/0008-mcp-connection-lifetime.md.

Status — the previously-documented blocker is resolved

An earlier revision of this description reported that cd middleware && npm test failed
4 of 4 runs on this branch, in unrelated suites, whenever both new test files were
present. That was the parallelism/resource-exhaustion problem tracked as #564.

#613 fixed it and is now on main (71079cac). This branch has been merged up to main
and re-verified end to end:

Gate Result
npm test (middleware) green 3/3 runs — 6075 pass, 0 fail, ~35–49 s
npm run lint pass
npm run typecheck pass
node scripts/check-core-decoupling.mjs pass (held at 3448)

Merging main required five conflict resolutions

Main moved a long way (#550 MCP waves 0-6, #624, #613), so this is not a trivial
fast-forward:

  • mcpClient.ts — kept both this PR's pool-lifetime members and main's
    structuredSink / pendingInput options and outputSchemas cache.
  • src/index.ts — kept the runtimeMcpManager handle alongside main's structured-sink
    wiring and the W2-1 (Support MRTR (resultType: input_required) — mid-call user input for MCP tools #544) input replayer.
  • routes/agentBuilder.ts — kept main's W0-1 ownership check on
    DELETE /mcp-servers/:id/token (404/403, fail-closed) and added the pool
    invalidation after the token row is deleted.
  • ADR number collision — main landed 0007-mcp-client-id-metadata-documents.md
    (dated 2026-07-30) first, so this ADR was renumbered 0007 → 0008 and every
    reference updated.
  • CHANGELOG.md — both Unreleased entries retained.

One real test fix fell out of the merge: mcpPool.test.ts's serverRow() fixture predates
#550 and omitted the now-required delegation field, so resolveMcpUserKey failed closed
and the token-revocation test saw 403 instead of 204.

The new tests are load-bearing (mutation-checked)

Green runs alone do not prove coverage, so both new behaviours were mutated:

  • Disabling options.onMcpServerChanged?.(serverId)3 tests fail.
  • Weakening the # pool-key separator to a bare startsWith(id)2 tests fail.
    (Note: these tests import the built package, so a src/ mutation only bites after
    rebuilding harness-orchestrator — the first attempt silently passed against stale
    dist/.)

Behaviour changes worth a reviewer's attention

  • McpManager.close(serverId) semantics widened. It now closes every token-scoped
    connection of that server rather than one exact pool key. Passing a full pool key still
    matches only itself, and a server id never matches a different server whose id shares its
    prefix (mcpPoolScopeMatches).
  • stdio connections are now shared across callers. Verified rather than assumed: in
    makeTransport the stdio branch consumes only command/args/env, and env comes
    from getConfigEnv(cfg), which is keyed by server config with no caller identity. No
    caller-specific material reaches the child, so de-duplication is safe. If stdio ever
    gains token-derived env, poolKey must change back in lockstep.
  • Idle connections are dropped after 5 minutes (idleTtlMs), so a rarely-used stdio
    server pays a respawn on next use.

Known follow-up (not a blocker)

MCP_POOL_IDLE_TTL_MS (300 s) is a new member of this codebase's timeout hierarchy but is
not registered in test/orchestrator/timeoutHierarchy.test.ts, which exists precisely
to catch inversions of this kind. With defaults it is safe — the absolute call ceiling is
180 s and the dispatch deadline 240 s, both below the TTL — but nothing pins that ordering.
Lowering idleTtlMs below the call ceiling (or raising
OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS above the TTL) would let evictIdle close a
connection out from under an in-flight call, since lastUsedAt is refreshed on
getOrConnect and not for the duration of the call. Worth a one-line invariant assertion
in a follow-up.

Issue: #563

Weegy added 3 commits August 6, 2026 18:18
The pool kept its state in two parallel maps keyed by server id plus a hash
of the caller's bearer token, and that split bookkeeping hid four defects:
a stdio child was spawned per token although the token never reaches the
child process; nothing ever removed an entry, so every token rotation leaked
one (and, for stdio, a process) for the life of the middleware; closing an
in-flight connect left the client it later resolved to unowned; and no route
or shutdown path invalidated anything, so a deleted, reconfigured or
disconnected server kept being served with its old command, env, headers and
token.

One entry map replaces the two, keyed by exactly what each transport
consumes, with a lazily-swept idle TTL and explicit invalidation from the
three mutating operator routes and from shutdownBuilder. close(serverId) is
widened to drop every token-scoped entry of that server — the scope rule is
exported as mcpPoolScopeMatches so the no-collateral guarantee lives in one
place and can be tested.

The subsystem had no pooling coverage at all, which is why the defects
survived; the new suites assert on real spawned child processes rather than
on the internal map.
npm test runs one OS process per test *file*, all in parallel. The pool work
landed two new files, and this pair was the only place in the suite spawning
grandchild processes and booting loopback listeners, so it added the largest
per-file footprint of the branch to a run that is already CPU-saturated.

Fold the route-invalidation assertions into test/mcpPool.test.ts so the branch
adds one scheduled process instead of two; serve all three route assertions
from a single express listener instead of one per test (the store stubs are
stateless, so sharing is safe); and let AC4's "closeAll kills the child" ride
on AC1's already-pooled child instead of spawning its own. Both suites now
state concurrency: 1 explicitly — it is the default, but it is load-bearing
here because these tests count child processes.

Coverage is unchanged: same eight assertions, same acceptance criteria, all
still asserted on process identity and at the route boundary.
Resolves five conflicts against main (#550 MCP waves 0-6, #624, #613):

- mcpClient.ts: keep BOTH the new pool-lifetime members (entries map,
  idleTtlMs, MCP_POOL_IDLE_TTL_MS, mcpPoolScopeMatches) and main's
  structuredSink / pendingInput options and outputSchemas cache.
- src/index.ts: keep the runtimeMcpManager handle alongside main's
  structured-sink wiring and the W2-1 (#544) input replayer.
- routes/agentBuilder.ts: keep main's W0-1 ownership check on
  DELETE /mcp-servers/:id/token (404/403 fail-closed) and invalidate the
  pooled connection after the token row is deleted.
- docs/adr: main landed 0007-mcp-client-id-metadata-documents (2026-07-30)
  first, so this ADR is renumbered 0007 -> 0008 and every reference updated.
- CHANGELOG: both Unreleased entries retained.

Also fixes the mcpPool fixture: serverRow() predates #550 and omitted the
required `delegation` field, so resolveMcpUserKey failed closed and the
token-revocation test got 403 instead of 204.

Verified on the merge result: lint, typecheck and the core-decoupling
ratchet pass, and `npm test` is green 3/3 (6075 pass, 0 fail, ~35-49s) --
the parallelism failures documented in the PR body are resolved by #613,
now on main. Both new behaviours mutation-checked: disabling
onMcpServerChanged turns 3 tests red, weakening the '#' pool-key separator
turns 2 red.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Simplify MCP connection pooling

1 participant