Stateful persistent workspace for AI agents over SSH.
Unlike traditional SSH MCP servers that execute every command in a fresh shell, SSH Agent Workspace provides a tmux-backed workspace that survives multiple commands, SSH reconnects, MCP restarts, and network interruptions. Your working directory, environment variables, shell history, and running processes remain intact.
Traditional SSH MCP
AI
└─ ssh exec channel
└─ command
└─ state lost every time
SSH Agent Workspace
AI
└─ persistent tmux workspace
├─ cwd persists
├─ env persists
├─ shell history persists
├─ running processes persist
└─ auto recovery
Most SSH MCP servers use exec channels. Every command starts from scratch:
❌ No persistent cwd — cd /var/www before every command
❌ No persistent env vars — re-export forever
❌ Interactive programs break — vim, htop, docker attach don't work
❌ State disappears after reconnect — start over from nothing
SSH Agent Workspace treats SSH as a persistent workspace instead of a command runner. Give your AI agent a real terminal that stays alive.
- Stateful workspaces — Persistent tmux-backed sessions. cwd, env, history survive everything.
- Automatic recovery — Reconnect to existing sessions after SSH drops or MCP restarts.
- Runtime reconfiguration — Enable/disable tools, update per-host security policies without restart.
- Deterministic output — Prompt sentinel-based execution. No sleep-based output guessing.
npm install -g ssh-agent-workspaceOr from source:
git clone https://github.com/ShiroNexo/ssh-agent-workspace.git
cd ssh-agent-workspace
npm install && npm run buildHosts must be defined in ~/.ssh/config:
Host prod
HostName 10.0.0.5
User deploy
IdentityFile ~/.ssh/id_ed25519
Host staging
HostName 10.0.0.10
User deploy
IdentityFile ~/.ssh/id_ed25519
Host internal
HostName 172.16.0.50
User admin
ProxyJump bastion
Host bastion
HostName jump.example.com
User jumpuser
> connect host=prod
→ { session_id: "sess_abc", tmux_session: "mcp_prod_x1y2z3" }
Your agent now has a persistent workspace on prod. Running cd /var/www once means the agent stays there for every subsequent command.
OpenCode
Add to ~/.config/opencode/opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"workspace": {
"type": "local",
"command": ["npx", "-y", "ssh-agent-workspace"]
}
}
}Or via CLI:
opencode mcp add workspace -- npx -y ssh-agent-workspaceClaude Code
claude mcp add workspace -- npx -y ssh-agent-workspaceOr add to ~/.config/claude-code/claude_code_config.json or project .mcp.json:
{
"mcpServers": {
"workspace": {
"command": "npx",
"args": ["-y", "ssh-agent-workspace"],
"autoApprove": [
"mcp__workspace__connect",
"mcp__workspace__exec",
"mcp__workspace__send_input",
"mcp__workspace__read_output",
"mcp__workspace__list_hosts",
"mcp__workspace__list_sessions",
"mcp__workspace__sftp_upload",
"mcp__workspace__sftp_download",
"mcp__workspace__sftp_list"
]
}
}
}Tip: The
autoApproveblock lets the agent use those tools without asking permission each time. Add or remove tools based on your comfort level.
Cursor
Go to Cursor Settings → MCP → New MCP Server. Use this config:
{
"mcpServers": {
"workspace": {
"command": "npx",
"args": ["-y", "ssh-agent-workspace"]
}
}
}Codex (OpenAI)
codex mcp add workspace -- npx -y ssh-agent-workspaceOr add to ~/.codex/config.toml:
[mcp_servers.workspace]
command = "npx"
args = ["-y", "ssh-agent-workspace"]Windsurf
Add to ~/.codeium/windsurf/mcp_config.json:
{
"mcpServers": {
"workspace": {
"command": "npx",
"args": ["-y", "ssh-agent-workspace"]
}
}
}Copilot / VS Code
{
"mcpServers": {
"workspace": {
"command": "npx",
"args": ["-y", "ssh-agent-workspace"]
}
}
}Gemini CLI
gemini mcp add workspace npx -y ssh-agent-workspaceCline
{
"mcpServers": {
"workspace": {
"command": "npx",
"args": ["-y", "ssh-agent-workspace"]
}
}
}Qoder
qodercli mcp add workspace -- npx -y ssh-agent-workspaceUsing npx means no global install needed. npx auto-downloads the latest version. If you installed globally (
npm install -g ssh-agent-workspace), replace"npx"/"-y"/"ssh-agent-workspace"with"ssh-agent-workspace"as the command directly.
Every session is a dedicated tmux session on the remote host. Your agent's working directory, environment variables, shell history, and running processes persist across commands, disconnections, and server restarts.
connect → tmux workspace created with PS1='__MCP_PROMPT__> '
│
exec "cd /var/www" → prompt wait → output returned → cwd is now /var/www
exec "docker ps" → runs in /var/www, no need to cd again
exec "vim app.js" → vim opens and stays running in tmux
(SSH drops, MCP restarts...)
reconnect_to_tmux → same tmux session, same cwd, vim still open
Instead of sleep 3 && capture, every exec call polls tmux capture-pane until the exact prompt string __MCP_PROMPT__> appears. No race conditions, no false positives from output that looks like a prompt.
Send command → grace interval → poll every 250ms → prompt detected → capture → return output
On server start, ssh-agent-workspace scans all configured hosts for mcp_* tmux sessions and automatically reconnects. Disable with MCP_SSH_RESTORE_SESSIONS=false.
| Tool | What it does |
|---|---|
tools_config |
Enable/disable any tool at runtime. Persistent. tools_config itself can never be disabled. |
host_security |
Set per-host read-only, command allowlist/denylist at runtime. Overrides global env vars per host. |
No restart needed. Changes apply immediately.
| Layer | Scope | Mechanism |
|---|---|---|
| Global | All hosts | MCP_SSH_READONLY, MCP_SSH_ALLOWED_HOSTS, MCP_SSH_DENYLIST_COMMANDS |
| Per-Host | Individual host | host_security tool: readonly, allow_commands, deny_commands |
| Per-Operation | Single command/query | SQL keyword blocklist, path sanitization, shell escaping |
Workspace (9 tools)
| Tool | Description |
|---|---|
connect |
Create persistent tmux workspace on remote host |
reconnect_to_tmux |
Reattach to existing workspace after disconnect |
exec |
Run command, wait for prompt, return output |
send_input |
Inject raw input (non-blocking) |
read_output |
Capture pane tail |
interrupt |
Ctrl-C / Ctrl-D signal |
disconnect |
Close session. Optionally kill tmux or keep alive |
list_hosts |
List ~/.ssh/config aliases |
list_sessions |
List active workspaces |
File Transfer (3 tools)
| Tool | Description |
|---|---|
sftp_upload |
Upload file to remote |
sftp_download |
Download file from remote |
sftp_list |
List remote directory |
Monitoring (3 tools)
| Tool | Description |
|---|---|
connection_status |
SSH liveness + tmux existence |
health_check |
CPU / RAM / Disk / Load / Uptime |
tail_log |
Log tail with optional follow |
DevOps (6 tools)
| Tool | Description |
|---|---|
deploy |
Upload → backup → chmod → chown → restart |
backup |
tar.gz archive → download → cleanup |
sync |
Rsync-lite via SFTP (bidirectional, dry-run) |
ssh_tunnel_open |
Local port forward or SOCKS5 proxy |
ssh_tunnel_list |
List active tunnels |
ssh_tunnel_close |
Close tunnel, free port |
Cluster & Queries (2 tools)
| Tool | Description |
|---|---|
group_exec |
Run command across multiple workspaces (parallel/sequential) |
db_query |
Read-only MySQL / PostgreSQL / MongoDB via SSH |
Runtime Config (2 tools)
| Tool | Description |
|---|---|
tools_config |
Enable/disable tools at runtime |
host_security |
Per-host read-only, command allow/denylist |
Full reference: docs/TOOLS.md | Security: docs/SECURITY.md
> connect host=prod
→ session_id: "sess_abc", tmux_session: "mcp_prod_x1y2z3"
> exec session_id=sess_abc command="cd /var/www && docker ps"
→ { output: "3 containers running" }
> exec session_id=sess_abc command="ls"
→ { output: "..." } (cwd is still /var/www)
> group_exec session_ids=["sess_abc","sess_def"] command="uptime"
→ [{ host: "prod", output: "up 14 days" }, { host: "staging", output: "up 3 days" }]
> health_check session_id=sess_abc
→ { cpu: 12%, memory: 45%, disk: [{ "/": 56% }], uptime: "14 days" }
> db_query session_id=sess_abc type=mysql database=mydb query="SELECT COUNT(*) FROM users"
→ [{ "COUNT(*)": 15423 }]
> deploy session_id=sess_abc files=[{"local":"dist/app.js","remote":"/var/www/app.js"}] backup=true chmod="755" restart_service="nginx"
> host_security action=set host=prod readonly=true
→ Host 'prod' locked to read-only
> tools_config disable backup
→ Tool 'backup' disabled. Removed from MCP tool list.
| Variable | Default | Description |
|---|---|---|
LOG_LEVEL |
info |
trace, debug, info, warn, error |
MCP_SSH_READONLY |
false |
Block all write operations globally |
MCP_SSH_ALLOWED_HOSTS |
(all) |
Comma-separated host whitelist |
MCP_SSH_DENYLIST_COMMANDS |
(none) |
Global command blocklist |
MCP_SSH_RESTORE_SESSIONS |
true |
Auto-restore workspaces on startup |
src/
├── core/
│ ├── SessionManager.ts # Session registry + persistence
│ ├── SSHManager.ts # SSH2 connections, SFTP, proxy-jump
│ ├── TmuxManager.ts # Tmux operations (create, capture, signal)
│ ├── StorageManager.ts # Persistent session storage (JSON)
│ ├── ToolConfigManager.ts # Runtime tool enable/disable
│ └── HostSecurityManager.ts # Per-host security policies
├── tools/ # 25 MCP tool handlers
└── utils/ # Security, SSH config parsing, logging, validation
ssh-add -l # Verify SSH agent key
ssh <alias> # Test manual login
chmod 600 ~/.ssh/id_* # Fix key permissions
# Clean stale workspaces on remote
tmux ls | grep '^mcp_' | awk -F: '{print $1}' | xargs -I{} tmux kill-session -t {}MIT