← Blog

Right-Sizing MCP Servers

We run a lot of Claude Code sessions. At any given time there might be six or eight open, across different repos, different tasks, sometimes different agents working in parallel. Each session spawns its own MCP server processes for the tools it uses. For most tools, this is fine. For Quarry, which loads a 200MB ONNX embedding model into memory, it meant six copies of the same model sitting in RAM doing nothing.

We built all of our tools as Python MCP servers first, one heavy server per session, and we were focused on getting the functionality right, not on what happens when eight sessions each spawn one. Then the machine started struggling under the memory multiplication, and that pressure is what pushed us to build a proxy and move the heavy state into shared daemons. We took the problem in the wrong order: functionality first, resource shape second. This post shares the order we actually went in, not a clean design we had up front. What we landed on is not “thick or thin.” It is two separate questions. How heavy and session-agnostic is the resource the server owns? And how much per-session context must the MCP server itself carry? The answers point to three shapes, and we run all three.

The debate we walked into

The journey started with an argument that was already running when we joined it. A widely shared Medium post argues that CLIs beat MCP for coding agents, and the reasoning deserves to be stated fairly [1]. MCP front-loads every tool’s schema into the context window at session start. A typical GitHub MCP server exposes around 90 tools and costs roughly 55,000 tokens of schema before the agent does anything. A CLI starts at zero context and reads --help on demand for a few hundred tokens. Models are fluent in shell from their training data, where git, gh, and jq appear constantly, whereas MCP tool schemas do not appear in that data at all. The post cites a benchmark across GitHub tasks where the CLI approach ran close to 100% reliable at 1,365 to 8,750 tokens per task, against MCP at about 72% reliable and 32,000 to 82,000 tokens [1]. The author is Phil at Rentier Digital, building on Peter Steinberger’s OpenClaw work.

That gap is already being narrowed. Anthropic’s late-2025 “code execution with MCP” pattern presents MCP servers as code APIs the agent imports and calls, rather than schemas loaded up front, which cuts the token cost substantially [2].

The clients are moving too, not only the servers. Claude Code shipped MCP Tool Search in January 2026, on by default, so instead of loading every tool’s schema at session start it defers the heavy ones and gives the model a search tool that pulls in the three to five it actually needs [3]; the underlying tool-search capability arrived on Anthropic’s developer platform in November 2025 with Opus 4.5. We can see the effect in our own stack: our MCP servers (Biff, Quarry, Vox, Lux, ethos, and Z Spec) declare about 85 tools between them, and their full schemas come to roughly 24,000 tokens, yet with deferral on a normal session holds closer to 2,000 tokens of tool schema, because Claude Code loads only what the session actually calls. These are approximate figures read from Claude Code’s own /context report, not a controlled measurement. The token tax the debate turns on is now being paid down at the client, not only at the server.

Our stance is simpler than either side of the debate. We build both, always. One library, projected to both a CLI and an MCP server, which is the pattern we describe in Universal Access to Capability. The choice is never CLI or MCP for the whole toolbox. It is per use case.

So when does MCP earn the token cost? Two cases have held up for us.

Session-tied or auth-tied connectivity. A CLI process cannot hold state between invocations. Connection pooling and cached authentication are therefore off the table, because each exec starts cold and exits. A long-lived MCP process keeps the pool warm and the credentials cached.

Asynchronous callbacks and notifications. MCP can push to the agent, and a CLI cannot. Biff updates its read-messages tool description with the current unread count and emits tools/list_changed when the picture changes, so the agent sees new information without asking for it. Fire-and-forget work benefits too: ingesting a large PDF into Quarry or synthesizing speech in Vox returns control to the agent immediately instead of blocking until the process exits.

None of this makes MCP the default. It makes MCP the right choice for a specific set of tools, and it leaves the token cost as a real charge we pay only when the connectivity or the callbacks are worth it.

