ssh2mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ssh2mcprun ./gradlew assembleRelease on the build host, then pull the apk back"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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
sshaccess to the host, configured as a Host alias in~/.ssh/config.BatchMode=yesis 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 /mcpis stateless, with a fresh server and transport per request;GETandDELETE /mcpanswer 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-tokenClient: 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.
|
|
|
| |
| read-only allowlist | any | any | refused |
| log only | yes | yes | refused |
| yes | yes | yes | refused |
| no | yes (+ reminder) | yes | refused |
| 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?}
runreturns{exit, ms, stdout, stderr, timed_out}, keeping the first 70% and the last 30% ofSSH2MCP_MAX_OUTPUTcharacters.runis bounded on both ends: the command is sent astimeout -k 5 <N> sh -c '<cmd>', so the deadline stops the remote process group as well, and the call returns at the deadline.runopts out of connection multiplexing (ControlPath=none; underControlMastera 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.bgwrites the command to/tmp/s2m-<job>.cmdover stdin, so the payload needs no shell quoting, starts it withsetsid, and returns a job id and pid. A background job is not bounded bytimeout_s.logreturns the chunk aftersince, a byte offset, withnext_sinceand the state (running,done:<code>,gone), so a client polls without re-reading the log.
s_xfer
{op: "pull" | "push" | "status", local, remote, verify?}
pullcopies from the host to the device;pushcopies from the device to the host.Both resume from the first missing byte (
tail -c +Non the sending side), write to<dest>.part, and rename only aftersha256matches on both ends. A mismatch renames the partial to.corruptand fails; a truncated file is never reported as success.Bytes never enter the model context.
pushalways verifies;verify: falseis 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?}pathwithlocal_rootandremote_rootaddresses the same relative path in both trees; an absolutepathis 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_pathandremote_path.The tool is read-only. Writes live only in
s_xfer, so a remote tree cannot be edited behind git's back.comparereturnssame,size-differs,content-differsormissing:<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 |
| 6222 | listen port |
| 127.0.0.1 | bind address |
|
|
|
| unset | remote path jail ( |
|
| startup permission level |
|
| bearer token |
|
| hot-switch permission file |
| 20000 | characters of stdout and stderr kept per call |
| 120000 | default |
|
| 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=6222Keep 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 hooksThe 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 # mirrorCommand 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 |
|
| server state, effective perm, last log and audit line |
| (re)start detached | |
| stop | |
| show or hot-switch | |
|
| run remotely, exit code passes through |
|
| detached job, prints the id |
| job output from a byte offset | |
| stop a job | |
| list jobs on the host ( | |
|
| host to device, default |
|
| device to host |
| both-sides compare | |
|
| show the bearer token masked, or rotate it |
| tail the audit log | |
|
| remote connectivity and toolchain |
| 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.
rocannot distinguish a read-onlygitinvocation from a state-changing one beyond the subcommand allowlist.Stateless Streamable HTTP cannot stream text token by token; progress is delivered by polling
bgwithlog.The
runtimeout is a hard cap on the host as well: a long build is killed mid-way iftimeout_sstays at its 120 s default.One remote per instance (
SSH2MCP_SSH_HOST). Several hosts need several instances on different ports.
License
MIT. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Remote MCP server exposing your Node Command fleet (files, shell, processes, browser) to AI hosts.
Termalin's hosted MCP server. Claude, ChatGPT or any MCP client runs commands, moves files over SFTP, queries databases and reads server metrics and monitoring alerts on your servers, keylessly through Termalin Connectors: no SSH key is handed over and no inbound port is opened. Per-host command policy and an audit log. Needs a termal.in Pro or Team account (new accounts start with a 14-day Pro trial).
Host any HTTP API as a standard MCP server; auto-distribute to 12+ mainstream agent platforms.
Secure and manage MCP servers through the Speakeasy AI Control Plane from your agent.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceRemote 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.1MIT
- AlicenseAqualityDmaintenanceZero-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.1711 npm2MIT
- AlicenseNot gradedqualityAmaintenanceTurns 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 npm5MIT
- AlicenseNot gradedqualityBmaintenanceEnables 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 PyPI1MIT