Bridge MCP
An MCP server that lets an AI assistant manage your machines over SSH — safely. 476 tools in 77 groups, a command whitelist and blacklist, secret redaction, an audit log, and a confirmation before anything destructive.
MCP client (Claude Code, …) ◄── MCP over stdio ──► bridge-mcp ◄── SSH ──► your hosts
Table of Contents
- Features
- Quick Start — from download to a first tool call
- Using it from an MCP client
- Upgrading to 4.0.0
- Examples
- Configuration reference
- Tool parameters
- Tool Groups
- MCP Prompts & Resources
- CLI
- Daemon Mode
- Protocol Support
- Troubleshooting
- Development
- License
Features
- 476 tools, 77 groups — Linux, Windows, Docker, Kubernetes/K3s, Podman, AWX, Ansible, databases, LDAP, network equipment, certificates and more. Only 8 core groups are on by default; you opt into the rest.
- SSH everywhere, more transports when built in — every build speaks SSH. WinRM, PSRP (PowerShell Remoting), Telnet, Kubernetes exec, serial, AWS SSM, Azure and GCP are cargo features. The release binaries and the Docker image are built with
--features full: they are SSH only (see Install). - Security first — command whitelist and blacklist, built-in secret-redaction patterns plus entropy detection on every output, an audit log, and a confirmation before any destructive tool runs (on by default, over MCP and on the CLI).
- Token-efficient output — server-side
jq_filter/yq_filter/columns/limit, TSV output (60-80% fewer tokens on lists), pagination of truncated output withssh_output_fetch, per-client size limits. - Two ways to list tools —
full(default: every enabled tool's schema) orprogressive(four meta-tools to search, describe and call tools on demand). - MCP 2026-07-28 (Modern) only —
server/discoveropens the connection; a pre-Modern client sendinginitializegets-32022. See Protocol Support. - CLI + MCP — every tool is also a CLI command (
bridge-mcp tool <name> key=value), for scripts and for agents that prefer a shell. - Daemon mode — a Unix-socket daemon keeps SSH connections warm between CLI calls.
- 10,000+ tests —
#![forbid(unsafe_code)], Rust 2024 edition, strict clippy.
Quick Start
Five steps, about five minutes. You need: a Linux or macOS machine to run bridge-mcp on (there is no Windows build of the bridge itself), and SSH key access to at least one host.
1. Install
# Linux x86_64. Other builds: bridge-mcp-linux-arm64, bridge-mcp-macos-arm64, bridge-mcp-macos-x86_64
curl -fsSLO https://github.com/muchiny/bridge-mcp/releases/latest/download/bridge-mcp-linux-x86_64.tar.gz
curl -fsSLO https://github.com/muchiny/bridge-mcp/releases/latest/download/bridge-mcp-linux-x86_64.tar.gz.sha256
sha256sum -c bridge-mcp-linux-x86_64.tar.gz.sha256 # macOS: shasum -a 256 -c …
tar xzf bridge-mcp-linux-x86_64.tar.gz
sudo install -m 0755 bridge-mcp /usr/local/bin/
bridge-mcp --version
Windows hosts over WinRM or PSRP need a source build. The release binaries and the Docker image are SSH only: a config with
protocol: winrmorprotocol: psrpfails to load in them. Windows hosts that run OpenSSH work with any build (os_type: windows). For WinRM/PSRP, do step 2 first, then:git clone https://github.com/muchiny/bridge-mcp && cd bridge-mcp make install # Rust 1.99+; builds --features full,winrm,psrp into ~/.local/bin/bridge-mcp
make installrefuses to install a binary that cannot load your config, which is why the config comes first.
Docker (linux/amd64, SSH only). The image runs bridge-mcp as user
bridge (home /home/bridge) and contains no config. Mount yours, and write
key paths as the container sees them:
docker pull ghcr.io/muchiny/bridge-mcp:latest
docker run -i --rm \
-v ~/.config/bridge-mcp:/home/bridge/.config/bridge-mcp:ro \
-v ~/.ssh:/home/bridge/.ssh:ro \
ghcr.io/muchiny/bridge-mcp:latest serve
# in config.yaml: auth: { type: key, path: /home/bridge/.ssh/id_ed25519 }
The mounted config and key must be readable by the container user and keep
their strict modes (0600).
Claude Code plugin. It adds the /bridge-mcp:bridge and
/bridge-mcp:discover skills and a session hook that downloads the release
binary when bridge-mcp is not on your PATH, and it registers the MCP
server itself — skip step 4's claude mcp add, or you get the server twice. It
uses your default config path (step 2).
claude plugin marketplace add muchiny/bridge-mcp
claude plugin install bridge-mcp@muchiny
Claude Desktop bundle. Each release also ships .dxt and .mcpb bundles,
one per platform. If yours does not install, use the JSON configuration in
step 4 instead.
2. Write a config
bridge-mcp reads one YAML file:
A complete starting config (Linux path shown):
mkdir -p ~/.config/bridge-mcp
cat > ~/.config/bridge-mcp/config.yaml <<'YAML'
hosts:
my-server: # the name you pass as host=my-server
hostname: 192.168.1.100
user: admin
auth:
type: key
path: ~/.ssh/id_ed25519 # must exist, chmod 600
description: "My server"
security:
mode: standard # the default: ssh_exec runs only whitelisted commands
whitelist:
- '^(uptime|hostname|whoami|df -h|free -m)$'
- '^(ls|cat|head|tail|grep|journalctl)( [^;&|`$<>]*)?$'
YAML
chmod 600 ~/.config/bridge-mcp/config.yaml
Three rules the loader enforces, each of which stops the server with exit code 5:
- The file must be private — no group-write and no access for others (
0600or0640). - Every key file must exist and be
0600(or0400). - Every key must be spelled exactly — unknown keys are rejected, not ignored.
And one the SSH layer enforces: host keys are checked strictly by default.
Connect once by hand so the host is in ~/.ssh/known_hosts:
ssh [email protected] true
Why a whitelist?
ssh_execruns any command you give it, so in the defaultstandardmode it only runs commands that match awhitelistentry — with an empty whitelist it refuses everything. The specialised tools (ssh_metrics,ssh_service_status,ssh_docker_ps, …) build their own commands and are checked against the blacklist only. Anchor every entry (^…$): the whole command line is matched, so^lsalone would also acceptls; rm -rf ~.mode: permissivedrops the whitelist check entirely.
3. Check it from the shell
bridge-mcp validate # config loads? prints host and tool counts
bridge-mcp tool ssh_metrics host=my-server 'metrics=["load","memory"]' # SSH and auth work?
validate opens no connection; ssh_metrics does. If either fails, see
Troubleshooting.
4. Connect your MCP client
Claude Code:
claude mcp add --scope user ssh-bridge -- bridge-mcp serve
claude mcp list # ssh-bridge should read ✔ Connected
Add --config /absolute/path/config.yaml before serve for a non-default
config. Everything after -- is the server's command line.
Connected but no tools? bridge-mcp only speaks MCP 2026-07-28. Recent Claude Code negotiates it by default (its changelog says so from 2.1.292). Older versions open with the Legacy handshake and see zero tools: set
MCP_PROTOCOL_NEGOTIATION=autoin Claude Code's environment — theenvblock of~/.claude/settings.json(or a project's.claude/settings.json), orexportit before startingclaude— then restart. Passing it withclaude mcp add -esets it for bridge-mcp, which ignores it.
Claude Desktop and other stdio clients — add a server entry (for Claude
Desktop on macOS: ~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"ssh-bridge": {
"command": "/usr/local/bin/bridge-mcp",
"args": ["serve"]
}
}
}
Use the absolute path: desktop apps do not inherit your shell's PATH. The
client must speak MCP 2026-07-28 — a client that opens with initialize gets
-32022 Unsupported protocol version and no tools. If yours does not yet,
v1.20.0 is the last release that speaks the Legacy handshake (2.x was never
published). The CLI (bridge-mcp tool …) works either way.
5. Use it
Ask your assistant: "What is the load and memory use on my-server?" It calls
ssh_metrics for you. From here:
- Turn on the tool groups you need (Docker, Kubernetes, Windows, …) — see Enabling tool groups.
- Read Destructive tools before relying on confirmations: they depend on your client.
Using it from an MCP client
What the client sees
tool_groups:
listing: progressive
The four meta-tools, available in both modes:
Groups that are not enabled are invisible to all four.
Enabling tool groups
Only core, file_ops, directory, process, monitoring, network,
systemd and sessions are on by default. Turn others on, or defaults off,
then restart the MCP server (reconnect it in your client):
tool_groups:
groups:
docker: true
kubernetes: true
journald: true
sessions: false # turn a default group off
Names are listed under Tool Groups. A misspelled group name is
silently ignored, so check with bridge-mcp list-tools --groups-only. Calling
a tool whose group is off returns "registered but its group … is not enabled".
Destructive tools and confirmation
45 tools are annotated destructive (destructiveHint: true), ssh_exec
among them: ssh_file_write, ssh_process_kill, ssh_service_stop,
ssh_k8s_delete, ssh_helm_rollback, ssh_win_service_stop, … With
security.require_elicitation_on_destructive: true (the default):
- Over MCP, the server asks the client to confirm before running the tool
(the 2026-07-28 elicitation round trip). A client that does not declare the
elicitationcapability gets a refusal saying so — the tool never runs unconfirmed. Going throughmcp_call_tooldoes not bypass this. - On the CLI,
bridge-mcp toolpromptsProceed? [y/N]on a terminal. Without a terminal (scripts, CI) it refuses with exit code 4 unless you pass--yes. Every decision is written to the audit log. - Over HTTP (
serve-http), destructive tools are refused outright.
ssh_exec_multi, ssh_session_exec and ssh_pty_exec also run commands you
write but are not annotated destructive, so they are never asked about — the
whitelist is their only gate.
The blacklist is checked after the confirmation: a blacklisted command
asks, then is refused anyway. The built-in blacklist denies systemctl stop and
systemctl disable, so ssh_service_stop (after asking) and
ssh_service_disable are refused unless you replace the blacklist.
Setting require_elicitation_on_destructive: false turns confirmations off for
MCP and the CLI. Don't, unless every caller is trusted.
Output size
Most tools take filters that run on the server, before truncation — see
Tool parameters. Output past max_output (default 40,000
characters; Claude clients get 80,000) is cut, and over MCP the result carries
an output_id: ssh_output_fetch output_id=… offset=… returns the rest.
save_output=/path writes the full output to a file on the machine running
bridge-mcp.
Upgrading to 4.0.0
4.0.0 changes behaviour that scripts and configs rely on. The protocol does not
change (still MCP 2026-07-28 only). Read the
### BREAKING list in the changelog first; the
items most people hit:
- Configs that used to load may not: sanitizer patterns over 512 bytes, more
than 50 of them, or a set that does not compile together;
ssh_net_equip_confignow requires the host to be taggednetwork-equipment. - Undeclared arguments are refused, over MCP and on the CLI (exit 1), instead
of being dropped — a typo like
jq_fitler=used to return unfiltered output. - A failed remote command is reported:
bridge-mcp toolexits 6, and the MCP result carriesisError: truefor tools whose command the bridge built. - Destructive tools prompt on the CLI (exit 4 without a terminal;
--yesfor scripts). audit.logformat and the library API changed — see the changelog.
Examples
CLI calls; the same tools are what your MCP client calls. Global flags go
before the first key=value.
Diagnose a Linux service (systemd is on by default; journald is not):
bridge-mcp tool ssh_service_status host=web1 service=nginx
bridge-mcp tool ssh_service_logs host=web1 service=nginx lines=200
bridge-mcp tool ssh_journal_query host=web1 unit=nginx priority=err since="-1h"
Inspect Kubernetes with a fraction of the tokens (enable kubernetes). Ask
kubectl for JSON, filter it on the server, return TSV:
bridge-mcp --jq '.items[] | [.metadata.name, .status.phase, .spec.nodeName]' --output-format tsv \
tool ssh_k8s_get host=k8s resource=pods namespace=default output=json
bridge-mcp tool ssh_k8s_describe host=k8s resource=pod name=api-7d-xyz namespace=default
bridge-mcp tool ssh_k8s_logs host=k8s pod=api-7d-xyz container=app tail=100
Windows and Linux from one CLI (enable windows_services, windows_events,
iis; the host needs os_type: windows, and WinRM/PSRP need a
source build):
bridge-mcp tool ssh_service_status host=web1 service=postgres
bridge-mcp tool ssh_win_service_status host=appsrv name=W3SVC
bridge-mcp tool ssh_win_event_query host=appsrv log=System count=20
Scripted, audited change. ssh_helm_rollback is destructive: on a terminal
it asks, in a script it needs --yes, and the audit log records the decision,
the arguments, the sanitized output and the exit code:
bridge-mcp --yes tool ssh_helm_rollback host=k8s release=api revision=7
Configuration reference
One YAML file, looked up in this order:
--config <path>/-c <path>(a global flag; every subcommand accepts it);<config_dir>/bridge-mcp/config.yaml—~/.config/bridge-mcp/on Linux ($XDG_CONFIG_HOMEif set),~/Library/Application Support/bridge-mcp/on macOS;<config_dir>/mcp-ssh-bridge/config.yaml, the location before the rename, used only when (2) does not exist.
No environment variable selects the file. Every subcommand loads and validates
it before doing anything, and exits 5 if it cannot. bridge-mcp validate
checks a file without contacting any host; bridge-mcp config-diff shows what
it changes from the defaults. config/config.example.yaml
is a commented example of nearly every key.
File-level rules:
- Unknown keys are rejected at every level — a typo stops the server rather than being ignored.
- Permissions: no group-write, no access for others (
0600or0640). Key files:0600or0400. - No
${VAR}interpolation —${WIN_PASSWORD}is sent as those exact characters. ~is expanded only inhosts.*.auth.path(key auth),audit.pathandssh_config.path.- Hot reload:
bridge-mcp servewatches the file.security.mode,whitelistandblacklistapply live;security.sanitizeneeds a restart.serve-httpdoes not watch it. - Size: at most 1 MiB, 50 levels of nesting, 100 anchors, 1,000 aliases, 10,000 nodes.
Top-level sections: hosts (required), security, limits, audit,
sessions, tool_groups, ssh_config, http, awx, rbac. There is no
recording: section — session recording is driven by the recording tool
group and the MCP_RECORDING_KEY environment variable — and OpenTelemetry is
configured with OTEL_EXPORTER_OTLP_ENDPOINT / OTEL_SERVICE_NAME.
hosts.<alias>
At least one host is required (hosts imported through ssh_config count).
auth is a map tagged by type:
key and agent are rejected on WinRM/PSRP hosts.
hosts:
bastion:
hostname: bastion.example.com
user: admin
auth: { type: agent }
internal-db: # reached through the bastion
hostname: 10.0.0.5
user: deploy
proxy_jump: bastion
auth: { type: key, path: ~/.ssh/id_ed25519 }
behind-proxy:
hostname: 10.0.0.50
user: deploy
socks_proxy: { hostname: proxy.corp.com, port: 1080, version: socks5 }
auth: { type: key, path: ~/.ssh/id_ed25519 }
windows-dc: # Windows with OpenSSH: works with any build
hostname: 192.168.1.200
user: Administrator
os_type: windows
shell: powershell
auth: { type: key, path: ~/.ssh/id_ed25519 }
windows-app: # WinRM over HTTPS: needs a --features winrm build
hostname: 192.168.1.201
port: 5986
user: Administrator
protocol: winrm
os_type: windows
auth: { type: ntlm, password: "change-me", domain: CORP }
prod-server:
hostname: 192.168.1.100
user: deploy
sudo_password: "your-sudo-password" # used by tools called with sudo=true
auth: { type: key, path: ~/.ssh/id_ed25519 }
core-switch:
hostname: 10.0.0.2
user: netadmin
tags: [network-equipment] # required by ssh_net_equip_config
auth: { type: password, password: "change-me" }
security
security.sanitize:
limits
audit
sessions
Persistent shells opened with ssh_session_create:
tool_groups
ssh_config
Only Host, HostName, Port, User, IdentityFile and ProxyJump are
read; wildcard Host blocks and blocks without HostName are skipped. An
IdentityFile that exists becomes key auth, otherwise the agent is used.
Imported hosts get host_key_verification: acceptnew. A host defined in YAML
wins over an imported one with the same alias.
http (only for serve-http)
http.session_timeout_seconds and http.max_sessions were retired in 3.0.0
and are rejected.
awx (for the awx tool group)
rbac
Not enforced. rbac.enabled: true is rejected at load so that nobody
believes roles protect anything; default_role and roles are accepted and
ignored.
Tool parameters
Every tool documents its own arguments: bridge-mcp describe-tool <name> on
the CLI, mcp_describe_tool over MCP. On top of those, most tools accept the
parameters below. They behave the same over MCP and on the CLI (where each one
is a key=value pair; arrays and objects are written as JSON). A parameter the
tool does not declare is refused — check describe-tool first.
Which reduction parameters a tool takes depends on its output kind, shown by
describe-tool with its reduction strategy:
A reduction parameter the output kind does not support is refused. For a
RawText tool, narrow the command itself, or use save_output. Some tools
produce JSON only when asked: ssh_k8s_get needs output=json for jq_filter.
Pagination. Truncated output is cut keeping its start and end. Over MCP (or
on the CLI with the daemon running), the result carries an
output_id; ssh_output_fetch output_id=<id> offset=<chars> (optional limit)
returns the rest, for limits.output_cache_ttl_seconds (300 s). A plain CLI
call has no cache: use save_output= or max_output=0 instead.
Tool Groups
476 tools organized in 77 groups. Only the eight core groups (core,
file_ops, directory, process, monitoring, network, systemd,
sessions) are enabled out of the box — everything else (containers, K8s,
Windows, cloud, …) is opt-in (how):
tool_groups:
groups:
docker: true # opt in to a non-default group
kubernetes: true
sessions: false # opt out of a default group
Representative tools per group — larger groups (e.g. kubernetes has 83,
awx 43) list only the common ones. For the full set, enable the group and run
bridge-mcp list-tools --group <name>.
MCP Prompts & Resources
CLI
The same binary runs every tool from a shell — for scripts, and for agents that would rather call a command than load MCP schemas (10-32x fewer tokens).
bridge-mcp tool ssh_docker_ps host=prod # any enabled tool, key=value arguments
bridge-mcp tool ssh_k8s_get --json-args '{"host":"k8s","resource":"pods","namespace":"default"}'
bridge-mcp --json tool ssh_docker_ps host=prod # the whole MCP result as JSON
bridge-mcp list-tools --groups-only # enabled groups
bridge-mcp list-tools --search kubernetes # keyword search
bridge-mcp describe-tool ssh_docker_ps # schema, output kind, reduction strategy
Subcommands
Every subcommand loads the config first, so even list-tools needs a valid one.
Global flags
Put flags before the first key=value. tool reads everything after it as
tool arguments, so bridge-mcp tool ssh_docker_ps host=prod --json fails with
exit 5 (Invalid argument '--json'). bridge-mcp --json tool … and
bridge-mcp tool ssh_docker_ps --json host=prod both work. Write columns as
JSON (columns='["NAMES","STATUS"]') or use --columns NAMES,STATUS: a bare
columns=NAMES,STATUS is not split.
There is no --timeout, --max-output or --save-output flag: use the tool
parameters timeout_seconds=, max_output= and save_output=. Logs go to
stderr; RUST_LOG=error silences them.
Exit codes
Codes 3, 4 and 6 are specific to tool; other subcommands exit 1 on their own
errors. Not every tool reports 6, and through a daemon 2, 3 and 4 become 1 — see
docs/cli-exit-codes.md before a script relies on it.
Shell completions
bridge-mcp completions bash > ~/.bash_completion.d/bridge-mcp
bridge-mcp completions zsh > ~/.zfunc/_bridge-mcp
bridge-mcp completions fish > ~/.config/fish/completions/bridge-mcp.fish
Claude Code: prefer the CLI
To have Claude Code call the CLI through Bash instead of MCP tools (fewer tokens), copy the provided rule and skill into your project:
mkdir -p .claude/rules .claude/skills/bridge
cp config/claude-code/rules/cli-bridge.md .claude/rules/
cp config/claude-code/skills/bridge/SKILL.md .claude/skills/bridge/
This adds the /bridge command (/bridge config for guided setup,
/bridge docker to explore a group).
Daemon Mode
A long-lived daemon keeps SSH connections, the output cache and connection pools warm between CLI calls:
bridge-mcp daemon start # foreground; Ctrl-C to stop
bridge-mcp daemon status
bridge-mcp daemon stop
The socket is $XDG_RUNTIME_DIR/bridge-mcp.sock, or /tmp/bridge-mcp-$UID.sock
when XDG_RUNTIME_DIR is unset. bridge-mcp tool forwards its calls to a
daemon at that default path only — a daemon started with a custom
--socket-path is not used by the CLI. Through the daemon:
- truncated output gets an
output_id, andssh_output_fetchworks; - exit codes 2, 3 and 4 become 1 (details);
- destructive tools are refused even with
--yes, because the daemon's own confirmation gate applies and the CLI cannot answer it.
MCP clients do not connect to the daemon: they start their own bridge-mcp serve.
After upgrading, restart the daemon — the CLI warns on stderr when the daemon
was built from a different revision.
With the matching cargo features, two pools amortize handshakes: WinRmPool
(--features winrm, 120 s TTL, reuses the HTTP client per host) and
K8sExecPool (--features k8s-exec, 300 s TTL, caches the Kubernetes client).
Protocol Support
Since 3.0, bridge-mcp implements MCP revision 2026-07-28 and only that
revision. This section is for client implementers; using the server needs none of it.
Transports
stdio (default, bridge-mcp serve) — one JSON-RPC message per line on
stdin/stdout. The _meta envelope is the only place the protocol revision
appears; there are no headers here.
Streamable HTTP (bridge-mcp serve-http, requires the http feature) —
POST /mcp only. Requests MUST carry MCP-Protocol-Version and
Mcp-Method, plus Mcp-Name for tools/call, resources/read and
prompts/get, so gateways and WAFs can route without parsing the body. A value
that cannot travel as plain ASCII — a file:// URI with an accent, a name with
edge whitespace — arrives wrapped as =?base64?…?= and is decoded before the
header is compared to the body, which matters here because Mcp-Name mirrors
params.uri on resources/read. A
missing header, or one that contradicts the body, is refused 400 with
JSON-RPC -32020 — the request is REJECTED rather than resolved in favour of
the body, so a gateway can never route on a header that lies. JSON-RPC
batching is not accepted on any transport: an array is refused with -32600
(400 here, and the same code on stdio and the daemon socket).
GET /mcp and
DELETE /mcp return 405 — the standalone SSE stream and the session
lifecycle are gone. There is no Mcp-Session-Id, no Last-Event-ID, and no
redelivery: a broken stream means the client re-issues with a new request id.
Server-to-client notifications arrive on the response stream of a
subscriptions/listen POST.
Anything that used to be keyed off the HTTP session is now an explicit handle
in tool arguments — ssh_session_create returns a session_id you pass to
ssh_session_exec, and a truncated response returns an output_id you pass
to ssh_output_fetch.
Multi Round-Trip Requests
Three things make the server ask the client for something, and all three now
travel the same way: the server returns input_required and the client retries
the original call under a new id with inputResponses and the requestState
attached, as siblings of _meta on params.
Nothing is asked for a capability the client did not declare: a client that
declares no sampling gets its raw output with no summary, and one that
declares no roots runs unscoped. That is the spec's rule, not a fallback.
requestState is HMAC-SHA256 over base64url JSON. It binds a 5-minute TTL and
a digest of the originating call — the tool name AND its arguments, so a
confirmation for one call cannot authorise another. It also binds the
authenticated principal, which is empty on every transport that reaches the
gate today: stdio and the daemon socket authenticate nobody, and HTTP
dispatches ordinary POSTs with no session, so the gate refuses destructive tools
there before issuing any state. The field is signed and checked regardless, so
the cross-user check activates the day a transport supplies a principal. Set
MCP_REQUEST_STATE_KEY on any deployment running more than one bridge-mcp
process behind one address — without it each process signs with its own
startup key, so a retry landing on another instance is refused and the user
confirms forever. A single stdio process needs nothing.
Troubleshooting
Logs go to stderr (your MCP client usually keeps them in its server log);
set RUST_LOG=debug for more, RUST_LOG=error for less.
Failed to load config from … (exit 5) — the line below it says why:
Configuration file not found— wrong path. On macOS the default is~/Library/Application Support/bridge-mcp/config.yaml, not~/.config.has permissions 0644; expected no group-write/other access—chmod 600the config.SSH key file … has permissions …; expected 0600—chmod 600the key; a missing key file also stops the load.unknown field `…`— a misspelled or retired key (rbac.enabled: true,http.max_sessionsandhttp.session_timeout_secondsare rejected too).unknown variant `…`— a value in the wrong case, e.g.AcceptNewinstead ofacceptnew.At least one host must be defined—hosts:is empty (andssh_configimport is off by default).
The MCP server shows "connected" but the model has no tools — the client
used the Legacy handshake: see the note in step 4.
With listing: progressive you see four mcp_* tools: that is expected.
-32022 Unsupported protocol version — the client opened with initialize
or declared a revision this server does not speak; data.supported lists the
one it does (2026-07-28).
-32602 missing _meta[…/protocolVersion] — every request in this revision
must carry protocolVersion and clientCapabilities in _meta.
Command denied: Command not in whitelist (standard mode) — ssh_exec (and
the other free-form command tools) run only whitelisted commands. Add an
anchored regex under security.whitelist, or use a specialised tool.
Command denied: Command matches blacklist pattern: … — the command, or the
command a tool built, matches the blacklist. Built-in examples:
ssh_service_stop and ssh_service_disable (systemctl stop|disable).
Unknown argument `x` for tool `y`. Did you mean `z`? — check the tool's
arguments with describe-tool / mcp_describe_tool. Note that some tools name
the same thing differently (ssh_service_status service=…, ssh_service_disable name=…).
Tool `x` is registered but its group `y` is not enabled (on the CLI:
MCP unknown tool, exit 2) — enable the group under tool_groups.groups.
… did not declare the `elicitation` capability — the tool is destructive
and your client cannot show a confirmation. Use a client that supports MCP
elicitation, or the CLI, where you confirm on the terminal or with --yes.
CLI exits 4 on a destructive tool — no terminal to ask on: add --yes
(before the tool name) once you mean it.
Unknown host: x (exit 3) — the alias is not in your config:
bridge-mcp status lists them.
SSH connection failed / unknown host key — check ssh user@host works by
hand first: that also records the host key in ~/.ssh/known_hosts, which the
default strict verification requires (or set host_key_verification: acceptnew on that host).
Output cache is not available — ssh_output_fetch from a plain CLI call:
use save_output= or max_output=0, or run the daemon.
-32602 requestState is not valid for this request — a confirmation was
replayed onto a different call, arrived after its 5-minute TTL, or was signed by
another process. Without MCP_REQUEST_STATE_KEY, each process signs with its
own startup key, so several instances behind one address refuse each other's
retries. Re-send the original request without requestState and
inputResponses to start over.
Development
make build # Debug build
make release # Optimized build with RELEASE_FEATURES (full,winrm,psrp) into target/release/
make install # make release, check it loads your config, copy to ~/.local/bin
make test # Tests (nextest if installed) + doctests
make ci # fmt-check, lint (pinned + stable + no-default), test, doc, audit, deny, typos, markdownlint
make help # Every target
Rust edition 2024, MSRV 1.99+, #![forbid(unsafe_code)], 10,000+ tests.
Adding a new tool — 3 steps: annotate the struct with #[mcp_tool] (or
#[mcp_standard_tool]), add the mod + pub use line, and (only if
introducing a new group) update ToolGroupsConfig. The inventory crate
auto-registers the handler at compile time.
Two things are NOT automatic, and both fail loudly rather than silently:
- Any new tool changes the total in
.migration-baseline.json. Regenerate it withpython3 scripts/extract_tool_metadata.pyand check the diff;python3 scripts/validate_baseline.pyis what compares the two. No workflow runs it, so nothing in CI will catch the drift for you. - A new group must also be added to the hardcoded
known_groupslist intests/tool_filtering.rs, which enumerates all 77 by name.
See CHANGELOG.md for version history and
docs/RELEASE_CHECKLIST.md for releases. There is no
separate threat-model document: the security design lives in
security, Destructive tools
and Protocol Support.