Mcp Switchboard — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Mcp Switchboard (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
<div align="center">
<img src="assets/banner.svg" alt="mcp-switchboard — a message bus for AI agents" width="100%">
</div>
[ the problem ]You're running multiple AI agents. Claude Code handles one task, an Ollama-backed daemon handles another. When they need to share information, you are the relay — copying output from one, pasting it into the other, manually keeping them in sync.
That's the human-as-middleman problem. Switchboard eliminates it.
[ what it is ]A centralized, self-hosted MCP server that acts as a message bus between agents. Any MCP-capable agent — Claude Code, Hermes, Ollama, or anything you add later — connects with one HTTP URL and a bearer token. From there, agents can:
One container. One URL. No broker, no Redis, no external dependencies. State lives in SQLite and survives restarts.
[ why not a2a ]Google's Agent-to-Agent protocol is the enterprise standard for agent coordination. It's well-designed and well-funded. It also requires implementing Agent Cards, capability discovery schemas, and a new protocol stack — which is the right call if you have an engineering team and an enterprise deployment.
[!TIP] If you want two agents talking to each other this afternoon, Switchboard is the answer.
| Switchboard | A2A | |
|---|---|---|
| Setup | docker run, one env var | Agent Cards + capability discovery + protocol implementation |
| Dependencies | None (SQLite) | Protocol stack |
| Best for | Homelab, small teams, self-hosted | Enterprise, multi-vendor, large scale |
| Governance | You | Linux Foundation (Google, Anthropic, OpenAI, Microsoft, AWS) |
[ quick start ]Self-host the whole thing on any Docker box. One command brings it up, generates a token, and prints the line your agents use to connect:
$ git clone https://github.com/jemplayer82/mcp-switchboard && cd mcp-switchboard
$ ./deploy/quickstart.sh # Windows: .\deploy\quickstart.ps1Prefer to drive Compose yourself:
$ docker compose up -d
$ docker compose logs switchboard # shows the auto-generated tokenOr a bare docker run — no config at all:
$ docker run -d --name switchboard \
-p 3107:3107 -v switchboard-data:/data \
ghcr.io/jemplayer82/mcp-switchboard:latest
$ docker logs switchboard # the token is printed hereNo token? One is generated on first boot, persisted to the /data volume, and printed in the logs. Pin your own anytime with -e SWITCHBOARD_MCP_TOKEN=… (or .env).
$ curl -sf http://localhost:3107/healthz
# → {"ok":true}That's it. Point your agents at http://your-host:3107/mcp — or let the one-command installer do it.
[!NOTE] Examples throughout use port 3107 — what the container listens on and the Compose default. To publish a different host port, setSWITCHBOARD_PORT(the container stays on 3107) and substitute it wherever you see3107below.
[ how it works ] Claude Code ──┐
├──► http://your-host:3107/mcp ──► bus.js (singleton)
Hermes daemon ─┘ Bearer auth ├─ EventEmitter (sub-second wakeups)
└─ SQLite (durable, survives restarts)bus singleton. State is shared across all connections automatically.wait_for_message holds the HTTP response open (up to 25s) and returns the instant a message arrives. Loop it for live receipt.set_status to report what they're working on. get_activity returns a cross-agent feed so any agent can see what the others are doing.{ok, messages, cursor} plus the full activity feed when include_activity:true.[ wiring an agent · one command ]The switchboard serves its own installer. Point a host at it and it writes the config, drops the hooks, merges your settings.json, and adds the MCP entry — the whole manual dance below, done for you. The base URL is baked into the script as it's served, so the agent targets the exact host it downloaded from — no IP to type.
Prerequisites: node on PATH (Claude Code already requires it); curl too on Linux/macOS. Nothing else.
# Linux / macOS
$ curl -fsSL http://your-host:3107/install.sh | sh -s -- --agent-id myagent --token <token>
# add --with-daemon to also install the headless responder (wakes on a message
# even when no session is open — see [ headless responder ])# Windows (PowerShell)
> $env:SWITCHBOARD_AGENT_ID='myagent'; $env:SWITCHBOARD_MCP_TOKEN='<token>'
> irm http://your-host:3107/install.ps1 | iex<token> is the value from your server's startup logs (or whatever you pinned). Restart your Claude Code session afterward so the hooks load. Re-running is safe — every step is idempotent and backs up what it touches.
Verify it worked. After restarting, the agent should show up online. From any connected agent call list_agents, or check over REST:
$ curl -s -X POST http://your-host:3107/sync \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{"agent_id":"myagent","activity":"idle"}'
# → {"ok":true, "messages":[...], "cursor":N} (a 200 means you're wired in)Uninstall. Remove the four switchboard hook entries from ~/.claude/settings.json, delete ~/.switchboard/config.json and ~/.claude/hooks/switchboard-*.mjs, and drop the switchboard entry from mcpServers in ~/.claude.json. If you installed the daemon: systemctl --user disable --now claude-code-agent.
[!TIP] Already a Claude agent connected to the bus? Just call thebootstraptool with youragent_id. It returns the one-line command plus the full hook contents and merge instructions, so you can self-install without leaving the session.
[!NOTE] The installer and hook code are served unauthenticated on purpose — they contain no secrets. The token is supplied by you at install time and is the only sensitive value.curl … | shruns remote code, so pull the script and read it first if you want:curl http://your-host:3107/install.sh.
[ wiring your agents · manual ]What the installer does, if you'd rather do it by hand.
Add to ~/.claude.json under mcpServers:
"switchboard": {
"type": "http",
"url": "http://your-host:3107/mcp",
"headers": { "Authorization": "Bearer your-secret-token" }
}Claude Code works as a sender anytime during a live session. As a responder, install the hooks (see [ hooks ] below) — they deliver inbound messages automatically during live sessions without you relaying anything.
Same URL and bearer token in its MCP config.
To receive messages, call wait_for_message in a loop — it waits up to 25 seconds and returns the moment something arrives. When it returns (message or timeout), call it again immediately. That's it.
[!IMPORTANT] Always use the full 25-second timeout. Don't poll with short intervals — a reply from another agent takes as long as a Claude tool call, which is almost always longer than a 1–5 second poll. Loop immediately with no sleep between calls.
Same pattern: HTTP URL + Authorization: Bearer header. Any client that speaks MCP over streamable HTTP works.
[ provider compatibility ]Switchboard speaks standard streamable-HTTP MCP. How each major provider connects:
| Provider | Native MCP | Notes |
|---|---|---|
| Claude Code | ✓ | HTTP MCP + hooks (see above) |
| OpenAI (Responses API) | ✓ | Pass "type":"mcp" in the tools array per request — docs |
| xAI Grok | ✓ | Same shape as OpenAI Responses API — authorization field in the tool object |
| Google Gemini | ✓ experimental | streamablehttp_client in the Python SDK; gemini mcp add in the CLI |
| Open WebUI (+ Ollama) | ✓ (v0.6.31+) | Admin → External Tools → MCP (Streamable HTTP) → paste URL + token |
| LangChain | ✓ | langchain-mcp-adapters — MultiServerMCPClient with streamable_http transport |
| LlamaIndex | ✓ | llama-index-tools-mcp — BasicMCPClient(url, headers={"Authorization": "Bearer …"}) |
| Ollama (raw) | ✗ | No native MCP client — use Open WebUI above, or call POST /sync directly |
[!IMPORTANT] OpenAI and Grok dial out from their cloud — your switchboard must be reachable on a public URL (Tailscale Funnel, ngrok, etc.). A private LAN address won't work. All other providers in the table above run the MCP client in your own process, so a LAN address is fine.
Any agent that can make HTTP requests can use the /sync endpoint to check in and drain messages without doing a full MCP handshake:
# Check in, drain inbox, report status — one call does all three
curl -s -X POST http://your-host:3107/sync \
-H "Authorization: Bearer your-secret-token" \
-H "Content-Type: application/json" \
-d '{"agent_id": "my-agent", "activity": "idle"}'
# → {"ok":true, "messages":[...], "cursor":42}[!NOTE]agent_idgoes in the JSON body — not a header. Add"include_activity": trueto also get the cross-agent activity feed.
If you want the full MCP tool surface (send messages, long-poll, etc.), you can speak MCP directly over HTTP. The two required headers that catch people out:
curl -s -X POST http://your-host:3107/mcp \
-H "Authorization: Bearer your-secret-token" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"my-agent","version":"1"}}}'[!WARNING] TheAcceptheader must include bothapplication/jsonandtext/event-stream, comma-separated. Sending onlytext/event-streamreturns406 Not Acceptable. This is a standard MCP streamable HTTP requirement — the server needs to know you can handle either response format.
[ tools ]| Tool | Purpose |
|---|---|
register_agent | Register or refresh an agent. Idempotent. Pass wake_url for daemon webhook-wake. |
list_agents | All agents with online presence flag and current activity. |
create_channel | Create a channel (idempotent). |
list_channels | Channels and member counts. |
join_channel | Join a channel. Cursor initializes at current max — no history flood on join. |
send_message | Send direct (to) or to a channel (channel_id). Supports type, thread_id, reply_to. |
wait_for_message | Long-poll receive. Returns instantly on backlog or arrival, else after timeout (1–25s). |
get_messages | Non-blocking history or drain. peek to read without advancing cursor. |
ack | Explicitly advance read cursor. For peek-then-act flows. |
heartbeat | Refresh presence between polls. |
set_status | Report what this agent is currently working on. |
get_activity | Cross-agent activity feed + presence snapshot. |
bootstrap | Returns the one-line install command for your platform + full hook file contents + settings.json merge snippet. Call from any connected agent to self-install. |
[ hooks — inbound delivery for claude code ]The hooks/ directory contains Node ESM scripts that wire Claude Code into the switchboard automatically. Install them in ~/.claude/settings.json. They are fire-and-forget with a 1.5s timeout — they never block your session.
switchboard-publish.mjs — PostToolUse + StopFires on every tool call and at the end of each turn.
POST /sync, publishes the current activity, and injects any arriving DMs as additionalContext so Claude sees them before its next action in the same turn.{"decision":"block"} to keep the turn alive so Claude can reply before going idle.stop_hook_active:true is in the payload, exits silently — prevents infinite loops. The platform enforces a hard cap of 8 blocks per turn regardless.switchboard-digest.mjs — SessionStart + UserPromptSubmitFires at session start and before every user prompt. Calls POST /sync with include_activity:true to drain any messages that arrived during idle time and surface the cross-agent activity feed as context.
Create ~/.switchboard/config.json:
{
"base": "http://your-host:3107",
"token": "your-secret-token",
"agent_id": "claude-code",
"name": "Claude Code",
"inbound": {
"deliver": true,
"block_on_stop": true
}
}Set block_on_stop: false to disable the Stop-hook blocking without redeploying. Set deliver: false to disable mid-turn injection entirely.
[ headless responder ]The hooks only deliver to a running session. To make an agent answer when no session is open at all, install the daemon — a small Python loop that registers on the bus, long-polls for messages with wait_for_message (sub-second delivery), and pipes each one to claude --print. It ships in the repo under daemon/ and reads the same ~/.switchboard/config.json as the hooks.
# Linux — installs the daemon + a systemd user service, enabled and started
$ curl -fsSL http://your-host:3107/install.sh | sh -s -- \
--agent-id myagent --token your-secret-token \
--with-daemon --allowed-senders Claude,FredIt runs as a systemd user service (claude-code-agent), so systemctl --user status claude-code-agent shows its health and journalctl --user -u claude-code-agent its logs.
[!WARNING] The daemon feeds untrusted bus content into an LLM, so it's fail-closed: without--allowed-senders(a comma-separated list of agent ids you trust) it drops every message. Its tool surface is also restricted by default (CHANNEL_DISALLOWED_TOOLS) so a prompt-injection can't run shell commands or read local secrets. Loosen that only if you fully trust every sender. Because identity is self-asserted (shared-token model), the allowlist is defense-in-depth, not authentication. SeeSECURITY.md.
[!WARNING] The daemon needs the Claude CLI authenticated on that host, or every reply bouncesNot logged in · Please run /login.claude --printis non-interactive and has no slash commands, so you can't fix this over the bus. Authenticate once on the host (claude, then/login) or setANTHROPIC_API_KEYin the systemd unit.
[!NOTE] Windows has no headless responder.--with-daemonis Linux-only (it installs a systemd user service). On Windows, run the agent interactively — the hooks deliver inbound messages during any live session. If you genuinely need always-on answering on Windows, wrapdaemon/claude-agent-daemon.pyin Task Scheduler or NSSM yourself (unsupported).
[ deploy · docker compose ]The repo ships a ready-to-run docker-compose.yaml. Everything is env-driven via .env (all optional — see .env.example):
| Variable | Default | Purpose |
|---|---|---|
SWITCHBOARD_MCP_TOKEN | auto-generated | Bearer token every agent uses. Unset → generated on first boot, persisted to /data, printed in logs. |
SWITCHBOARD_PORT | 3107 | Host port to publish (container always listens on 3107). |
SWITCHBOARD_PUBLIC_BASE | Host header | Public URL agents reach you on. Only needed behind a reverse proxy / custom domain; otherwise auto-detected per request. |
$ cp .env.example .env # optional — edit to pin a token / port / domain
$ docker compose up -d[!TIP] Running behind a TLS reverse proxy or custom domain? Set SWITCHBOARD_PUBLIC_BASE=https://switchboard.example.com so the served install one-liner targets the public URL instead of the internal Host header.[!IMPORTANT] If you pinSWITCHBOARD_MCP_TOKENyourself, set it in.envor the environment — never commit it.
[ portainer deployment ]The prebuilt image is published to ghcr.io/jemplayer82/mcp-switchboard:latest by GitHub Actions on every push to main. Deploy via Portainer as a standalone stack using the compose file above. Set SWITCHBOARD_MCP_TOKEN in the Portainer stack environment — not in the committed compose file.
After a fresh CI build, force-pull the latest image before redeploying:
$ docker compose pull && docker compose up -d[!WARNING] Portainer'sGET /api/stacks/<id>returnsEnv: [](secrets redacted). If you PUT that empty array back, it wipes the environment variables. Always re-supply the fullEnvarray on any stack update.
[ testing ]Self-contained bus smoke test (temp DB, no server required):
$ node test/smoke.mjsHook contract test (spins up a local server, fires synthetic hook payloads):
$ bash test/hook_contract_test.shTwo-client real-time test against a live server:
# Terminal 1
$ SWITCHBOARD_MCP_TOKEN=your-token node test/receiver.js
# Terminal 2
$ SWITCHBOARD_MCP_TOKEN=your-token node test/sender.js receiver "hello"Pass: message delivered in under 1 second. Run the sender before the receiver to prove backlog durability. Restart the container mid-test to prove SQLite persistence.
[ troubleshooting ]| Symptom | Cause & fix |
|---|---|
401 Unauthorized | The client's token doesn't match the server's. Check ~/.switchboard/config.json (or the Authorization header) against the token in the server logs. |
406 Not Acceptable | The Accept header is missing a value. It must be application/json, text/event-stream (both, comma-separated) on every /mcp request. |
Not logged in · Please run /login | A daemon host whose Claude CLI isn't authenticated. Run claude → /login on that host (or set ANTHROPIC_API_KEY in the unit). See headless responder. |
| Agent never shows online | Hooks not loaded — restart the session after installing. Otherwise: wrong base in config, the server isn't reachable from the agent, or (headless) the daemon isn't running (systemctl --user status claude-code-agent). |
address already in use on startup | The host port is taken. Set SWITCHBOARD_PORT to a free port (the container still listens on 3107). |
| OpenAI / Grok can't connect | They dial out from their cloud, so a LAN address is unreachable. Expose a public URL (Tailscale Funnel, ngrok) and set SWITCHBOARD_PUBLIC_BASE. Providers that run the MCP client locally are fine on a LAN address. |
| Token changed after redeploy | You're relying on the auto-generated token but lost the /data volume. Pin SWITCHBOARD_MCP_TOKEN so it's stable across redeploys. |
[ honest limitations ][!WARNING] One replica only. The in-process EventEmitter and single-writer SQLite assume one container. Do not scale horizontally against the same volume.
SWITCHBOARD_MCP_TOKEN and self-assert their agent_id. Fine for a trusted home network. Per-agent tokens are a straightforward future upgrade.--with-daemon) to answer with no session at all. Daemons like Hermes handle this with a long-poll loop or wake_url.wake_url) but only if the harness exposes an HTTP trigger endpoint. It cannot spin up a model from nothing. Push-wake is fail-closed: the bus only POSTs to a wake_url whose host is listed in SWITCHBOARD_WAKE_ALLOWED_HOSTS (an SSRF guard), so set it if you rely on wake.[ architecture notes ]Switchboard is intentionally simple:
EventEmitter handles real-time wakeups. No Redis, no Kafka, no external dependencies.busy_timeout=5000, monotonic message IDs as cursors. Proven, boring, reliable.McpServer + StreamableHTTPServerTransport per POST (stateless pattern). All handlers close over one bus singleton. res.on('close') tears down the per-request transport — never the singleton.wait_for_message awaits an EventEmitter wakeup or a ≤25s timeout. An AbortSignal from res.on('close') cancels the waiter so listeners don't leak.better-sqlite3 is synchronous, the drain is an atomic claim — concurrent sessions sharing a mailbox can't double-deliver.[ license ]Copyright © 2026 Fathom Consulting LLC. Released under the GNU Affero General Public License v3.0.
Commercial licensing available for proprietary deployments — contact via github.com/jemplayer82.
#### [ acknowledgments ]
Architecture patterns adapted from gsd-browser-mcp. Built with @modelcontextprotocol/sdk and better-sqlite3.
<div align="center">
<img src="assets/fathom-works-dark.svg" alt="Fathom Works" height="40">
</div>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.