We did not arrive at that case-by-case judgement cleanly, and the reason is where our code runs. We build for developers to run our tools on their own machine or their own network, not in the cloud. On one machine, six or eight heavy MCP servers multiply memory and duplicate audio processes, and a shared daemon is the fix. A cloud deployment would have hidden the same multiplication behind separate containers, so we might never have felt it. Local-first is why right-sizing mattered so much to us, and it is why we fell into the heavy-MCP trap before climbing out. The rest of this post is that climb.

The problem has three faces

Memory multiplication. Quarry loads snowflake-arctic-embed-m-v1.5 (200MB) on startup and opens the LanceDB index separately in each process. On one developer machine with eight concurrent sessions, eight full Python Quarry servers came to roughly 450MB of resident memory. (These are RSS figures, read with ps. RSS overcounts shared libraries, so the per-session Python numbers slightly overstate the true marginal cost of each extra session. We state that caveat once; it applies to every per-session figure below.)

Resource contention. Vox plays audio through the machine’s speakers. When Biff broadcasts a /wall message, every session’s independent Vox process synthesizes and plays the same announcement simultaneously. A machine has one pair of speakers, so it should have one audio process.

The hook timing budget. Claude Code hooks (PreToolUse, SessionStart) have roughly 100ms before they feel sluggish. Our Python tools were taking 1.5–4.7 seconds to cold-start because of import trees. Biff’s hook script called the full CLI, which imported typer, nats, pydantic, and fastmcp before reaching the handler, even though the handler only needed the standard library [4]. Quarry’s session-start hook imported lancedb (16.2s on first load), onnxruntime, pymupdf, and beautifulsoup, but the handler only needed sqlite3 and subprocess.

What we tried first: thin Python clients

The obvious fix was to separate the CLI’s import tree so hooks could avoid the heavy dependencies. We tried this across several projects.

It didn’t work well. Python’s import system is infectious. Importing one module that imports another that imports a heavy dependency pulls the whole tree in. We documented the pattern across projects in our hooks standard: the three-layer fix was a _stdlib.py module with only standard library imports, a lightweight entry point, and lazy __init__.py that defers heavy imports. Biff got its hook time from 3.7s down to 0.29s this way [5].

But that only solved the hook problem. The MCP server process itself still loaded everything, and there was still one process per session. We needed a way to share expensive state across sessions.

The proxy pattern

In March 2026 we built mcp-proxy, a Go binary (~6MB, <10ms startup) that sits between Claude Code and a shared daemon process:

Claude Code ←── stdio ──→ mcp-proxy ←── WebSocket ──→ daemon
                                                        (one process)

Claude Code thinks it is talking to an MCP server over stdio, the standard input/output pipe a parent process uses to talk to a child. The proxy forwards every message, unmodified, to a single daemon over a WebSocket connection. Every session then shares one daemon behind the proxy instead of loading the heavy resource itself.

The arithmetic is the whole point. With the proxy, Quarry across eight sessions is one shared quarry serve daemon at 56MB plus eight Go proxies at about 6MB each, roughly 104MB in total. Without the proxy, the same eight sessions ran eight full Python Quarry servers at roughly 450MB. The proxy saves around 350MB. Lux, which we come to below, tells the same story with its display process in place of the embedding model.

The proxy doesn’t parse MCP messages. They pass through opaque. This means the proxy works with any MCP server that speaks WebSocket, so we did not have to build separate proxies for each tool.

Why WebSocket and not Unix sockets or HTTP? We considered both [6]. HTTP is request-response only, with no way for the server to push. When Biff’s tool list changes because a new session joins, the daemon needs to notify all connected clients with a tools/list_changed message, the MCP notification that tells a client to refresh its tool list. Unix domain sockets work but require hand-rolled framing and keepalive. WebSocket gives us RFC 6455 framing, built-in ping/pong liveness detection, and bidirectional push, all standard and all tested.

Building the proxy in Go rather than Python was deliberate, and it turned into a broader learning. Python works fine for this, and most of our tools stay in Python. But once we understood the shape of the problem, the latency-critical pieces were easier to reason about in Go. The mcp-proxy binary is about 6MB and starts in under 10ms. The Python tools it fronts cold-start in 1.5 to 4.7 seconds. When something sits on the hot path of every session, that difference is the whole game. We now reach for Go on the latency-critical pieces and keep Python for everything else. It is a preference we earned in this work, not a verdict on Python.

