Build a meta-MCP proxy in Go that lets a coding agent dynamically register, reload, and terminate child MCP servers at runtime — through MCP tools themselves. The proxy presents a single unified MCP server to the client while managing multiple backend MCP servers behind the scenes.
┌──────────────┐ ┌──────────────────────────────┐
│ MCP Client │◄─stdio─►│ mcpmux │
│ │ │ ┌────────────────────────┐ │
│ │ │ │ Management Tools: │ │
│ │ │ │ • add_server │ │
│ │ │ │ • remove_server │ │
│ │ │ │ • reload_server │ │
│ │ │ │ • list_servers │ │
│ │ │ └────────────────────────┘ │
│ │ │ ┌────────────────────────┐ │
│ │ │ │ Proxied Tools: │ │
│ │ │ │ • myserver__tool_a │ │
│ │ │ │ • myserver__tool_b │ │
│ │ │ │ • other__tool_x │ │
│ │ │ └────────────────────────┘ │
│ │ │ │ │ │
│ │ │ ┌────▼───┐ ┌───▼────┐ │
│ │ │ │child 1 │ │child 2 │ ... │
│ │ │ │(stdio) │ │(stdio) │ │
│ │ │ └────────┘ └────────┘ │
└──────────────┘ └──────────────────────────────┘
Use the official Go MCP SDK: github.com/modelcontextprotocol/go-sdk/mcp (v1.2.0+).
Key SDK features to leverage:
mcp.NewServer— the proxy-facing servermcp.AddToolwith generic typed inputs — for management tools- Dynamic
AddTool/Server.RemoveToolson a running server sendsnotifications/tools/list_changedautomatically mcp.NewClient+mcp.CommandTransport{Command: exec.Command(...)}— to connect to each child MCP server processclient.Connect(...)returns a*mcp.ClientSessionfor child server interactionssession.ListTools(...)— discover child server toolssession.CallTool(...)— forward calls to child servers
- Bazel with
rules_go - Module: use existing
google/agent-shell-toolsgo.mod - All test targets runnable via
bazel test //...
mcpmuxcan spawn arbitrary child processes via theadd_servermanagement tool- If the coding agent is not already running inside an equivalent sandbox,
mcpmuxmust be run inside//sandbox - The sandbox boundary is responsible for constraining filesystem and process-level effects of child MCP servers
The proxy exposes these MCP tools to the client on startup:
{
"name": "string (required, unique identifier, e.g. 'my-dev-server')",
"command": "string (required, e.g. 'node')",
"args": ["string array (optional, e.g. ['./dist/index.js'])"],
"env": {"key": "value (optional, merged into the proxy's environment)"},
"cwd": "string (optional, working directory)"
}- Spawns the child process with stdio transport
- Performs MCP
initializehandshake with the child - Calls
tools/liston the child to discover its tools - Re-exposes each child tool on the proxy with namespace prefix:
{name}__{tool_name} - Sends
notifications/tools/list_changedto the client - Succeeds even if the child exposes zero tools (server is still visible in
list_servers) - Returns success with list of discovered tools, or error if spawn/init fails
- Error if
namealready exists
{
"name": "string (required)"
}- Removes all namespaced tools from the proxy
- Sends
notifications/tools/list_changed - Removes the server from
list_serversimmediately (nostoppingstate) - Sends graceful shutdown to the child (close stdin, wait with timeout)
- Force-kills if child doesn't exit within timeout (default 5s)
- Error if
namenot found
{
"name": "string (required)"
}- Equivalent to
remove_server+add_serverwith the same config - Preserves the original spawn configuration
- The SDK debounces
notifications/tools/list_changedwith a 10ms window, so rapid RemoveTool + AddTool calls coalesce into a single notification automatically - Error if
namenot found
No input. Returns:
{
"servers": [
{
"name": "my-dev-server",
"command": "node",
"args": ["./dist/index.js"],
"status": "starting | running | crashed",
"tools": ["my-dev-server__echo", "my-dev-server__greet"],
"pid": 12345,
"uptime_seconds": 120
}
]
}- When the client calls a namespaced tool (e.g.
myserver__echo), the proxy:- Strips the namespace prefix
- Forwards
tools/callto the correct child via the stored MCP client - Returns the child's response to the calling client
- Tool descriptions and input schemas are preserved from the child, with the tool name rewritten
- If a child process crashes mid-call, return an MCP error to the client
- Tool calls containing
__are routed to child servers (split on first__); all others are management tools - Server names must be non-empty, unique, and must not contain
__
- Child processes are spawned with
os/exec.Command, stdin/stdout wired as MCP stdio transport - Child stderr is captured, prefixed with the server name, and logged to the proxy's stderr
- If a child process exits unexpectedly:
- Mark server status as
crashed - Remove its tools from the proxy
- Send
notifications/tools/list_changed - Do NOT auto-restart (the agent decides whether to
reload_server)
- Mark server status as
- The proxy itself shutting down must clean up all child processes (SIGTERM, then SIGKILL after timeout)
- The proxy serves over stdio
- Multiple tool calls may be in flight simultaneously
- Child server map must be protected (sync.RWMutex or similar)
- Adding/removing servers while tool calls are in progress must not deadlock or race
- If a child is slow to respond, the proxy should respect context cancellation from the client
- If the child's stdin pipe breaks, detect and mark as crashed
- Structured logging (slog) to stderr
- Log: child spawn/exit, tool discovery, tool calls (at debug level), errors
- Pure Go. The proxy itself has zero runtime dependencies outside the Go binary.
- Child servers it manages can be anything (Node, Python, Rust, etc.) — the proxy doesn't care.
- Verify
{server}__{tool}encoding and decoding - Verify decoding splits on the first
__ - Edge cases: tool names containing
__, empty server names, server names containing__, Unicode
- Add/remove/reload operations on an in-memory registry
- Verify concurrent add + remove doesn't race (run with
-race) - Verify duplicate
add_serverreturns error - Verify
remove_serveron nonexistent name returns error - Verify
reload_serverpreserves config
- Add two child servers with overlapping tool names → verify both appear with distinct namespaces
- Remove one → verify only its tools disappear
- Verify reserved management tool names cannot be overridden
- Spawn a mock child process (a simple Go binary that implements MCP)
- Verify initialize handshake completes
- Verify tools are discovered
- Kill the child externally → verify proxy detects crash and removes tools
- Verify graceful shutdown sends SIGTERM then SIGKILL
- Start the proxy as a subprocess
- Connect an MCP client to it
- Call
add_serverwith a test echo MCP server (built as a Bazel target) - Verify the echo server's tools appear via
tools/list - Call the proxied echo tool → verify correct response
- Call
remove_server→ verify tools disappear - Call the removed tool → verify error
- Start proxy + add a test server (v1: exposes
echotool) - Swap the test server binary to v2 (exposes
echo+reversetools) - Call
reload_server - Verify
reversetool now appears - Call
reverse→ verify correct response
- Start proxy + add a test server
- Kill the test server's process directly (simulate crash)
- Verify
list_serversshows statuscrashed - Verify the crashed server's tools are no longer callable
- Call
reload_server→ verify it comes back
- Start proxy + add a test server with a slow tool (sleeps 100ms)
- Fire 10 concurrent
tools/callrequests - Verify all return correct results
- While calls are in-flight, call
remove_server - Verify in-flight calls either complete or return clean errors
Build minimal test MCP servers as Go binaries:
- Implements MCP over stdio
- Exposes one tool:
echo(returns input as-is) - Exposes one tool:
slow_echo(sleeps for a configurable duration, then returns input)
- Same as above but adds a
reversetool (reverses input string) - Used for reload testing
- Implements MCP, exposes a
crashtool that callsos.Exit(1) - Used for crash detection testing
# All unit tests
bazel test //mcpmux/...
# Integration tests (spawns real processes)
bazel test //mcpmux/internal/integration/...
# With race detector
bazel test //mcpmux/... --@io_bazel_rules_go//go/config:race
# Verbose for debugging
bazel test //mcpmux/... --test_output=allmcpmux/
├── BUILD.bazel
├── main.go # Entry point: creates server, adds management tools, runs stdio
├── proxy.go # Core proxy logic: server registry, child management
├── proxy_test.go # Unit tests for registry, namespacing, merging
├── namespace.go # Tool name encoding/decoding
├── namespace_test.go
├── child.go # Child process lifecycle (spawn, monitor, kill)
├── child_test.go
├── integration/
│ ├── BUILD.bazel
│ ├── proxy_test.go # End-to-end integration tests
│ └── testutil.go # Helpers: start proxy, connect client, assert tools
└── test/
├── echoserver/
│ ├── BUILD.bazel
│ └── main.go
├── echoserver_v2/
│ ├── BUILD.bazel
│ └── main.go
└── crashserver/
├── BUILD.bazel
└── main.go
- Bootstrap: Initialize the module, set up Bazel targets, add
go-sdkdependency - Namespace: Implement and test
namespace.gofirst — it's pure logic, easy to validate - Registry: Implement
proxy.gowith in-memory server map, add/remove/reload operations, unit test with-race - Child process: Implement
child.go— spawn, MCP handshake, tool discovery, crash monitoring - Test fixtures: Build
echoserver,echoserver_v2,crashserveras Bazel Go binaries - Management tools: Wire up
add_server/remove_server/reload_server/list_serversas MCP tools inmain.go - Tool proxying: Implement the call-forwarding logic with namespace resolution
- Integration tests: End-to-end tests using real subprocesses
- Self-test:
add_serverthe proxy's own echo server, call it, reload it, remove it — verify the full loop
- SSE/HTTP child transports: Not in V1, stdio only
watch_serverauto-reload on file changes: No- Config file for pre-registering servers on startup: Not in V1
- Annotate tool descriptions with source server name: Add if needed
- Tool schema changes on reload: TBD — if a child's tool schema changes after
reload_server, the proxy re-discovers tools and updates schemas, but clients may cache old schemas