Skip to content

unified-mcp-server: 7 slot processes per session — 259 processes / 10.4 GiB measured, all running the same script #408

Description

@jobordu

Measurement

On a 48 GiB workstation running ~35 concurrent Claude Code sessions:

unified-mcp-server.mjs : 259 processes, 10.45 GiB   (mean 41 MiB)

That is one process per configured provider slot, per session — 7 slots × ~35 sessions. Every one of them runs the identical script. The machine was at 0.0 GiB free with swap 93% full, and MCP servers were the single largest consumer on it — larger than the Claude sessions they serve (12.3 GiB) and larger than everything else combined.

This is duplication, not capability: 259 copies of one server, not 259 different tools.

Root cause

~/.claude.json configures one MCP entry per provider, each setting PROVIDER_SLOT:

"claude-1":   { "command": "node", "args": ["…/unified-mcp-server.mjs"],
                "env": { "PROVIDER_SLOT": "claude-1", } },
"codex-1":    { …"PROVIDER_SLOT": "codex-1"… },
"copilot-1":  { …"PROVIDER_SLOT": "copilot-1"… },
… 7 total

MCP stdio spawns a child process per client, so this multiplies by session count.

Notably this contradicts the documented design. docs/dev/requirements-coverage.md:1842:

The unified MCP server (bin/unified-mcp-server.mjs) is the single entrypoint for all 11 MCP provider servers.

The server already implements that — PROVIDER_SLOT unset exposes all providers as slot-named tools. The slot-per-entry configuration is a deviation from the intended architecture.

Mitigation available today (no code change)

Replacing the 7 slot entries with a single entry that omits PROVIDER_SLOT:

259 processes, 10.45 GiB  →  ~35 processes, ~1.4 GiB     (~9 GiB recovered)

Verified working against the installed build:

initialize → {"name":"unified-mcp-server","version":"1.0.0"}
tools/list → claude-1 codex-1 copilot-1 antigravity-1 claude-z-ai
             claude-minimax claude-kimi gemini-1 opencode-1 kimi-1

Nothing is lost, and three providers that were not previously exposed (gemini-1, opencode-1, kimi-1) become available. Costs: tool names change from mcp__claude-1__claude to mcp__unified-1__claude-1, and the per-slot health_check/help/identity/ping tools collapse (42 tools → 10).

Two things make this safe, both verified in the source rather than assumed:

  • Every spawn builds its own env — const env = { ...process.env, ...resolveEnvPlaceholders(provider.env ?? {}) } (bin/unified-mcp-server.mjs:415). Credentials are resolved per provider at call time.
  • The global process.env mutation that would race under consolidation is guarded by if (SLOT …) (:1001, :1025) and never runs in unified mode.

Proposed fix: an HTTP transport, so the server is a true machine-wide singleton

Unified mode still costs one process per session. An HTTP transport makes it one process per machine, and — the real point — flat regardless of session count:

sessions slot mode unified stdio HTTP singleton
35 10.4 GiB 1.4 GiB ~41 MiB
100 30 GiB 4 GiB ~41 MiB

The transport surface is already small enough to make this contained:

  • handleRequest(req) (:909) is the single dispatcher.
  • send() (:110) is the single response sink; sendResult/sendError wrap it, and all 15 response call sites go through those three functions.
  • The stdio reader (:1051-1066) only parses lines and calls handleRequest.

Sketch:

  1. Make the response sink per-request via AsyncLocalStorage, so send() resolves the current sink and falls back to stdout. No change to the 15 call sites or to handleRequest's signature.
  2. Add an HTTP listener behind an env flag (e.g. MCP_HTTP_PORT), keeping stdio as the default so existing installs are untouched.
  3. Match the streamable-HTTP MCP profile Claude Code already speaks — it connects to HTTP MCP servers with {"type":"http","url":…}, SSE-framed replies and mcp-session-id.
  4. Daemon lifecycle: start-on-demand with a lockfile or a user agent, plus restart handling. This is the part that needs the most care — a singleton becomes a single point of failure for every session on the machine.

Per-call work is already stateless (spawn per request, scoped env), so concurrency across sessions does not require a state redesign.

Suggested sequencing

The config mitigation is independent of the transport work and recovers ~87% of the memory immediately. The HTTP singleton is worth doing on its own merits because it stays flat as session count grows, but it is a feature with protocol and lifecycle surface, not a quick patch.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions