Skip to main content
Glama

ssh2mcp

An MCP server that exposes one remote host, reached over ssh, as three tools: s_shell, s_xfer and s_file. It serves the "compile on the server, install on the phone" workflow: the agent keeps its workspace on the device and reaches the build machine through this server, rather than binding a second workspace.

Requirements

  • Key-based ssh access to the host, configured as a Host alias in ~/.ssh/config. BatchMode=yes is forced, so a password prompt is a hard failure rather than a hang.

  • Node.js with the MCP SDK, Express and Zod (npm install).

Related MCP server: ssh-chat-mcp

Transport and auth

  • Listens on 127.0.0.1:6222 (SSH2MCP_BIND, MCP_PORT) and speaks Streamable HTTP only. POST /mcp is stateless, with a fresh server and transport per request; GET and DELETE /mcp answer 405.

  • Every route requires Authorization: Bearer <token>, read per request from ~/.ssh2mcp-token (SSH2MCP_TOKEN_FILE); rotating the file needs no restart. The server refuses to start without one.

  • localhostHostValidation() rejects DNS-rebinding hosts on every route.

  • Loopback is not access control: any app on the device with the INTERNET permission can reach 127.0.0.1, while the commands themselves run with the privileges of the ssh user on the remote host. Keep the token at mode 600 and treat both ends as privileged.

Generate the token yourself, so that it never passes through a model:

python3 -c "import secrets;print(secrets.token_hex(24))" > ~/.ssh2mcp-token
chmod 600 ~/.ssh2mcp-token

Client: http://127.0.0.1:6222/mcp, header Authorization: Bearer <token>.

Permission levels

--perm ro|rw|full|selfcheck (default rw, or SSH2MCP_PERM). The live value is read per request from ~/.ssh2mcp-perm, so echo ro > ~/.ssh2mcp-perm switches without a restart; an unreadable or invalid file falls back to the startup level and never widens it. Raising the level to full from the CLI requires --force, and s2m st warns whenever the live level is not rw.

ro

rw

full

selfcheck

s_shell command

read-only allowlist

any

any

refused

s_shell bg / kill

log only

yes

yes

refused

s_xfer pull

yes

yes

yes

refused

s_xfer push

no

yes (+ reminder)

yes

refused

s_file

yes

yes

yes

refused

risk rules

block

block

warn only

n/a

path jail

on

on

off

n/a

ro accepts a single command line: no >, <, ;, &&, ||, $() or backticks, and | only between allowlisted commands. find may not use -delete or -exec; java only -version; apksigner only verify; aapt2 only dump; date not -s; env is excluded because it executes its arguments. This is a guard against accidents, not a sandbox; a deliberately crafted equivalent command passes.

Tools

s_shell

{op: "run" | "bg" | "log" | "kill", command, cwd?, timeout_s?, job?, since?}

  • run returns {exit, ms, stdout, stderr, timed_out}, keeping the first 70% and the last 30% of SSH2MCP_MAX_OUTPUT characters.

  • run is bounded on both ends: the command is sent as timeout -k 5 <N> sh -c '<cmd>', so the deadline stops the remote process group as well, and the call returns at the deadline.

  • run opts out of connection multiplexing (ControlPath=none; under ControlMaster a killed client leaves the pipe to the mux master open, and the call blocks until the remote command ends). Cost: about 0.6 s per call.

  • bg writes the command to /tmp/s2m-<job>.cmd over stdin, so the payload needs no shell quoting, starts it with setsid, and returns a job id and pid. A background job is not bounded by timeout_s.

  • log returns the chunk after since, a byte offset, with next_since and the state (running, done:<code>, gone), so a client polls without re-reading the log.

s_xfer

{op: "pull" | "push" | "status", local, remote, verify?}

  • pull copies from the host to the device; push copies from the device to the host.

  • Both resume from the first missing byte (tail -c +N on the sending side), write to <dest>.part, and rename only after sha256 matches on both ends. A mismatch renames the partial to .corrupt and fails; a truncated file is never reported as success.

  • Bytes never enter the model context.

  • push always verifies; verify: false is rejected, because a silently truncated file on the build machine is worse than a failure.

s_file

{op: "compare" | "stat" | "hash" | "list" | "read", path, side?,
 local_root?, remote_root?, local_path?, remote_path?}
  • path with local_root and remote_root addresses the same relative path in both trees; an absolute path is used verbatim on both sides, and combining it with a root is rejected rather than resolved against one tree twice.

  • To compare two files at different paths, pass local_path and remote_path.

  • The tool is read-only. Writes live only in s_xfer, so a remote tree cannot be edited behind git's back.

  • compare returns same, size-differs, content-differs or missing:<side>, plus the first differing line for text files up to 64 KB. Files above 128 MB are compared by size and mtime only.

Configuration

var

default

