---
title: "The Tool That Spawns Tools"
date: "2026-04-29"
approved: true
publish_date: "2026-05-11"
excerpt: "Ten MCP servers loaded at startup means ten sets of tool schemas injected into every request. I built a proxy that lazy-loads them instead."
tags: ["mcp", "claude-code", "python", "tooling"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-04-29-meta-mcp"
---

# The Tool That Spawns Tools

![A robot librarian in a vast dark warehouse of dormant toolboxes handing over a single glowing one](./images/meta-mcp-header.png)

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:

```python
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.

![An open toolbox revealing nested smaller toolboxes like matryoshka dolls](./images/meta-mcp-1.png)

## The Config

Sub-MCPs are registered in a `config.toml` alongside the server:

```toml
[[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:

```python
@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:

```bash
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](https://github.com/nakomis/mcps/tree/main/meta-mcp). 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.

{{donate}}

---

*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.*

