Skip to main content
Glama
README.md
# mcp-switchboard

Run MCP servers on machines behind NAT, and use their tools from anywhere — n8n,
an agent, or the built-in web console — without opening a single inbound port.

A **client** on each machine spawns your local stdio MCP servers and opens one
*outbound* WebSocket to a **hub**. The hub speaks MCP to each tunnelled server,
aggregates every tool into one endpoint, and serves it over Streamable HTTP. It
also gives you a console to browse what is connected, call any tool by hand, and
read back every call that has ever been made. When the agent orchestrator is on, the hub is also the
agent's home config: its skills live as `SKILL.md` files and its prompts as Markdown under
`$DATA_DIR/skills/` and `$DATA_DIR/prompts/` (editable from the console **or** with any text editor —
the hub reconciles file edits into its index every turn), and scanned client skills appear for
inspection and one-click import.

```
┌── your laptop, a NATed box, anywhere ─────────────────┐
│ mcp-switchboard-client                                │
│   mcp.json → spawns local stdio MCP servers           │
│   one outbound WSS connection, all servers multiplexed│
└───────────────────────┬───────────────────────────────┘
                        │  wss://…/tunnel/v1   (outbound only, token)
┌───────────────────────▼───────────────────────────────┐
│ mcp-switchboard-hub                                   │
│                                                       │
│  :8097  tunnel endpoint  ← the only public listener   │
│  ──────────────── loopback only ───────────────────   │
│  :8099  /mcp      Streamable HTTP  ← n8n, agents      │
│         /         web console                         │
│         /api/*    JSON API                            │
│         /metrics  Prometheus                          │
│                                                       │
│  → Loki (batched)        → SQLite call log            │
└───────────────────────────────────────────────────────┘
```

## Why this exists