meaning

MCP_PORT

6222

listen port

SSH2MCP_BIND

127.0.0.1

bind address

SSH2MCP_SSH_HOST

myhost

~/.ssh/config Host alias

SSH2MCP_ROOT

unset

remote path jail (s_file, s_xfer, cwd)

SSH2MCP_PERM

rw

startup permission level

SSH2MCP_TOKEN_FILE

~/.ssh2mcp-token

bearer token

SSH2MCP_PERM_FILE

~/.ssh2mcp-perm

hot-switch permission file

SSH2MCP_MAX_OUTPUT

20000

characters of stdout and stderr kept per call

SSH2MCP_TIMEOUT_MS

120000

default run timeout (maximum 3600000)

SSH2MCP_SSH_BIN

ssh

ssh binary

Local configuration

Values that describe a particular machine stay outside the repository. restart.sh sources ~/.ssh2mcp.env before starting the server:

SSH2MCP_SSH_HOST=myhost     # a Host alias from ~/.ssh/config
MCP_PORT=6222

Keep that file at mode 600. check-public.sh rejects commits that carry public IPv4 addresses, key material, GitHub tokens or personal mail addresses, and the pre-push hook runs it automatically:

git config core.hooksPath hooks

The bare repository on the build host is the source of truth; GitHub is a mirror.

git push origin main --tags   # bare repository on the build host
git push github main --tags   # mirror

Command line

node index.js [--perm <level>] [--port N]
node index.js --self-test     # offline: risk table and ro allowlist assertions
node index.js --self-check    # connectivity, remote toolchain, tmp roundtrip, token and perm state
node index.js --help

--self-test exits non-zero on failure; --self-check answers "is the build machine ready" in a single call.

s2m

~/ssh2mcp/s2m, symlinked into $PREFIX/local/bin as s2m and as five-letter aliases. Every subcommand goes through the MCP endpoint, so the permission level, the path jail and the risk table apply to the CLI exactly as they apply to the agent; there is no second code path that could bypass them.

command

alias

does

s2m st

s2mstl

server state, effective perm, last log and audit line

s2m start [--perm L]

(re)start detached

s2m stop

stop

s2m perm [L|off]

show or hot-switch ro, rw, full, selfcheck

s2m sh [--cwd D] [--timeout S] CMD

s2msh

run remotely, exit code passes through

s2m bg [--cwd D] CMD

s2mbg

detached job, prints the id

s2m log JOB [since]

job output from a byte offset

s2m kill JOB

stop a job

s2m jobs

list jobs on the host (running, done:N, gone)

s2m pull REMOTE [LOCAL]

s2mpl

host to device, default ~/incoming/

s2m push LOCAL REMOTE

s2mps

device to host

s2m cmp PATH [--local-root D] [--remote-root D]

both-sides compare

s2m token [new]

s2mtk

show the bearer token masked, or rotate it

s2m audit [N]

tail the audit log

s2m check

s2mck

remote connectivity and toolchain

s2m test [--full]

offline rules and smoke test, plus end-to-end

Audit log

Every call appends one JSON line to ~/.ssh2mcp/audit.log: timestamp, tool, op, permission level, matched rule ids, exit code and duration. Commands are stored as a sha256 prefix rather than as text, so credentials on a command line do not land in the log. A permission change is recorded as perm-change with the previous level.

Known limits

  • The risk table is a set of regular expressions over the command string. It catches the usual destructive shapes, not an obfuscated equivalent.

  • ro cannot distinguish a read-only git invocation from a state-changing one beyond the subcommand allowlist.

  • Stateless Streamable HTTP cannot stream text token by token; progress is delivered by polling bg with log.

  • The run timeout is a hard cap on the host as well: a long build is killed mid-way if timeout_s stays at its 120 s default.

  • One remote per instance (SSH2MCP_SSH_HOST). Several hosts need several instances on different ports.

License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Remote Linux operations via SSH, exposed as an MCP server, with tools for filesystem, systemd, Docker, network, and more, using per-user AD authentication and optional sudo elevation.
    1
    MIT
  • A
    license
    A
    quality
    D
    maintenance
    Zero-config SSH/SFTP MCP server that lets an LLM client open temporary SSH/SFTP sessions to remote hosts, run commands, and upload/download files without holding any pre-baked credentials.
    17
    11 npm
    2
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Turns any Linux server into an AI-agent-accessible machine over HTTPS, allowing MCP clients like ChatGPT, Claude, and Grok to securely control files, run shell commands, inspect system state, query SQLite, and work with git behind a bearer token.
    124 npm
    5
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables MCP clients such as ChatGPT and Claude to administer Linux or macOS hosts through authenticated Streamable HTTP, including running shell commands, reading and writing files within a workspace, listing directories, and retrieving system metrics.
    65 PyPI
    1
    MIT