MB

muchiny/bridge-mcp

Deployment & DevOps
12 stars 5 forks Quality 84 Trend 39MIT

MCP server for secure SSH remote management — 337 tools across 74 groups for DevOps, Docker, Kubernetes, databases, systemd, Windows, cloud, compliance & more. Built in Rust.

Overview

476 tools in 77 groups, a command whitelist and blacklist, secret redaction, an audit log, and a confirmation before anything destructive. - — 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. - — every build speaks SSH. WinRM, PSRP (PowerShell Remoting), Telnet, Kubernetes exec, serial, AWS SSM, Azure and GCP are cargo features. (see Install). - — 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). - — server-side jq_filter / yq_filter / columns / limit, TSV output (60-80% fewer tokens on lists), pagination of truncated output with ssh_output_fetch, per-client size limits.

README

Bridge MCP


Table of Contents


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 with ssh_output_fetch, per-client size limits.
  • Two ways to list tools — full (default: every enabled tool’s schema) or progressive (four meta-tools to search, describe and call tools on demand).
  • MCP 2026-07-28 (Modern) only — server/discover opens the connection; a pre-Modern client sending initialize gets -32022. See Protocol Support.
  • CLI + MCP — every tool is also a CLI command (bridge-mcp tool 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: winrm or protocol: psrp fails 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 install refuses to install a binary that cannot load your config, which is why the config comes first.

2. Write a config

bridge-mcp reads one YAML file:

Platform Default path
Linux ~/.config/bridge-mcp/config.yaml (or $XDG_CONFIG_HOME/bridge-mcp/config.yaml)
macOS ~/Library/Application Support/bridge-mcp/config.yaml
Any the path given with bridge-mcp --config …

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 (0600 or 0640).
  • Every key file must exist and be 0600 (or 0400).
  • 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_exec runs any command you give it, so in the default standard mode it only runs commands that match a whitelist entry — 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 ^ls alone would also accept ls; rm -rf ~. mode: permissive drops 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=auto in Claude Code’s environment — the env block of ~/.claude/settings.json (or a project’s .claude/settings.json), or export it before starting claude — then restart. Passing it with claude mcp add -e sets 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:


Using it from an MCP client

What the client sees

tool_groups.listing tools/list returns Use it when
full (default) every tool of the enabled groups (43 with the defaults), plus the four meta-tools below few groups enabled; the model sees every schema up front
progressive only the four meta-tools many groups enabled; schemas are fetched on demand instead of costing tokens in every session
tool_groups:
  listing: progressive

The four meta-tools, available in both modes:

Tool Arguments Purpose
mcp_list_tool_groups — Enabled groups with their tool counts
mcp_search_tools query (required), group, limit (default 20, max 200) Keyword search over enabled tools, with read-only / destructive hints
mcp_describe_tool name One tool’s full schema, output kind and reduction strategy
mcp_call_tool name, arguments Calls any enabled tool by name — the tool’s own confirmation and filters apply

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 elicitation capability gets a refusal saying so — the tool never runs unconfirmed. Going through mcp_call_tool does not bypass this.
  • On the CLI, bridge-mcp tool prompts Proceed? [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_config now requires the host to be tagged network-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 tool exits 6, and the MCP result carries isError: true for tools whose command the bridge built.
  • Destructive tools prompt on the CLI (exit 4 without a terminal; --yes for scripts).
  • audit.log format 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:

  1. --config / -c (a global flag; every subcommand accepts it);
  2. /bridge-mcp/config.yaml — ~/.config/bridge-mcp/ on Linux ($XDG_CONFIG_HOME if set), ~/Library/Application Support/bridge-mcp/ on macOS;
  3. /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 (0600 or 0640). Key files: 0600 or 0400.
  • No ${VAR} interpolation — ${WIN_PASSWORD} is sent as those exact characters.
  • ~ is expanded only in hosts.*.auth.path (key auth), audit.path and ssh_config.path.
  • Hot reload: bridge-mcp serve watches the file. security.mode, whitelist and blacklist apply live; security.sanitize needs a restart. serve-http does 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.

At least one host is required (hosts imported through ssh_config count).

Key Values Default What it does
hostname string, required — Address to connect to
user string, required — Remote login user
auth map, required — How to authenticate — see the next table
port integer 22 TCP port. Not remapped for WinRM/PSRP: set 5985 or 5986 yourself
description string — Free text shown to the model in the host list
host_key_verification strict, acceptnew, "off" strict strict refuses hosts missing from ~/.ssh/known_hosts; acceptnew records them on first use and refuses a changed key; "off" skips the check (testing only — quote it, YAML reads a bare off as a boolean). Lowercase only: AcceptNew is rejected
proxy_jump alias of another host — Tunnels SSH through that host (bastion). Not with socks_proxy, not on WinRM/PSRP hosts
socks_proxy map: hostname (required), port (1080), version (socks5/socks4, default socks5), username, password — Tunnels SSH through a SOCKS proxy
sudo_password string — Sent to sudo on stdin (never on the command line), over SSH only. Without it, sudo=true needs passwordless sudo
tags list of strings [] Free-form labels. network-equipment is required by ssh_net_equip_config
os_type linux, windows linux Selects command semantics and which tools accept the host. Not inferred from protocol
shell posix, cmd, powershell (alias pwsh) posix on Linux, cmd on Windows Shell used for escaping
protocol ssh; winrm, psrp, telnet, k8sexec, serial, ssm, azure, gcp with the matching cargo feature ssh Transport. Release binaries only have ssh
winrm_use_tls bool (feature winrm) true only on port 5986 HTTPS or HTTP for WinRM/PSRP
winrm_accept_invalid_certs bool (feature winrm) false Accept self-signed certificates
winrm_operation_timeout_secs integer (feature winrm) 60 WS-Management operation timeout
winrm_max_envelope_size integer (feature winrm) 153600 Maximum SOAP envelope, bytes
retry map — Accepted but not used in 4.0.0; use limits.retry_*

auth is a map tagged by type:

type Other keys Notes
key path (required), passphrase Recommended. The file must exist and be 0600/0400
agent — Uses SSH_AUTH_SOCK
password password (required) SSH password. On WinRM/PSRP this is HTTP Basic — use TLS
ntlm password (required), domain WinRM/PSRP only (feature winrm)
certificate cert_path, key_path (both required, PEM, not ~-expanded) WinRM/PSRP only (feature winrm)
kerberos — Accepted, but no current build can connect with it

key and agent are rejected on WinRM/PSRP hosts.

security

Key Values Default What it does
mode strict, standard (alias normal), permissive standard strict and standard (identical in 4.0.0): free-form commands must match the whitelist. permissive: only the blacklist is checked
whitelist list of regexes [] Applies to free-form command tools: ssh_exec, ssh_exec_multi, ssh_session_exec, ssh_canary_exec, ssh_rolling_exec, ssh_fleet_diff. Matched against the whole command line — anchor with ^…$
blacklist list of regexes 32 built-in patterns Checked for every tool; a match is denied. Setting it replaces the built-in patterns (rm -rf /, mkfs, dd if=, reboot, shutdown, systemctl stop/disable, curl … | sh, Remove-Item -Recurse, diskpart, …) — copy them from default_blacklist in src/config/types.rs if you want to extend them
require_elicitation_on_destructive bool true Confirmation before destructive tools, over MCP and on the CLI — see Destructive tools
sanitize map see below Secret masking in tool output
sanitize_patterns list of regexes [] Legacy masking patterns, replaced with [REDACTED]. Count toward the sanitize.custom_patterns limits

security.sanitize:

Key Values Default What it does
enabled bool true Masks secrets in every tool output
custom_patterns list of {pattern, replacement, description} [] Extra regexes; replacement defaults to [REDACTED] and accepts $1 groups. At most 50 patterns (with sanitize_patterns), 512 bytes each, and the set must compile together, or the config does not load
disable_builtin list of categories [] Turns off built-in families: ansible, aws, azure, certificates, database, discord, docker, gcp, generic, github, gitlab, hashicorp, jwt, k3s, kubeconfig, openai, slack
entropy_detection bool true Also masks high-entropy tokens no pattern matched
entropy_threshold number, 0–8 4.5 Bits per character. Tokens shorter than 2^threshold (23 by default) are never flagged
entropy_min_length integer 16 Minimum token length checked (the effective floor is the larger of this and 2^threshold)
entropy_whitelist list of strings [] Strings never flagged
entropy_hex_threshold number, 0–8 unset Separate threshold for hex-only tokens; 40-character git SHAs are skipped

limits

Key Default What it does
command_timeout_seconds 1800 Timeout of one command (tools accept timeout_seconds to override it)
connection_timeout_seconds 10 Timeout to open an SSH connection
keepalive_interval_seconds 30 SSH keepalive interval, also the inactivity timeout
max_concurrent_commands 5 Commands running at once, across the whole server
rate_limit_per_second 10 Requests per second per host; 0 turns it off
retry_attempts 3 Attempts for a failed connection or command
retry_initial_delay_ms 100 First delay between retries
max_output_chars 40000 Characters returned per call before truncation; 0 = no limit
client_overrides built-in list Per-client max_output_chars, matched on the MCP client name: entries {name_contains, match_mode: prefix|exact|contains, max_output_chars}, first match wins. Built in: claude/cline 80000, cursor/windsurf/copilot/zed 50000, continue 30000. Setting it replaces the built-in list
max_output_bytes 10485760 Bytes collected from one command (10 MiB)
output_cache_ttl_seconds 300 How long truncated output stays fetchable with ssh_output_fetch
output_cache_max_entries 100 Truncated outputs kept
sftp_write_threshold_bytes 65536 Above this, ssh_file_write uses SFTP; 0 = always
max_tasks 50 Concurrent async MCP tasks
max_task_ttl_ms 3600000 Longest task TTL a client may request
task_poll_interval_ms 2000 Poll interval suggested to clients for tasks
client_request_timeout_seconds, sampling_max_tokens 120, 4096 Accepted but not used in 4.0.0

audit

Key Default What it does
enabled true Writes one JSON line per tool call (arguments, sanitized output, exit code, duration, confirmations)
path ~/.local/share/bridge-mcp/audit.log (macOS: ~/Library/Application Support/bridge-mcp/audit.log) Log location; ~ is expanded
max_size_mb 100 Rotates the log at this size; 0 turns rotation off
retain_days 30 At each rotation, deletes this log’s rotated archives older than N days; 0 keeps them all

sessions

Persistent shells opened with ssh_session_create:

Key Default What it does
max_sessions 10 Open shells at most
idle_timeout_seconds 300 Closes a shell idle this long
max_age_seconds 3600 Closes a shell this old, even in use

tool_groups

Key Values Default What it does
groups map of group name to true/false {} Turns groups on or off. Unlisted groups are on only if they are among the 8 defaults (core, file_ops, directory, process, monitoring, network, systemd, sessions). Unknown names are silently ignored
listing full, progressive full What tools/list returns — see What the client sees

ssh_config

Key Default What it does
enabled false Imports hosts from your OpenSSH client config. Off by default: it would hand your whole SSH inventory to the model
path ~/.ssh/config File to read
exclude [] Aliases not to import

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)

Key Default What it does
bind 127.0.0.1:3000 Listen address (serve-http --bind overrides it). A non-loopback address is refused unless oauth.enabled or allow_unsafe_bind
max_body_size 1048576 Largest request body, bytes
allowed_origins http(s)://localhost, http(s)://127.0.0.1, http(s)://[::1] Accepted Origin values (DNS-rebinding protection)
allow_unsafe_bind false Allows a non-loopback bind without OAuth (same as --insecure-bind)
oauth disabled Bearer-token auth. camelCase keys: enabled, issuer, audience, clientId, requiredScopes, staticKeys (list of {kid, publicKeyPem}, required when enabled), jwksUri (not implemented: refused without staticKeys)

http.session_timeout_seconds and http.max_sessions were retired in 3.0.0 and are rejected.

awx (for the awx tool group)

Key Default What it does
ssh_host required Host alias the AWX API calls run from (as curl over SSH)
url required AWX base URL, e.g. https://awx.internal
token required AWX OAuth2 token; masked in output
api_timeout 30 Seconds per API request (tools accept timeout_seconds, 1–3600)
verify_ssl true false for self-signed certificates

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

Parameter Type Declared by Default What it does
host string 415 of 476 tools required where declared Host alias from the config
timeout_seconds integer 432 limits.command_timeout_seconds Per-call timeout
max_output integer (characters) 404 limits.max_output_chars or the client override Truncation limit for this call; 0 = none
save_output path 372 — Writes the full, untruncated output to this file on the machine running bridge-mcp (mode 0600, overwritten, .. refused)
sudo bool 327 false Runs the command under sudo -n (or with the host’s sudo_password on stdin)
sudo_user string 327 root User to sudo to ([A-Za-z0-9._-])
jq_filter jq expression 141 — Filters JSON output on the server, before truncation
yq_filter jq expression 81 — Same, on YAML output
columns array of header names 106 — Keeps only these columns of tabular output (case-insensitive)
limit integer ≥ 1 173 — Caps rows, array items, YAML documents or filter results
output_format json, tsv 142 json tsv prints each array the filter returns as a tab-separated row — 60-80% fewer tokens on lists

Which reduction parameters a tool takes depends on its output kind, shown by describe-tool with its reduction strategy:

Output kind Tools jq_filter yq_filter columns limit output_format
RawText 308 — — — — —
Tabular 26 — — ✓ ✓ —
Json 61 ✓ — — ✓ ✓
Yaml 1 — ✓ — ✓ ✓
Auto 80 ✓ ✓ ✓ ✓ ✓

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= offset= (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

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

Subcommand What it does
serve MCP server over stdio. Also what bridge-mcp with no subcommand runs
serve-http MCP server over Streamable HTTP (POST /mcp). -b/--bind, --insecure-bind
tool (alias t) Runs one enabled tool: [key=value …] or --json-args ''
describe-tool (alias dt) One tool’s schema, output kind, reduction strategy and annotations
list-tools Enabled tools: --groups-only, -g/--group , -s/--search
validate Checks the config without contacting a host; prints host and tool counts and security warnings
config-diff What your config changes from the defaults
status Configured hosts and security settings (no connection)
exec "" — one shell command. -t/--timeout (default 120 s), -w/--working-dir. Goes through the whitelist/blacklist but not the destructive prompt
upload, download SFTP: / ; --mode overwrite|append|resume|fail-if-exists, --chunk-size, --verify-checksum, -p/--progress
history In-memory history of the current process — empty for a fresh CLI call; read the audit log instead
daemon start|stop|status See Daemon Mode
completions bash, zsh, fish, elvish or powershell

Every subcommand loads the config first, so even list-tools needs a valid one.

Global flags

Flag Effect
-c, --config Config file to use
--yes Confirms destructive tools in advance (scripts, CI). Recorded in the audit log
--dry-run tool and exec only: print what would run, without checking or running it
--json JSON output; with tool, the whole MCP result (content, isError, …)
--jq Same as jq_filter=
--columns a,b,c Same as columns='["a","b","c"]'
--limit Same as limit=
--output-format json|tsv Same as output_format=…

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

Code Meaning
0 Success
1 Tool or bridge error — including an undeclared argument or a value of the wrong type
2 Usage error — unknown tool (or a tool whose group is off), bad flag
3 Unknown host or SSH connection error
4 Security denial — whitelist/blacklist, or a destructive tool not confirmed (no terminal and no --yes)
5 Configuration error — config fails to load; also a malformed key=value or --json-args
6 The remote command ran and exited non-zero

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, and ssh_output_fetch works;
  • 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.

Opens the connection server/discover (there is no initialize, no notifications/initialized)
Revision + client identity + client capabilities per-request _meta, keys io.modelcontextprotocol/protocolVersion, …/clientInfo, …/clientCapabilities
Notifications opt-in via subscriptions/listen; every notification carries _meta["io.modelcontextprotocol/subscriptionId"]
Async execution the io.modelcontextprotocol/tasks extension, declared under capabilities.extensions
Server needs something from the client Multi Round-Trip Requests — the server RETURNS resultType: "input_required" and the client retries under a NEW id. It never sends elicitation/create, sampling/createMessage or roots/list as its own request; that pattern is deleted in this revision
Older client sends initialize -32022, with data.supported = ["2026-07-28"]

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.

Trigger Asks for Costs
A destructive_hint: true tool, with the confirmation gate on elicitation/create one round trip before the tool runs
A path-scoped tool (ssh_ls, ssh_find, ssh_upload, ssh_download, ssh_sync, ssh_file_write, ssh_files_write) from a client that declared roots roots/list one round trip per call — a Modern server may not reuse a previous request’s answer
summarize=true on a diagnostic tool, from a client that declared sampling sampling/createMessage one round trip, and the remote command runs ONCE — the finished result is sealed into the requestState rather than the call being replayed

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.


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 with python3 scripts/extract_tool_metadata.py and check the diff; python3 scripts/validate_baseline.py is 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_groups list in tests/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.


License

MIT

View the full README on GitHub

Install

docker run -i --rm \
New to MCP? Start with the mcpskill.org guide →

Configuration

{ "mcpServers": { "ssh-bridge": { "command": "/usr/local/bin/bridge-mcp", "args": ["serve"] } } }