What the proxy actually fixed

Quarry adopted mcp-proxy immediately. Eight sessions now share one embedding model behind the proxy, cutting Quarry’s footprint from roughly 450MB to about 104MB by the breakdown above. The daemon opens LanceDB once, and per-session state (which database is selected) rides as a session key on the WebSocket upgrade URL.

Vox adopted it next. One daemon, one audio output, no duplicate synthesis. When Biff broadcasts, the Vox daemon synthesizes once and plays once. The 5-second deduplication window (DaemonContext.should_play) catches duplicate notifications that arrive from different sessions within the same wall broadcast [7].

For hooks, the proxy’s --hook mode sends one-shot JSON-RPC messages, the lightweight request and response format MCP speaks, to the daemon in about 15ms, comfortably within the 100ms budget. No Python imports at all.

Where a thick server was the wrong shape

After shipping the Vox daemon with mcp-proxy, we spent eight rounds fixing path-resolution bugs. The daemon needed to know each session’s working directory to find .vox/config.md. We tried resolving the working directory from the session PID via lsof, then looking for config files relative to the project root. Every piece of this chain broke.

The root cause was not implementation defects. It was architectural ambition. The daemon was trying to be “project-aware” across sessions, maintaining per-connection state through Python ContextVars, a mechanism for holding per-task state that changes with the execution context. This was the wrong boundary.

Vox’s v3 architecture [8] took a different approach: make the MCP server so lightweight that spawning one per session is cheap. The per-session server handles config resolution (walk up from CWD, find .vox/config.md) and validation. It calls the daemon only for the expensive operations: TTS synthesis and audio playback. The daemon knows nothing about projects, sessions, or Claude Code. It accepts text and parameters, and it speaks.

This removed mcp-proxy from Vox entirely. No Go binary, no WebSocket bridge, no class of “MCP session doesn’t survive daemon restart” bugs. Each session now runs its own vox mcp client, a thin Python process of about 9MB, and they all call one shared voxd daemon of about 47MB that owns the audio device. The per-session client stays because it has to. It resolves .vox/config.md from the session’s working directory before it can act, and that per-session, directory-dependent step is exactly what broke when it lived inside a shared daemon. A proxy cannot help here, because the work that must run per session is not routing. It is the MCP server’s own logic.

Right-sizing: proxy by default

Stepping back from the individual tools, the question was never “thick or thin.” It was the two questions from the top of this post. How heavy and session-agnostic is the resource the server owns? And how much per-session context must the MCP server itself carry? Three answers fell out, and each maps to something we actually run.

Heavy, session-agnostic state, and the per-session work is only routing. Put an ultra-thin Go proxy in front of one shared daemon. This is Quarry and Lux. Quarry’s 56MB quarry serve holds the embedding model and LanceDB. Lux’s luxd plus its Lux GUI window, about 54MB together, hold the display. Neither resource depends on which session is calling, so the per-session process can be a 6MB proxy that forwards bytes. Per-session identity rides through as a key: Quarry passes the selected database as a session key on the WebSocket upgrade URL. Keeping that single display owner correct under concurrent draws turned out to be hard enough that we proved it with a model, the subject of a separate post on a concurrency bug.

The server needs real per-session context: a working directory, a config file, a session identity. Keep a thin per-session client that owns that context and delegates the heavy operation to a singleton. This is Vox, whose 9MB vox mcp resolves .vox/config.md from the session’s directory before calling the shared 47MB voxd. It is also Biff, whose biff mcp carries the session’s identity and pushes its shared state to a cloud-hosted NATS relay, which offloads that state off the machine and is why Biff needs no local daemon of its own. No proxy, because the per-session logic has to run per session, and a byte-forwarding bridge has nowhere to put it.

The server keeps its state on disk, so there is nothing to centralize. Run a per-session native binary and share nothing. This is ethos, about 2MB of Go per session. Its state lives in files, not memory. It writes repo-local files, reads repo-level files, and reads global config under ~/, and the filesystem already coordinates all of that across sessions, so there is no in-memory resource a daemon could hold. It is the reminder that not everything needs one. When the whole server is a couple of megabytes and keeps its state on disk, a proxy or daemon in front of it is the wrong move.

