mcp-ssh-terminal
Enables AI agents to have persistent, fully interactive SSH sessions into remote hosts, behaving like a local terminal.
README
mcp-ssh-terminal
An MCP server that gives an AI agent persistent, fully interactive SSH sessions — a real shell into a remote host that behaves as if you were typing at a local terminal.
- Sessions persist across tool calls — connect once, run commands, inspect output, run more; state (working directory, environment, running programs) is preserved.
- A real terminal, not a command runner — arbitrary control characters (Ctrl-C, Ctrl-X, arrows, Tab, function keys) pass through faithfully, so interactive TUIs, pagers, tab-completion, and prompts all work.
- Non-Unix CLIs work too — network appliances like Mikrotik RouterOS are first-class citizens because the session is a genuine PTY.
- Your existing SSH setup just works —
~/.ssh/configaliases,IdentityFile, ssh-agent,ProxyJumpchains, and known_hosts are all handled by the real OpenSSH client, untouched.
ssh_connect { "host": "prod-web" } → s1 + login screen
ssh_send { "session": "s1", "text": "htop", "appendEnter": true }
ssh_send { "session": "s1", "keys": ["F5"] } → tree view, rendered
ssh_interrupt { "session": "s1" } → Ctrl-C, back to prompt
Table of contents
- How it works
- Requirements
- Install
- Register with an MCP client
- Docker
- Tools
- Special keys
- Usage walkthroughs
- Security notes
- Troubleshooting
- Development
How it works
Decision 1: drive the real OpenSSH client (ssh) inside a PTY
Rather than reimplementing SSH in JavaScript, each session spawns your actual ssh binary attached to a pseudo-terminal. Every connection requirement you'd otherwise have to hand-build is something OpenSSH already does correctly:
| Requirement | How it's satisfied |
|---|---|
Honor ~/.ssh/config |
ssh reads it natively — Host aliases, IdentityFile, Match, Include, everything. No config parsing of our own is in the connection path. |
| ProxyJump A → B → C | ProxyJump in your config, or the jump argument (ssh -J), handled entirely by ssh. |
| known_hosts verification | ssh's default StrictHostKeyChecking behavior is left intact. A new host's fingerprint prompt appears on the screen; you approve it with ssh_send (yes). |
| Key-based auth | ssh uses your IdentityFile, ssh-agent, encrypted keys, FIDO tokens — unchanged. Passphrase/password/2FA prompts surface on the screen and you answer them with ssh_send. |
| Arbitrary control characters | The child runs on a PTY, so raw bytes (0x00–0x1F, 0x7F, escape sequences) reach the remote program exactly. The ssh escape char is disabled (-e none) so nothing is intercepted. |
| Non-Unix shells (RouterOS) | Because it's a faithful terminal (PTY + -tt to force a remote PTY), interactive CLIs, paging, and tab-completion behave normally. |
| Uninterrupted sessions | The ssh process is long-lived and tracked by session id; you connect once and interact repeatedly. Keepalives (ServerAliveInterval=30, ServerAliveCountMax=3) are set by default so NAT/conntrack doesn't silently drop idle sessions; override via extraArgs. |
A pure-JS library (e.g. ssh2) would force us to re-implement ssh_config resolution, Match logic, ProxyJump chaining, known_hosts hashing/verification, and key handling — a large, security-sensitive surface that OpenSSH already gets right. Delegating to ssh means the server behaves exactly like your own ssh <host> command.
Decision 2: a headless terminal emulator for reading output
Raw PTY output is a byte stream full of cursor moves, colors, and redraws. Feeding that to an agent is unreadable. So output is piped through @xterm/headless, which maintains a virtual screen. Tools return the rendered screen a human would see — redraws collapsed to their final state, escape codes resolved to plain text. (A raw mode is still available on ssh_read when you need the underlying bytes.) The emulator also answers terminal queries from the remote side (cursor-position reports, device attributes), so programs that probe the terminal don't hang.
How "is the output done?" is decided
After input is sent, the server waits until output has been quiet for settleMs (default 500 ms) or a timeoutMs cap is hit, then renders. A long-running command returns partial output at the timeout with a hint to call ssh_read again — no output is lost, and short commands feel snappy.
Terminal multiplexing
Two levels:
- Multiple concurrent sessions — connect to many hosts at once; each has its own session id (up to 32 concurrent).
- tmux/screen inside a session — since each session is a real terminal, you can run
tmuxon the remote host and drive it with control keys (ssh_send { keys: ["C-b", "c"] }, etc.).
Requirements
- Node.js ≥ 22 (
fs.glob, used by the config lister, does not exist on Node 20) - The OpenSSH client (
ssh) onPATH— the same one your shell uses. - macOS/Linux. (
node-ptyships prebuilt binaries; on macOS the bundledspawn-helpermust be executable — see Troubleshooting.)
Install
From npm — nothing to do up front; register it straight with npx (next section). Or install globally:
npm install -g mcp-ssh-terminal
From source:
git clone https://github.com/zhdkirill/mcp-ssh-terminal.git
cd mcp-ssh-terminal
npm install
npm run build # compiles TypeScript to dist/
npm test # optional: runs the tests (keys, argv builder, ssh_config, live PTY sessions)
Register with an MCP client
Claude Code (CLI):
claude mcp add ssh -- npx -y mcp-ssh-terminal
# or, from a source checkout:
claude mcp add ssh -- node /path/to/mcp-ssh-terminal/dist/index.js
Project-scoped .mcp.json or Claude Desktop config:
{
"mcpServers": {
"ssh": {
"command": "npx",
"args": ["-y", "mcp-ssh-terminal"]
}
}
}
(For a source checkout, use "command": "node", "args": ["/path/to/mcp-ssh-terminal/dist/index.js"] instead.)
The server speaks MCP over stdio. It writes nothing to stdout except protocol traffic (logs go to stderr), so it is safe to run under any stdio MCP host.
Docker (optional)
Run it natively if you can. The whole design delegates auth to your OpenSSH — config, keys, agent, known_hosts — and a container sees none of that unless you mount it in. Docker is the right choice when the consuming machine shouldn't need Node, or you want a pinned, hermetic runtime; it is the wrong choice if you rely on ssh-agent, FIDO keys, or VPN-only routes that exist on the host.
docker build -t mcp-ssh-terminal .
Register (mounting your ssh config/keys read-only):
claude mcp add ssh -- docker run -i --rm --init -v "$HOME/.ssh:/home/node/.ssh:ro" mcp-ssh-terminal
Notes for container mode:
-iis mandatory (stdio transport) and--initis recommended (proper PID-1 signal handling; the server also handles SIGTERM itself).- ssh-agent: Linux: add
-v "$SSH_AUTH_SOCK:/agent.sock" -e SSH_AUTH_SOCK=/agent.sock. Docker Desktop for Mac:-v /run/host-services/ssh-auth.sock:/agent.sock -e SSH_AUTH_SOCK=/agent.sock. Without an agent, mounted key files still work (you'll type passphrases interactively viassh_send). - known_hosts: with a read-only mount, newly-accepted host keys can't be persisted — ssh warns and continues for that session. Mount
~/.sshread-write (or a dedicated known_hosts file) if you want them remembered. - File ownership: the image runs as the
nodeuser (uid 1000). If your bind-mounted keys come through owned by a different uid and unreadable, add--user root(the container is ephemeral and has your keys mounted either way). - Networking: the container has its own network namespace. Host-only routes (VPN tunnels,
localhosttargets) may need--network host(Linux only) or won't work as they do natively.
Tools
| Tool | Purpose |
|---|---|
ssh_connect |
Open a session to a host. Returns a session id and the initial screen (prompt, or an auth/host-key prompt to answer). |
ssh_send |
Send text and/or special keys (and optional Enter), wait for output to settle, return the updated screen. |
ssh_read |
Read the current screen without sending — poll long-running output. Supports mode: "raw" and blocking for new output via waitMs. |
ssh_interrupt |
Send Ctrl-C to the foreground program. |
ssh_resize |
Change terminal dimensions (affects wrapping and full-screen apps). |
ssh_list |
List active sessions with state, pid, age, destination. |
ssh_disconnect |
Close a session and terminate its ssh process. |
ssh_config_hosts |
List Host aliases from ~/.ssh/config (best-effort, follows Include) for discovery. |
ssh_connect parameters
| Parameter | Type | Description |
|---|---|---|
host |
string, required | ssh_config Host alias, hostname, or user@hostname |
user |
string | Username (prepended as user@host) |
port |
number | Port (ssh -p); usually unnecessary if set in config |
identityFile |
string | Private key path (ssh -i); ~ is expanded |
jump |
string | ProxyJump chain (ssh -J), e.g. "bastion" or "userA@a,userB@b" |
remoteCommand |
string | Run this command instead of an interactive login shell (still on a PTY) |
extraArgs |
string[] | Extra raw ssh arguments appended verbatim |
forceTty |
boolean | Force remote PTY allocation (ssh -tt). Default true |
cols, rows |
number | Terminal size (default 120×40) |
settleMs, timeoutMs |
number | Output-settle threshold / max wait (defaults 700 / 20000) |
maxLines |
number | Max screen lines returned (default 200) |
Every screen-returning tool accepts settleMs, timeoutMs, and maxLines, and appends a status footer:
[session=s1 | state=live | wait=idle | cursor=12,1 | screen=40x120 | shown=24/40]
state becomes exited code=N when the ssh process ends; wait tells you whether output settled (idle), was still streaming (timeout), or nothing new arrived (quiet).
Special keys for ssh_send
Pass an ordered keys array. Recognised tokens (case-insensitive):
- Control chords:
C-c(Ctrl-C),C-x,Ctrl-d,^u,C-[(Esc),C-@(NUL),C-\,C-?(DEL) — covers all of 0x00–0x1F and 0x7F. - Named keys:
Enter,Tab,Backtab,Space,Escape,Backspace,Delete,Insert,Up/Down/Left/Right,Home,End,PageUp,PageDown,F1–F12. - Alt/Meta:
M-b,Alt-f,meta-.→ ESC-prefixed. - Raw bytes:
hex:1b5b41→ arbitrary byte sequence. - Single characters:
y,Q→ sent as-is.
Any other multi-character token is an error — a typo'd chord is rejected instead of being typed into the remote shell. Literal text belongs in text, not keys. Arrow/Home/End keys automatically switch to SS3 sequences when the remote app enables application cursor-keys mode (DECCKM), like a real terminal.
text is sent literally first, then keys in order, then Enter if appendEnter: true.
Usage walkthroughs
Basic:
ssh_connect { "host": "prod-web" }→ screen shows the shell prompt.ssh_send { "session": "s1", "text": "uname -a", "appendEnter": true }→ screen shows the output.ssh_disconnect { "session": "s1" }.
Mikrotik RouterOS:
ssh_connect { "host": "admin@192.0.2.1" }→ RouterOS banner +[admin@MikroTik] >prompt.ssh_send { "session": "s1", "text": "/interface print", "appendEnter": true }.- Tab-completion:
ssh_send { "session": "s1", "text": "/ip ad", "keys": ["Tab"] }. - Abort a running command:
ssh_interrupt { "session": "s1" }(Ctrl-C), or sendQto quit a pager.
RouterOS console gotchas worth knowing:
?and Tab are live hotkeys (inline help / completion) — they act the instant they arrive, so keep them out oftextunless intended, and prefer single-line commands (multi-line pastes are mangled by auto-indent; use/importfor scripts).- Long
printoutput stops at a pager line (-- [Q quit|D dump|down]): sendDto dump the rest, or runprint without-paging. keys: ["C-x"]toggles Safe Mode — enter it before config changes so they auto-revert if the session drops.
ProxyJump A → B → C: use a config alias that sets ProxyJump, and just ssh_connect { "host": "internal-box" }. Or set the chain inline: ssh_connect { "host": "10.0.0.5", "jump": "bastionA,bastionB" }.
Answering auth / host-key prompts: if the initial screen shows Are you sure you want to continue connecting (yes/no)?, reply ssh_send { "session": "s1", "text": "yes", "appendEnter": true }. For a password prompt, send the password the same way. The known_hosts check is never disabled.
Long-running output: ssh_send returns at the settle/timeout; if the footer says output is still arriving, call ssh_read { "session": "s1", "waitMs": 2000 } to poll for more. ssh_read blocks until new output arrives (footer wait=idle/timeout) or reports wait=quiet if nothing new came within waitMs.
Security notes
This server hands an AI agent a real shell with your SSH identity. Read this section before wiring it into anything.
- The agent can do whatever your keys can do. Every host reachable from your
~/.ssh/configand agent is reachable by the model. Use your MCP client's permission prompts (don't blanket-allowssh_send), and consider a dedicated restricted key or user for agent-driven work. - Remote output is untrusted input to the model. Screen content from a remote host flows back into the agent's context; a compromised or malicious host could try prompt injection through banners, MOTDs, or command output. Treat sessions to untrusted hosts accordingly.
- known_hosts verification is left on. The server does not add
StrictHostKeyChecking=noor similar; you decide, per host, by answering the fingerprint prompt. - Argument injection is blocked for
host/user/jump: values starting with-are rejected and the destination is passed after a--separator, so a hostile hostname can't smuggle in ssh options likeProxyCommand.extraArgsremains a deliberate raw passthrough — treat it as you would a shell: options like-oProxyCommand=…execute local commands as the user running the server. If your MCP client supports per-tool argument review, scrutinizeextraArgs. ssh_config_hostsreads the file you point it at.configPathaccepts any path readable by the server's user (it only echoes config-shaped lines, but still). Point it at ssh configs only.- Credentials are never handled by this server;
ssh/ssh-agent own them. Passwords you type viassh_sendgo straight to the PTY (not logged to stdout) — but they do transit the MCP transport and may end up in client-side conversation logs. Prefer key-based auth over typing passwords. - Run it as your normal user so it inherits your
~/.sshand agent.
Troubleshooting
posix_spawnp failedfrom node-pty on macOS: the prebuiltspawn-helperlost its execute bit. Fix:chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper.npm run buildis unaffected, but a freshnpm installmay need this once.ssh: connect to host … Connection refused: faithfully reported from the realssh; the destination isn't listening. Session exits with code 255.Connection timed out during banner exchangeon a ProxyJump chain: OpenSSH'sConnectTimeout(default here: 30) also caps the destination's banner exchange, and that clock keeps running while you answer interactive prompts (host key, password) at the jump hop. Answer promptly, or raise it:extraArgs: ["-o", "ConnectTimeout=120"]. Note that-ooptions are not propagated to the jump hop itself — its host key is checked against your default known_hosts.channel 0: open failed: administratively prohibitedvia a jump host: the jump's sshd hasAllowTcpForwarding no(common on hardened/minimal distros, e.g. Alpine's default). Enable it on the jump host; ProxyJump needs TCP forwarding there.- Output looks truncated: increase
maxLinesonssh_send/ssh_read, or poll withssh_read. The emulator keeps 5000 lines of scrollback per session. - Session limit reached: at most 32 concurrent sessions; exited ones are evicted automatically, live ones must be
ssh_disconnected first.
Development
npm run build # tsc → dist/
npm test # vitest: unit tests + live PTY integration tests
src/
index.ts entry point; stdio transport + graceful shutdown
server.ts MCP server + tool definitions
manager.ts session registry + ssh argv builder
session.ts PTY + headless xterm; idle-wait + screen rendering
keys.ts key-token → byte-sequence translation
sshConfig.ts best-effort ~/.ssh/config host lister (discovery only)
test/ vitest tests (keys, argv builder, ssh_config, live PTY sessions)
Dockerfile optional container packaging (see the Docker section)
Contributions welcome — please run npm test before opening a PR.
License
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。