This replaces the reverse-proxy feature of [mcp-context-forge](https://github.com/IBM/mcp-context-forge),
which cannot work as advertised. In every released version (checked 0.9.0 through
1.0.10), its tunnel endpoint accepts connections and then drops everything:

```python
elif msg_type in ("response", "notification"):
    # TODO: Route to appropriate MCP client      ← responses are logged and discarded
```
```python
# TODO: Implement message queue for SSE delivery ← the SSE endpoint only sends keepalives
```

Nothing registers tunnelled servers into its tool catalog, so the tools never
appear to a consumer. Its protocol also has both peers answer `heartbeat` with
`heartbeat`, an infinite loop we measured at ~80 messages/second per session.

## Quick start

### The hub

NixOS, via the flake:

```nix
{
  inputs.mcp-switchboard.url = "github:AkosPapp/mcp-switchboard";

  # in your host config
  imports = [ inputs.mcp-switchboard.nixosModules.default ];

  services.mcp-switchboard = {
    enable = true;
    # Any setting takes a literal or a path to a file holding the value, so
    # sops-nix secrets need no special handling.
    tunnelToken = "/run/secrets/mcp-switchboard/tunnel-token";
    tunnel.host = "0.0.0.0";        # reachable by your clients
    private.host = "127.0.0.1";     # console + /mcp stay local
    loki.enable = true;
    prometheus.register = true;

    # Optional: the agent orchestrator. Off by default; without it /api/agents
    # does not exist. apiKeyFile is a path, read by the hub at startup.
    agents.enable = true;
    llm = {
      provider = "anthropic";                       # or openai, openai-compatible
      apiKeyFile = "/run/secrets/mcp-switchboard/llm-api-key";
      # modelsFile = "/etc/mcp-switchboard/models.json";
      # baseUrl = "http://127.0.0.1:11434/v1";      # required for openai-compatible
      # openaiCompatible.kind = "auto";             # auto | ollama | generic
      # openaiCompatible.discover = true;           # list models from the provider
      # ollamaNumCtx = 0;                           # 0 = automatic
    };
  };
}
```

`agents.enable` requires `llm.provider` and `llm.apiKeyFile` (the key is optional for
`openai-compatible`, which needs `llm.baseUrl` instead). The limits are
`agents.{maxDepth,maxChildren,maxConcurrentRuns,replyTimeout,approvalTimeout}`. These map to
`MCP_SWITCHBOARD_AGENTS_ENABLED`, `MCP_SWITCHBOARD_LLM_<PROVIDER>_API_KEY` /
`_BASE_URL` (provider upper-cased, `-` becomes `_`), `MCP_SWITCHBOARD_LLM_MODELS` and
`MCP_SWITCHBOARD_AGENT_*`, so the same settings work with Docker.
`llm.openaiCompatible.{kind,discover}` and `llm.ollamaNumCtx` map to
`MCP_SWITCHBOARD_LLM_OPENAI_COMPATIBLE_{KIND,DISCOVER}` and
`MCP_SWITCHBOARD_LLM_OLLAMA_NUM_CTX`; they are only written when they differ from
their defaults (`auto`, `true`, `0`), or, for kind and discover, when the provider is
`openai-compatible`.

#### Using Ollama / LiteLLM

Choose the `openai-compatible` provider and point `baseUrl` at the server:
`http://localhost:11434/v1` for Ollama, or your LiteLLM proxy's URL.

- Models are discovered automatically from the provider
  (`MCP_SWITCHBOARD_LLM_OPENAI_COMPATIBLE_DISCOVER`, default on).
  OpenRouter (openrouter.ai) is not auto-listed: declare its models in the models file
  (`MCP_SWITCHBOARD_LLM_MODELS`).
- `MCP_SWITCHBOARD_LLM_OPENAI_COMPATIBLE_KIND` is `auto` (detect Ollama), `ollama` or
  `generic`.
- For Ollama the hub uses Ollama's native API to set the context window. Ollama's
  `/v1` endpoint cannot set it and falls back to 4096 tokens, which is too small for
  agents with tools. `MCP_SWITCHBOARD_LLM_OLLAMA_NUM_CTX` sets it explicitly; the
  default `0` is automatic: the model's own maximum, capped at 32768.

Or with Docker (note it binds loopback by default, so override the hosts):

```sh
docker run -p 8097:8097 -p 8099:8099 \
  -e MCP_SWITCHBOARD_TUNNEL_TOKEN=... \
  -e MCP_SWITCHBOARD_TUNNEL_HOST=0.0.0.0 \
  -e MCP_SWITCHBOARD_PRIVATE_HOST=0.0.0.0 \
  -v mcp-switchboard:/var/lib/mcp-switchboard \
  ghcr.io/akospapp/mcp-switchboard-hub
```

### The client

On any machine with MCP servers, next to an `mcp.json`:

```sh
curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
  --hub-url wss://switchboard.example.com --token "$TOKEN"
```

or, if you already have `uv`:

```sh
uvx mcp-switchboard-client --hub-url wss://switchboard.example.com --token "$TOKEN"
```

The installer only makes sure `npx` and `uvx` exist (via `nix shell` if you have
Nix, otherwise per-user installs with no sudo) and then hands off to `uvx`.

Pass `--editor=code` to also set up the [OpenCode](https://opencode.ai) TUI inside
VS Code (it installs the `sst-dev.opencode` extension and the hub bridge plugin
described below). A missing editor only warns; the client still starts:

```sh
curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
  --editor=code --hub-url wss://switchboard.example.com --token "$TOKEN"
```

`--editor=code` implies `--opencode`, which is also usable on its own and **ends
the command in a running `opencode` session**: the client tunnel is moved to the
background (pid and log under `~/.cache/mcp-switchboard-installer/`, a rerun
reuses a live client instead of double-tunnelling) and `opencode` takes the
terminal. Exiting opencode leaves the tunnel running; `kill $(cat
~/.cache/mcp-switchboard-installer/client.pid)` stops it. For hosts where you
just want `opencode`, no VS Code:

```sh
curl -fsSL https://akospapp.github.io/mcp-switchboard/install.sh | sh -s -- \
  --opencode --hub-url wss://switchboard.example.com --token "$TOKEN"
```

Either flag makes sure `opencode` exists, **detect-first**: an existing copy — a
Nix-managed one, typically — is reported and left completely alone (a per-user
install would silently shadow it); the official installer runs only where the
binary is entirely absent, into `~/.opencode/bin`, per-user, no sudo.

It then installs the **hub bridge plugin** (`editor/opencode/plugin/switchboard-hub.js`,
served from the same Pages site, merged into the *global*
`~/.config/opencode/opencode.json`, never clobbering it; invalid JSON gets a
printed snippet instead). The plugin turns every opencode project into a live
**bridge chat** in the hub console:

- the assistant's replies and finished tool calls are mirrored into the chat, and
  what you type there is fed back as opencode's next turn — see it, steer it, from
  the browser;
- a permission prompt opencode raises is mirrored **and** pushed to your phone
  (the hub's `question` notification); answering `yes`/`no` in the chat decides it;
- `switchboard_chats` / `switchboard_read` / `switchboard_message` tools let the
  model talk to any hub chat — the agent-to-agent comms the setup is for.

A bridge chat is `kind: "bridge"` on the hub: sending to it never runs the hub
agent (opencode is the brain); the plugin reaches the hub's API, so that origin
must serve `/api`. A loopback hub at `http://127.0.0.1:8099` works out of the
box. For a remote hub, either expose the private listener where the plugin can
reach it, or set `MCP_SWITCHBOARD_PUBLIC_API=1` on the hub (`public.api.enable`
in the NixOS module): that mounts `/api/*` on the **tunnel** listener too,
behind a bearer token — the same `--token` the client presents, so the origin
you already pass to `install.sh --hub-url` just works. Override the plugin's
hub origin with `MCP_SWITCHBOARD_HUB_URL`, and its bearer with
`MCP_SWITCHBOARD_PRIVATE_TOKEN`. The VS Code extension only adds
selection/tab sharing.

Related editor tooling lives in this repo: [`editor/vscode`](editor/vscode) quotes
the current selection into a chat's draft, and `.vscode/tasks.json`,
"Switchboard: agent reads" runs `tools/agent-reads.py`, which rewrites
`.harness/agent-reads.md` from the hub's call log so a Markdown preview shows
which files the agent reads.

### `mcp.json`

The usual shape, as used by Claude Desktop, Cursor and VS Code:

```json
{
  "mcpServers": {
    "git":  { "command": "uvx", "args": ["mcp-server-git", "--repository", "."] },
    "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] }
  }
}
```

An `mcp.json` is optional: with none in the current directory the client tunnels only the
built-in harness. The client also adds a built-in `harness` server by default, confined to the directory
you start the client in; pass
`--no-harness` to leave it out. Its core tools are the opencode suite — `bash`, `read`, `write`,
`edit`, `glob`, `grep` — plus the extras the hub also offers in agent chats (`task`→chat spawn,
`question`→user ask, `todowrite`, `webfetch`, `skill`): a hub agent sees one familiar tool set, half of
it executed here, half by the hub. The client also scans SKILL.md skills on the host
(`~/.claude/skills`, `.opencode/skills`, … — `MCP_SWITCHBOARD_SKILLS=false` or `--no-skills` off) and
keeps the hub's `skills/hosts/` mirror current. It gives a shell to anyone who can reach `/mcp`, so read
[the security model](spec.md#10-security-model) first.

An entry may carry a `"project"` to group it (a top-level `"project"` sets the default
for every entry without one). Servers in a project are exposed under an extra scope, see
[Connecting n8n](#connecting-n8n).

Server names are unique within one client (a duplicate is rejected), so two
projects that both want an `lsp` need distinct names such as `lsp-nix` and `lsp-web`.

FastMCP-style entries are also recognised — any entry containing a top-level
`source` key is launched with `fastmcp run` instead. Only stdio transport is
tunnelled, since the wire protocol bridges stdin/stdout.

## Connecting n8n

Point n8n's **MCP Client Tool** node (transport: HTTP Streamable) at the hub. The
same tools are served at three scopes, so you can narrow the list instead of
scrolling one enormous flat one:

| URL | What it lists | Tool names |
|---|---|---|
| `/mcp` | everything, every machine | `legion5__git__git_status` |
| `/mcp/host/legion5` | one machine | `git__git_status` |
| `/mcp/host/legion5/project/nix` | one project on a machine | `lsp__hover` |
| `/mcp/host/legion5/server/git` | one server | `git_status` |
| `/mcp/host/legion5/project/nix/server/lsp` | one server within a project | `hover` |

A server's project, when it has one, also appears in the broader scopes' names
(`legion5__nix__lsp__hover` at `/mcp`). The console's **Endpoints** panel lists the URLs
for whatever is currently connected, with example tool names, and — when
`MCP_SWITCHBOARD_PUBLIC_URL` is set — a copyable client-install command.

Every tool is tagged with its origin three ways, because different clients
surface different fields: in the **name** (above), in the **title**
(`git_status · git @ legion5`), and at the front of the **description**
(`[legion5 · git] …`) — that last one matters because it is what an LLM reads
when choosing a tool. The machine-readable `_meta` carries `host`, `server`,
`connectionId` and `upstreamName` too.

The console shows each tool's fully-resolved exposed name, so there is never a
guess about what n8n will see.

## Two listeners, on purpose

The tunnel endpoint is the only thing that needs to face the internet, so it is
the only thing on the public listener, and it always requires a bearer token.
The console, the API, `/mcp` and `/metrics` live on a separate listener that
binds loopback and is **unauthenticated by default** — not because auth was
skipped, but because not being reachable is a stronger guarantee than a shared
secret. If you do expose it, set `MCP_SWITCHBOARD_PRIVATE_TOKEN`.

## Configuration

Everything is configured with `MCP_SWITCHBOARD_*` environment variables, read
from the process environment and optionally from a `.env` file. See
[`.env.example`](.env.example) for the full list.

Any value that starts with `/` and points at an existing regular file is replaced
by that file's contents. So secrets are passed as paths, with no separate
`*_FILE` variables:

```sh
MCP_SWITCHBOARD_TUNNEL_TOKEN=literal-value
MCP_SWITCHBOARD_TUNNEL_TOKEN=/run/secrets/mcp-switchboard/tunnel-token
```

Directories are never substituted, so path-valued settings like
`MCP_SWITCHBOARD_DATA_DIR` behave normally. A setting marked secret that points
at a missing file fails at startup rather than silently authenticating with the
literal string `/run/secrets/...`.

## Observability

`/metrics` exposes `mcpsb_connections_active`, `mcpsb_servers{state}`,
`mcpsb_tools_total{label,server}`, `mcpsb_tool_calls_total{label,server,tool,status,source}`,
`mcpsb_tool_call_duration_seconds`, `mcpsb_tunnel_frames_total{direction}` and
`mcpsb_loki_dropped_total`.

With `MCP_SWITCHBOARD_LOKI_ENABLED=true`, each tool call and lifecycle event is
batched and pushed to Loki. The queue is bounded and drops (counting the drops)
rather than ever blocking or failing a tool call if Loki is down.

Every call — from the console and from MCP consumers alike — is also written to a
SQLite log with its full arguments and result, browsable in the console and
subject to `RETENTION_DAYS` / `MAX_ROWS`.

## Layout

```
hub/          the service: tunnel, MCP endpoint, console, API, exporters
client/       the tunnel client, deliberately dependency-light (no MCP dep)
servers/      first-party MCP servers (harness: filesystem, git and shell tools, on by default in the client)
nix/          NixOS module, uv2nix package set, VM test
tests/        end-to-end test: real client, real MCP server, real consumer
docs/         PROTOCOL.md, the tunnel wire format; EVALS.md, the agent eval harness
evals/        agent eval harness (run_evals.py; talks to a running hub)
```

## Development

```sh
nix develop                      # or: uv sync
pytest hub/tests client/tests servers/harness/tests -q # unit
pytest tests -q                  # end to end (spawns a real client process)
nix build .#checks.x86_64-linux.vm -L   # NixOS VM test, needs KVM
```

The client and hub each carry byte-identical copies of `protocol.py` and
`envconf.py` so the client needs no dependency on the hub; a test fails if they
drift.

See [spec.md](spec.md) for the behavioural specification.

## Licence

MIT.