feat(mcp): keep unchanged MCP servers alive across a classic /reload - #377
Merged
Conversation
code-yeongyu
force-pushed
the
perf/mcp-reload-measure
branch
from
July 26, 2026 10:14
c85d0a4 to
b22189c
Compare
Classic reload no longer disposes the MCP singleton, so the next attach re-enters config-hash reconciliation: unchanged servers keep their process, changed ones reconnect, removed ones are disposed. Provider-scoped services still dispose on reload, since every factory execution owns a fresh McpService. To make that safe when the MCP builtin is disabled during the same reload, core now emits session_extensions_removed to extensions present in the old runner but absent in the new one, right after the runtime is rebuilt. MCP subscribes and disposes when its own <builtin:mcp> entry is removed, so a disabled MCP can never leak its connections, child processes, or reconnect timers. Measured motivation: dispose+respawn is ~400ms per reload (constant, independent of server count) versus ~1ms for preserve+reconcile, on a reload that costs ~477ms total. /mcp reconnect <name> stays the wedged-server escape hatch, covered by a kill-the-child-then-reconnect test.
Reload only reported session_extensions_removed after _buildRuntime succeeded, so a rebuild that threw (for example _refreshToolRegistry rejecting an extension's tool metadata) left a removed extension orphaned. The emit now runs in a finally block. /new, /resume, /fork, import, and cross-cwd switches never reported removal at all, so a classic MCP singleton preserved across those shutdown reasons could leak when the rebuilt runtime omitted it. teardownCurrent now captures the old runner and its extension identities, and apply reports the diff through the same event, so every replacement path notifies removed extensions.
…runners Test hosts and partial ExtensionRunner implementations do not expose getExtensionIdentities; teardownCurrent now only captures and reportRemovedExtensions only emits when both runners expose it, so those hosts degrade to the previous behavior instead of throwing during session replacement. Caught by CI's rpc-session-registry suite, which uses a minimal runner stub.
code-yeongyu
force-pushed
the
perf/mcp-reload-measure
branch
from
July 26, 2026 10:50
b22189c to
9962222
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
/reloaddisposes every MCP connection and the next session respawns every server, even when nothing about their configuration changed. Measured on real stdio children:/reload)The cost is a roughly constant ~400 ms floor per reload (independent of server count — 2 vs 5 servers barely moves it), against a total reload of ~477 ms. It is the single largest remaining reload cost, and it is entirely self-inflicted: the service already has config-hash reconciliation (
#syncFromConfig, keyedname\0configHash) that disposes only changed or removed servers. That path ships today for shutdown reason"new"—/reloadjust never used it.What changed
Classic reload preserves the MCP singleton. The next attach re-enters reconciliation: unchanged servers keep their process, changed ones reconnect, removed ones are disposed.
quitstill disposes.Provider-scoped services still dispose on reload, deliberately.
mcpExtensionbuildssessionOwned ? new McpService() : getMcpService(), so under provider scopes (multi-session RPC) every factory execution creates a fresh service, and reload re-executes factories. Skipping disposal there would orphan the previous instance's child processes.Core now tells an extension it was removed by a reload. After the runtime is rebuilt,
AgentSessioncompares old vs new extension identities and emitssession_extensions_removedto any extension present in the old runner but absent in the new one. This is the part that makes preservation safe: if the MCP builtin is disabled during that same reload, shutdown alone would skip disposal and nothing would ever re-attach, leaking the preserved connections. MCP subscribes and disposes when its own<builtin:mcp>entry is removed. The mechanism is extension-agnostic — core knows nothing about MCP.This closes both leak vectors the earlier attempts hit. Removing
"reload"fromshouldDisposeMcpServiceoutright leaked provider-scoped services; making that scope-aware alone then leaked when MCP was disabled mid-reload. Each is now covered by a dedicated test./mcp reconnect <name>remains the wedged-server escape hatch, covered by a kill-the-child-then-reconnect test.Evidence
58 tests pass in one run across the MCP and reload suites; 127 pass across the 16 affected suites.
npm run checkexits 0. The cost table above is from a dedicated A/B probe using the existing fixtures (local-ignore/mcp-reload-cost.mts,reason "reload"vsreason "new", real stdio children, 6 cycles).The leak-guard tests that matter:
ProviderScope, real stdio child, asserts the child pid is dead (the first-round leak, guarded).quitdisposes in both modes; three consecutive classic reloads hold connection and spawn counts steady.session_extensions_removed.Deliberately flipped assertions
test/mcp/extension-load.test.tspinned the old always-dispose contract and was updated on purpose: reload moved from the disposal set to the retained-session set; the reload-followup test now asserts the same singleton survives with two session starts instead of expecting a fresh service.Summary by cubic
Classic
/reloadnow preserves unchanged MCP servers by reusing the sharedMcpService, cutting reloads by ~400ms. Core reportssession_extensions_removedon the old runner for all session replacements and even if a reload rebuild fails, so disabling the MCP builtin cleanly disposes preserved servers.New Features
McpServicealive across/reload; unchanged servers keep their process, changed reconnect, removed dispose./mcp reconnect <name>remains the manual recovery path for wedged servers.Bug Fixes
session_extensions_removedon the old runner for all replacements (/new,/resume,/fork, imports, cross-cwd) and in afinallyfor/reload; MCP listens and disposes when"builtin:mcp"` is removed.getExtensionIdentities, avoiding crashes in partial test hosts.Written for commit 9962222. Summary will update on new commits.