Here is the per-session and shared footprint we measured on one machine:

ToolPer-session processShared daemonShape
Quarrymcp-proxy (Go) ~6MBquarry serve 56MBproxy bridge
Luxmcp-proxy (Go) ~6.5MBluxd 16MB + Lux GUI 38MBproxy bridge
Voxvox mcp (Python) ~9MBvoxd 47MBthin client, singleton
Biffbiff mcp (Python) ~23MBcloud NATS relaythin client, external
ethosethos serve (Go) ~2MBnone (on-disk state)share nothing

This connects to something we underweighted. The universal access pattern we wrote about in Universal Access to Capability projects one library into many surfaces: CLI, MCP, REST. That is one kind of multiplicity, many kinds of caller reaching one function. There is a second kind we did not plan for: many instances of the same client, one MCP server per session. Projecting cleanly across surfaces is not enough. You also have to decide how those per-session instances map onto the heavy resources behind them, or you pay N times for everything.

The default we landed on is simple. Reach for mcp-proxy for any server with non-trivial memory or session-agnostic state. Step off it to a thin per-session client only when the server needs real per-session context. Drop the daemon entirely when the server is cheap. We did not have that rule at the start. We built the heavy servers first and found the rule by running out of memory.

What we don’t know yet

We haven’t tested this at scale beyond a single developer machine with 10–15 sessions. The memory savings are real and measurable, but we don’t know how the WebSocket connection pool behaves under heavier concurrent load. The mcp-proxy formal specification covers the state machine (6 states, 43 transitions verified by ProB, a model checker that exhaustively explores a specification’s reachable states [9]), but the specification doesn’t model resource contention under load.

The token-cost figures in the debate section come from other people’s setups, not ours. The 55,000-token GitHub schema and the CLI-versus-MCP benchmark are numbers we are quoting, not numbers we have reproduced. We have not benchmarked our own tool schemas head to head against their CLI equivalents, so we cannot say how our build-both split scores on tokens. That measurement is still on the list.

Biff is the borderline case in our own rule, and its design needs rework we have not done yet. Its shared state lives in a cloud-hosted NATS relay, a genuine offload that leaves no local daemon and no memory multiplication behind it. But the per-session client is a 23MB Python process, and eight of those at once is not tiny. For something whose main job is to relay to NATS, 23MB is heavy. Biff is a candidate for a slimmer client or a Go rewrite. The cloud backend buys it some slack, though not that much.

We also don’t know whether the three-shape split is stable. It’s possible that as tools get more complex, a hybrid emerges where the proxy handles some sessions and direct connections handle others. We haven’t needed that yet.

References

  1. Phil, Rentier Digital Automation. “Why CLIs Beat MCP for AI Agents — And How to Build Your Own CLI Army.” Medium, 2026. medium.com
  2. Anthropic. “Code Execution with MCP: Building More Efficient Agents.” Anthropic Engineering, 2025. anthropic.com
  3. Anthropic. “Introducing Advanced Tool Use on the Claude Developer Platform.” Anthropic Engineering, 2025. anthropic.com
  4. Punt Labs. “Hook Import Tax — Lightweight Entry Point.” Biff DESIGN.md, DES-028. 2026. github.com
  5. Punt Labs. “Hook Startup Performance.” punt-kit hooks standard, §12. 2026. github.com
  6. Punt Labs. “Transport — WebSocket.” mcp-proxy DESIGN.md, DES-001. 2026. github.com
  7. Punt Labs. “Daemon Mode — Single Process with mcp-proxy.” Vox DESIGN.md, DES-021. 2026. github.com
  8. Punt Labs. “Vox v3 — Audio Server Architecture.” Vox DESIGN.md, DES-028. 2026. github.com
  9. Punt Labs. “mcp-proxy Z Specification.” mcp-proxy docs/mcp-proxy.tex. 2026. github.com