rootcanal is an SSH MCP server written in Go. It lets an MCP client (Claude Desktop, the Claude CLI, or any MCP host) open persistent shell sessions and perform SFTP file operations on a pre-declared set of remote hosts.
MCP client ──(stdio MCP)──▶ rootcanal ──(SSH sessions)──▶ remote hosts
└──(SFTP)──────────▶ remote hosts
- Pre-declared hosts only: the LLM references hosts by name (e.g.
"prod-web"). It can only reach what you have explicitly listed in the config. - Persistent shell sessions:
ssh_session_sendkeeps the shell alive across calls, so the LLM can runsudoor interact with a REPL across multiple commands. - Strict host-key verification:
known_hosts-based;InsecureIgnoreHostKeyis not exposed. - No plaintext secrets: passwords and passphrases come from environment variables.
| Tool | Description |
|---|---|
ssh_session_open |
Open a persistent shell session; returns a session_id |
ssh_session_send |
Write to the shell's stdin, return stdout/stderr output |
ssh_session_close |
Close the session and release resources |
ssh_session_list |
List open sessions with timing metadata |
sftp_read |
Read a remote file (UTF-8 or base64 for binary) — requires sftp_enabled: true on the host |
sftp_write |
Write a remote file (base64 accepted for binary) — requires sftp_enabled: true on the host |
sftp_list |
List a remote directory — requires sftp_enabled: true on the host |
ssh_run_once |
Execute a single command via SSH exec channel (no PTY); returns stdout, stderr, exit_code |
ssh_list_hosts |
List all pre-declared hosts with non-sensitive metadata (no credentials) |
ssh_host_capabilities |
Return SSH/SFTP caps, session limits, and terminal settings for a host |
ssh_accept_host_key |
Preview and re-trust a changed SSH host key after a server rebuild. Preview returns both fingerprints; confirm (with expected_fingerprint) rewrites the entry. Requires allow_known_hosts_update: true on the host. |
Download the latest release for your platform from the Releases page.
# Linux / macOS — extract and install
tar -xzf rootcanal_v2.0.0_linux_amd64.tar.gz
sudo mv rootcanal /usr/local/bin/
# Windows — extract rootcanal.exe from the zip and add to PATHRequires Go 1.27+.
git clone https://github.com/zorak1103/rootcanal.git
cd rootcanal
go install github.com/go-task/task/v3/cmd/task@latest # build tool
task build # → ./rootcanal (or rootcanal.exe)Or with plain go:
go build -o rootcanal ./cmd/rootcanalCreate a config file (~/.config/rootcanal/config.yaml on Linux/macOS, %APPDATA%\rootcanal\config.yaml on Windows) and declare your hosts.
# ~/.config/rootcanal/config.yaml
hosts:
prod-web:
address: web1.example.com:22
user: deploy
known_hosts: ~/.ssh/known_hosts
description: "Production web server" # optional label for ssh_list_hosts
idle_timeout: 10m # override global default_idle_timeout
term: dumb # $TERM for this host (default: dumb)
clean_output: true # strip ANSI/echo (default: true)
auth:
type: key
key_path: ~/.ssh/id_ed25519
passphrase_env: ROOTCANAL_PROD_PASSPHRASE # optional
# SFTP is disabled by default. Enable it and restrict paths explicitly.
sftp_enabled: true
sftp_allowed_prefixes:
- /srv/app
- /var/log/nginx
staging:
address: staging.example.com:22
user: ops
known_hosts: ~/.ssh/known_hosts
auth:
type: agent # uses SSH_AUTH_SOCK (Linux/macOS) or OpenSSH agent (Windows)
# No sftp_enabled → all sftp_* tool calls on this host are rejected.
legacy:
address: 10.0.0.7:2222
user: admin
known_hosts: ~/.ssh/known_hosts
auth:
type: password
password_env: ROOTCANAL_LEGACY_PASSWORDAnnotated example with all options: examples/rootcanal.example.yaml.
Validate your config without connecting to anything:
rootcanal -validate-config -config ~/.config/rootcanal/config.yaml
# → OK: 3 host(s) definedTest connectivity to a single host:
rootcanal -probe prod-web -config ~/.config/rootcanal/config.yaml
# → OK: connected to web1.example.com:22 as deployAdd rootcanal to your claude_desktop_config.json:
Linux / macOS (~/.config/claude/claude_desktop_config.json):
{
"mcpServers": {
"rootcanal": {
"command": "/usr/local/bin/rootcanal",
"args": ["-config", "/home/you/.config/rootcanal/config.yaml"]
}
}
}Windows (%APPDATA%\Claude\claude_desktop_config.json):
{
"mcpServers": {
"rootcanal": {
"command": "C:\\tools\\rootcanal.exe",
"args": ["-config", "C:\\Users\\you\\AppData\\Roaming\\rootcanal\\config.yaml"]
}
}
}Restart Claude Desktop after saving. The rootcanal tools appear in the tool list.
For a smoke test, ask Claude:
"Use rootcanal to open a session on
prod-weband rununame -a."
Claude will call ssh_session_open, then ssh_session_send("uname -a\n"), then ssh_session_close.
For Claude Code users, a companion skill is bundled under .claude/skills/rootcanal-ssh/.
Install it once to give Claude deep knowledge of all rootcanal tools, error messages, and SFTP
workflows without repeating context each session. See docs/mcp-client-setup.md
for installation instructions.
rootcanal connects to the OpenSSH for Windows agent via its named pipe. Enable it once:
# Run as Administrator
Set-Service -Name ssh-agent -StartupType Automatic
Start-Service ssh-agent
# Add your key
ssh-add $env:USERPROFILE\.ssh\id_ed25519PuTTY/Pageant are not supported; use the OpenSSH for Windows agent instead.
limits:
max_sessions_total: 32 # hard cap across all hosts
max_sessions_per_host: 4 # also limits concurrent SFTP ops
default_idle_timeout: 15m # GC closes sessions unused this long
max_session_age: 4h # GC closes sessions older than this
output_buffer_bytes: 1048576 # 1 MiB ring buffer per session
dial_timeout: 10s # SSH TCP connect timeout
default_send_timeout_ms: 2000 # ssh_session_send default timeout
max_send_timeout_ms: 30000 # ssh_session_send hard cap
sftp_max_read_bytes: 2097152 # 2 MiB per sftp_read call (default; raise or lower per your needs)
sftp_max_write_bytes: 26214400 # 25 MiB per sftp_write call
# v2.0 additions
default_term: dumb # $TERM advertised to remote shell
default_clean_output: true # strip ANSI/escape codes by default
run_once_max_bytes: 1048576 # 1 MiB cap per stream for ssh_run_once
run_once_max_timeout_ms: 60000 # ssh_run_once hard timeout cap
max_run_once_concurrent: 16 # concurrent ssh_run_once callsSFTP access is disabled by default and controlled by two per-host config fields:
| Field | Type | Default | Meaning |
|---|---|---|---|
sftp_enabled |
bool | false |
Must be true for any SFTP tool call to succeed on this host |
sftp_allowed_prefixes |
list of strings | [] |
Absolute Unix paths the LLM may access. Empty list denies all paths. |
allow_known_hosts_update |
bool | false |
Permit ssh_accept_host_key to rewrite this host's known_hosts entry after a server rebuild. Requires the preview→confirm flow; see docs/security.md. |
Three validation layers are applied to every sftp_read, sftp_write, and sftp_list call:
- Host opt-in — the host must have
sftp_enabled: true, otherwise the call is rejected immediately. - Path normalisation —
path.Cleanis applied and the result must be an absolute Unix path (starts with/). Traversal sequences such as../are rejected after cleaning. - Allowlist check — the cleaned path must equal one of the configured prefixes or be a descendant of it. A prefix of
/srv/appmatches/srv/app/config.jsonbut not/srv/apple/secret.
Explicit "allow all" escape hatch: set sftp_allowed_prefixes: ["/"] to permit any absolute path — this must be written deliberately; it does not happen by default.
Hosts without sftp_enabled: true have all sftp_* calls rejected, even if SFTP credentials would otherwise permit access.
Symlinks: after the lexical allowlist check, rootcanal asks the remote SFTP server to canonicalize the path (REALPATH) and re-checks the resolved path against the same prefixes, so a symlink inside an allowed prefix that points outside it (e.g. /srv/app/link -> /etc/shadow) is rejected. A small window remains between that check and the actual file operation if the symlink is swapped concurrently — SFTP has no atomic "open only if it resolves under X" primitive to close it fully.
sftp_allowed_prefixes scopes the SFTP tools only — it does not sandbox the host. ssh_run_once and persistent shell sessions run arbitrary commands with no path restriction, so any path the remote user can reach is also reachable via cat, cp, etc., regardless of the SFTP prefix list. Do not rely on sftp_allowed_prefixes as a general filesystem boundary for a host that also has shell access enabled (which is every host, since ssh_run_once is always registered).
- Output framing uses sentinel markers.
ssh_session_sendinjects aRC_EXIT_<nonce>_<code>marker after each command and waits for it to appear in the output. For raw mode (raw: true) or REPL/TUI use, usewait_idle_msinstead; output is then returned after that many milliseconds of silence. - Long-running commands: If
timeout_mselapses before the marker arrives,still_running: trueis returned. Send empty input to keep waiting. ssh_run_oncevs persistent sessions: Usessh_run_oncefor one-shot reads (df,cat,docker inspect). Usessh_session_open+ssh_session_sendfor interactive work,sudo, or REPLs that require PTY.- No port forwarding.
- PuTTY/Pageant not supported on Windows: use OpenSSH for Windows agent.
rootcanal supports sudo on remote hosts through its PTY-based persistent sessions. The LLM sends sudo <command> via ssh_session_send, receives the password prompt in the output, and can respond with the password in a follow-up call.
Security warning: never pass a
sudopassword to the LLM as prompt context or conversation input. The password would travel to the LLM provider's infrastructure in plaintext and may appear in conversation logs.
Configure sudoers to grant the SSH user passwordless access to exactly the commands that are needed:
# /etc/sudoers.d/rootcanal (always edit with visudo -f)
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/apt-get update
Do not use NOPASSWD: ALL. Restrict to the minimum set of commands the LLM actually needs.
If a password prompt appears and no password is provided, the session blocks until default_send_timeout_ms elapses and returns the prompt text. The LLM can detect this and surface it to the user.
task build # compile binary
task test # run all tests
task cover # enforce ≥85% coverage
task lint # golangci-lint v2 (requires `task lint:install` locally first)
task run # run locally (pass args after --)Race detector (requires CGO):
CGO_ENABLED=1 go test ./... -raceThe e2e/ directory contains end-to-end tests that run the real rootcanal binary against an openssh-server Docker container. They are excluded from the CI pipeline (via the //go:build e2e tag) and are intended for local use only.
Requirements: Docker (Desktop or Engine) must be running.
task e2e # build binary, start container, run ~40 tests, teardownThe tests exercise the full stack: real SSH PTY sessions, SFTP file operations, auth strategies (key, passphrase, password), host-key strict pinning, session/SFTP limits, stderr logging, and graceful shutdown. Because the container is ephemeral and easily restored, tests may modify files inside it freely.
See docs/security.md for the full threat model and security boundary documentation.
GPL v3 — see LICENSE.