The Tool That Spawns Tools

I have ten MCP servers configured for Claude Code. Each one is useful — occasionally. Between them, they cover oscilloscopes, Ollama, Draw Things, Google Ads, an Onshape CAD integration, Taiga, and a few others. The kind of MCP sprawl that accumulates when you build things and keep building them.
The problem: every time Claude Code starts, it loads all ten. Each MCP server announces its tools via a JSON schema, and that schema gets injected into the context window. Before I've typed a single word, roughly 25% of Claude's available context is already occupied by tool descriptions for servers I probably won't use in that session.
This bothered me more than it should have. The obvious fix — remove the ones you don't use — defeats the point of having them. The answer had to be laziness.
The Spring ToolSearchTool
If you've used Spring AI, you may know the ToolSearchTool: a special tool that lets a model search a registry for other tools rather than having the entire registry pre-loaded. The model discovers what it needs on demand.
That's the idea, applied to MCP servers rather than individual tools.
The Design
meta-mcp is a single MCP server that Claude Code loads at startup. It exposes exactly three tools:
list_mcps() # → name and one-line description for each sub-MCP
describe_mcp(name) # → full JSON tool schemas for the named sub-MCP
call_mcp(name, tool, args) # → proxied result
The one-line descriptions are the lazy-loading index. When I ask Claude to capture an oscilloscope trace, it sees nakoscope: oscilloscope waveform capture in list_mcps output, calls describe_mcp("nakoscope") to get the actual tool schemas, then calls call_mcp("nakoscope", "get_waveform", {...}).
The nakoscope process is spawned on that first describe_mcp call and kept alive for the rest of the session. Claude never sees it directly — it's a subprocess managed entirely by meta-mcp.

The Config
Sub-MCPs are registered in a config.toml alongside the server:
[[mcps]]
name = "nakoscope"
description = "oscilloscope waveform capture"
command = ["/usr/bin/python", "/path/to/nakoscope/mcp/server.py"]
[[mcps]]
name = "ollama"
description = "local LLM inference"
command = ["node", "/path/to/ollama-mcp/dist/index.js"]
env = {OLLAMA_API_KEY = "..."}
The env field handles MCPs that need credentials. The values go in the gitignored config.toml rather than leaking into shell config or a public file.
Building It
The interesting part is the proxy layer. Sub-MCPs communicate via JSON-RPC over stdio — the standard MCP transport. meta-mcp is simultaneously a server (to Claude Code) and a client (to each sub-MCP process it spawns).
The MCP initialisation handshake is a two-step exchange: send initialize, receive capabilities, send notifications/initialized. After that, tools/list fetches the schema and tools/call proxies individual invocations. The subprocess stays alive between calls.
The server side uses FastMCP, which keeps things concise:
@mcp.tool()
async def describe_mcp(name: str) -> str:
sub = _registry.get(name)
tools = await sub.list_tools()
return json.dumps(tools, indent=2)
@mcp.tool()
async def call_mcp(name: str, tool: str, args: dict) -> str:
sub = _registry.get(name)
result = await sub.call_tool(tool, args)
return json.dumps(result)
Shutdown is the reverse: meta-mcp owns the child processes, so it sends SIGTERM to each when it exits, with a five-second SIGKILL timeout for anything that ignores it.
The Bug That Hid Itself
The first version failed silently. The server process crashed immediately on startup, Claude Code saw no tools, and rather than surfacing an error it quietly went and read the draw-things-mcp source code to find the underlying HTTP endpoint — then called curl directly.
I'd called mcp.run_async(), which doesn't exist on FastMCP. The correct call is mcp.run(transport="stdio"). A one-line fix, but it took a test from a separate shell to diagnose:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize",...}' | uv run meta-mcp
Silence is the worst failure mode for an MCP server. The Claude Code /mcp view showed zero connected tools and gave no hint as to why.
The Proof
Once it was running, I tested with Draw Things: generate a cream Maine Coon at /tmp/Mu.png. Claude called describe_mcp("draw-things") to get the schema, then call_mcp("draw-things", "generate_image", {...}).
Then, immediately: generate a dark tabby at /tmp/Chi.png.
The second call went straight to call_mcp — no describe_mcp needed. The draw-things schema was already in the context window from the first request. The lazy-loading pattern works in both directions: load once on first use, reuse for free thereafter.
Context usage after two image generations: 11%. Before meta-mcp, loading all ten MCPs at startup alone cost around 25%.
One Exception
Draw.io uses HTTP transport rather than stdio, so it can't be proxied this way and stays as a direct entry in ~/.claude.json. Eleven out of twelve is fine for now.
There were also project-level MCP overrides hiding in ~/.claude.json — Claude Code adds these silently when you connect an MCP whilst inside a specific project directory. Clearing those took a second pass, but python3 -c "import json; ..." sorted it quickly enough.
Getting It
meta-mcp is in the mcps repo. Copy config.toml.example to config.toml, add your sub-MCPs, and replace your entire mcpServers block in ~/.claude.json with two entries: meta-mcp, and any HTTP-transport MCPs that can't be proxied.
Your existing MCP servers don't need to change at all. They just stop being loaded at startup.
If you find this blog useful, please consider a donation.
Martin Harris connects cloud infrastructure to the real world. If your AI assistant seemed distracted before you'd said anything, it was probably reading twelve instruction manuals at once.
