@yawlabs/ssh-mcp 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -219
- package/dist/index.js +131 -54
- package/dist/server.d.ts +14 -1
- package/dist/server.js +118 -50
- package/package.json +61 -61
package/README.md
CHANGED
|
@@ -1,219 +1,219 @@
|
|
|
1
|
-
# @yawlabs/ssh-mcp
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@yawlabs/ssh-mcp)
|
|
4
|
-
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
|
|
6
|
-
**Make SSH work for AI tools.** MCP server that manages your SSH environment, diagnoses what's broken, fixes it, and gives your agent remote access to anything.
|
|
7
|
-
|
|
8
|
-
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
9
|
-
|
|
10
|
-
## The problem
|
|
11
|
-
|
|
12
|
-
AI CLI tools run in subprocesses where SSH is constantly broken. The agent tries to `git pull` and gets `Permission denied (publickey)`. It tries to SSH into a server and the agent socket is stale. It tries to deploy and the host key changed because the instance was recreated. Every time, the AI has no idea what's wrong and spirals.
|
|
13
|
-
|
|
14
|
-
This happens across every situation that needs SSH keys:
|
|
15
|
-
|
|
16
|
-
- **Git** — clone, pull, push, fetch, submodules, LFS
|
|
17
|
-
- **Package managers** — `npm install`, `pip install`, `go get`, `cargo`, `composer` from private repos
|
|
18
|
-
- **Server access** — SSH, SCP, SFTP, rsync
|
|
19
|
-
- **Tunneling** — port forwarding to databases, SOCKS proxies
|
|
20
|
-
- **Deployment** — Ansible, Terraform, Capistrano, deploy scripts
|
|
21
|
-
- **Cloud** — AWS EC2, GCP, Azure, DigitalOcean, any VPS
|
|
22
|
-
|
|
23
|
-
**ssh-mcp** fixes this. It manages the SSH agent, loads keys, diagnoses failures with actionable fix commands, and provides remote operations — all as MCP tools your AI agent can call.
|
|
24
|
-
|
|
25
|
-
## Quick start
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
npm install -g @yawlabs/ssh-mcp
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Add to your MCP client config:
|
|
32
|
-
|
|
33
|
-
```json
|
|
34
|
-
{
|
|
35
|
-
"mcpServers": {
|
|
36
|
-
"ssh": {
|
|
37
|
-
"command": "ssh-mcp"
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
## Tools
|
|
44
|
-
|
|
45
|
-
### SSH environment management
|
|
46
|
-
|
|
47
|
-
Tools that fix your local SSH setup so everything else — git, deploys, tunnels — stops breaking.
|
|
48
|
-
|
|
49
|
-
| Tool | Description |
|
|
50
|
-
|------|-------------|
|
|
51
|
-
| `ssh_agent_ensure` | Ensure ssh-agent is running. Starts one if needed and sets env vars for the session. |
|
|
52
|
-
| `ssh_key_list` | List all SSH keys in ~/.ssh/ with type, fingerprint, and agent status. |
|
|
53
|
-
| `ssh_key_load` | Load a key into the running agent. Ensures the agent is started first. |
|
|
54
|
-
| `ssh_config_lookup` | Resolve the effective SSH config for a host (hostname, user, port, proxy, identity files). |
|
|
55
|
-
| `ssh_known_hosts_fix` | Remove a stale host key and re-scan. Fixes "host key verification failed" errors. |
|
|
56
|
-
| `ssh_git_check` | Test Git-over-SSH auth to GitHub, GitLab, Bitbucket, etc. |
|
|
57
|
-
| `ssh_test` | Quick connectivity test with timing and actionable error details. |
|
|
58
|
-
|
|
59
|
-
### Diagnostics
|
|
60
|
-
|
|
61
|
-
| Tool | Description |
|
|
62
|
-
|------|-------------|
|
|
63
|
-
| `ssh_diagnose` | Full SSH environment diagnostic. Checks agent, keys, config, known_hosts, and connectivity. Returns exact fix commands for every failure. |
|
|
64
|
-
|
|
65
|
-
### Remote operations
|
|
66
|
-
|
|
67
|
-
| Tool | Description |
|
|
68
|
-
|------|-------------|
|
|
69
|
-
| `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code. |
|
|
70
|
-
| `ssh_read_file` | Read a file from a remote host via SFTP. |
|
|
71
|
-
| `ssh_write_file` | Write content to a file on a remote host via SFTP. |
|
|
72
|
-
| `ssh_upload` | Upload a local file to a remote host via SFTP. |
|
|
73
|
-
| `ssh_download` | Download a file from a remote host to local filesystem. |
|
|
74
|
-
| `ssh_ls` | List files in a directory on a remote host. |
|
|
75
|
-
|
|
76
|
-
### Higher-level operations
|
|
77
|
-
|
|
78
|
-
Tools that wrap common patterns agents build with ssh_exec — faster and less error-prone.
|
|
79
|
-
|
|
80
|
-
| Tool | Description |
|
|
81
|
-
|------|-------------|
|
|
82
|
-
| `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. |
|
|
83
|
-
| `ssh_find` | Search for files remotely with structured parameters (name, type, size, depth). |
|
|
84
|
-
| `ssh_tail` | Read the last N lines of a file, optionally filtered by a grep pattern. |
|
|
85
|
-
| `ssh_service_status` | Check systemd service status (active, PID, uptime, description). |
|
|
86
|
-
|
|
87
|
-
### Auto-diagnostics
|
|
88
|
-
|
|
89
|
-
When any remote operation fails, ssh-mcp automatically runs diagnostics and includes the results in the error response. Your agent doesn't need to call `ssh_diagnose` separately — it gets told what's wrong and how to fix it right in the error message.
|
|
90
|
-
|
|
91
|
-
### Connection pooling
|
|
92
|
-
|
|
93
|
-
Remote operations reuse SSH connections automatically. When your agent makes multiple calls to the same host, the first call opens a connection and subsequent calls reuse it. Connections are kept alive for 60 seconds after the last use, then closed automatically.
|
|
94
|
-
|
|
95
|
-
### SSH config support
|
|
96
|
-
|
|
97
|
-
All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, identity files, and ProxyJump settings are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves everything.
|
|
98
|
-
|
|
99
|
-
**ProxyJump / bastion hosts** are supported automatically. If your SSH config has `ProxyJump bastion` for a host, ssh-mcp connects through the bastion transparently. Chained proxies work too.
|
|
100
|
-
|
|
101
|
-
### Host key verification
|
|
102
|
-
|
|
103
|
-
All remote operations verify the server's host key against `~/.ssh/known_hosts`:
|
|
104
|
-
|
|
105
|
-
- **Known host, key matches** — accept.
|
|
106
|
-
- **Known host, key changed** — reject (MITM protection).
|
|
107
|
-
- **Unknown host** — accept on first connection (TOFU). Use `ssh_known_hosts_fix` to pin the key for future mismatch detection.
|
|
108
|
-
|
|
109
|
-
For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first.
|
|
110
|
-
|
|
111
|
-
The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
|
|
112
|
-
|
|
113
|
-
### Windows support
|
|
114
|
-
|
|
115
|
-
On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
|
|
116
|
-
|
|
117
|
-
## Authentication
|
|
118
|
-
|
|
119
|
-
All remote operations accept connection parameters:
|
|
120
|
-
|
|
121
|
-
| Parameter | Description | Default |
|
|
122
|
-
|-----------|-------------|---------|
|
|
123
|
-
| `host` | SSH hostname or IP (required) | — |
|
|
124
|
-
| `port` | SSH port | From SSH config or `22` |
|
|
125
|
-
| `username` | SSH username | From SSH config or current user |
|
|
126
|
-
| `privateKeyPath` | Path to SSH private key | Auto-detect |
|
|
127
|
-
| `password` | SSH password (prefer keys) | — |
|
|
128
|
-
|
|
129
|
-
**Auth resolution order:** ssh-mcp picks the first match from this list and does not fall through to later entries — this makes the auth method deterministic and predictable.
|
|
130
|
-
|
|
131
|
-
1. Explicit `privateKeyPath`
|
|
132
|
-
2. Explicit `password`
|
|
133
|
-
3. ssh-agent (`SSH_AUTH_SOCK` on Unix, `\\.\pipe\openssh-ssh-agent` on Windows)
|
|
134
|
-
4. Identity files from `~/.ssh/config` for the host
|
|
135
|
-
5. Default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`)
|
|
136
|
-
|
|
137
|
-
## Example workflows
|
|
138
|
-
|
|
139
|
-
### Agent can't git pull
|
|
140
|
-
|
|
141
|
-
```
|
|
142
|
-
Agent calls ssh_git_check → "Permission denied. Your SSH key is not registered with github.com."
|
|
143
|
-
Agent calls ssh_key_list → finds id_ed25519 exists but is not loaded
|
|
144
|
-
Agent calls ssh_key_load("~/.ssh/id_ed25519") → "Key loaded"
|
|
145
|
-
Agent calls ssh_git_check → "Git SSH authentication to github.com succeeded as username"
|
|
146
|
-
Agent runs git pull → works
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
### Host key changed after instance recreation
|
|
150
|
-
|
|
151
|
-
```
|
|
152
|
-
Agent calls ssh_exec on server → error: "Host key verification failed"
|
|
153
|
-
(auto-diagnostics included in error: "Fix with ssh_known_hosts_fix")
|
|
154
|
-
Agent calls ssh_known_hosts_fix("my-server") → "Host key refreshed"
|
|
155
|
-
Agent calls ssh_exec → works
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
### First-time connection to a new server
|
|
159
|
-
|
|
160
|
-
```
|
|
161
|
-
Agent calls ssh_test("new-server") → "Connection refused at new-server:22"
|
|
162
|
-
Agent calls ssh_diagnose("new-server") → full report showing agent running, keys loaded, but host unreachable
|
|
163
|
-
Agent reports: "SSH server isn't running on new-server or port 22 is blocked"
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
## Programmatic usage
|
|
167
|
-
|
|
168
|
-
```typescript
|
|
169
|
-
import { connect, exec, diagnose, ensureAgent, listSshKeys, checkGitSsh, ConnectionPool } from '@yawlabs/ssh-mcp';
|
|
170
|
-
|
|
171
|
-
// Fix SSH environment
|
|
172
|
-
const agent = ensureAgent();
|
|
173
|
-
console.log(agent.message);
|
|
174
|
-
|
|
175
|
-
// Check git access
|
|
176
|
-
const git = checkGitSsh('github.com');
|
|
177
|
-
console.log(git.message);
|
|
178
|
-
|
|
179
|
-
// List available keys
|
|
180
|
-
const keys = listSshKeys();
|
|
181
|
-
for (const key of keys) {
|
|
182
|
-
console.log(`${key.name} (${key.type}) - ${key.loadedInAgent ? 'loaded' : 'not loaded'}`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Run a remote command (one-off)
|
|
186
|
-
const client = await connect({ host: 'my-server', username: 'deploy' });
|
|
187
|
-
const result = await exec(client, 'uptime');
|
|
188
|
-
console.log(result.stdout);
|
|
189
|
-
client.end();
|
|
190
|
-
|
|
191
|
-
// Run multiple commands with connection pooling
|
|
192
|
-
const pool = new ConnectionPool();
|
|
193
|
-
await pool.withConnection({ host: 'my-server' }, async (client) => {
|
|
194
|
-
const r1 = await exec(client, 'uptime');
|
|
195
|
-
console.log(r1.stdout);
|
|
196
|
-
});
|
|
197
|
-
// Connection stays open for 60s — next call reuses it
|
|
198
|
-
await pool.withConnection({ host: 'my-server' }, async (client) => {
|
|
199
|
-
const r2 = await exec(client, 'df -h');
|
|
200
|
-
console.log(r2.stdout);
|
|
201
|
-
});
|
|
202
|
-
pool.drain(); // close all connections when done
|
|
203
|
-
|
|
204
|
-
// Diagnose issues
|
|
205
|
-
const report = diagnose('my-server');
|
|
206
|
-
console.log(report.overall); // "ok" | "warning" | "error"
|
|
207
|
-
for (const check of report.checks) {
|
|
208
|
-
console.log(`[${check.status}] ${check.name}: ${check.message}`);
|
|
209
|
-
}
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
## Requirements
|
|
213
|
-
|
|
214
|
-
- Node.js 18+
|
|
215
|
-
- SSH client installed (for diagnostics and environment management)
|
|
216
|
-
|
|
217
|
-
## License
|
|
218
|
-
|
|
219
|
-
MIT
|
|
1
|
+
# @yawlabs/ssh-mcp
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@yawlabs/ssh-mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
**Make SSH work for AI tools.** MCP server that manages your SSH environment, diagnoses what's broken, fixes it, and gives your agent remote access to anything.
|
|
7
|
+
|
|
8
|
+
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
9
|
+
|
|
10
|
+
## The problem
|
|
11
|
+
|
|
12
|
+
AI CLI tools run in subprocesses where SSH is constantly broken. The agent tries to `git pull` and gets `Permission denied (publickey)`. It tries to SSH into a server and the agent socket is stale. It tries to deploy and the host key changed because the instance was recreated. Every time, the AI has no idea what's wrong and spirals.
|
|
13
|
+
|
|
14
|
+
This happens across every situation that needs SSH keys:
|
|
15
|
+
|
|
16
|
+
- **Git** — clone, pull, push, fetch, submodules, LFS
|
|
17
|
+
- **Package managers** — `npm install`, `pip install`, `go get`, `cargo`, `composer` from private repos
|
|
18
|
+
- **Server access** — SSH, SCP, SFTP, rsync
|
|
19
|
+
- **Tunneling** — port forwarding to databases, SOCKS proxies
|
|
20
|
+
- **Deployment** — Ansible, Terraform, Capistrano, deploy scripts
|
|
21
|
+
- **Cloud** — AWS EC2, GCP, Azure, DigitalOcean, any VPS
|
|
22
|
+
|
|
23
|
+
**ssh-mcp** fixes this. It manages the SSH agent, loads keys, diagnoses failures with actionable fix commands, and provides remote operations — all as MCP tools your AI agent can call.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g @yawlabs/ssh-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Add to your MCP client config:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"mcpServers": {
|
|
36
|
+
"ssh": {
|
|
37
|
+
"command": "ssh-mcp"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Tools
|
|
44
|
+
|
|
45
|
+
### SSH environment management
|
|
46
|
+
|
|
47
|
+
Tools that fix your local SSH setup so everything else — git, deploys, tunnels — stops breaking.
|
|
48
|
+
|
|
49
|
+
| Tool | Description |
|
|
50
|
+
|------|-------------|
|
|
51
|
+
| `ssh_agent_ensure` | Ensure ssh-agent is running. Starts one if needed and sets env vars for the session. |
|
|
52
|
+
| `ssh_key_list` | List all SSH keys in ~/.ssh/ with type, fingerprint, and agent status. |
|
|
53
|
+
| `ssh_key_load` | Load a key into the running agent. Ensures the agent is started first. |
|
|
54
|
+
| `ssh_config_lookup` | Resolve the effective SSH config for a host (hostname, user, port, proxy, identity files). |
|
|
55
|
+
| `ssh_known_hosts_fix` | Remove a stale host key and re-scan. Fixes "host key verification failed" errors. |
|
|
56
|
+
| `ssh_git_check` | Test Git-over-SSH auth to GitHub, GitLab, Bitbucket, etc. |
|
|
57
|
+
| `ssh_test` | Quick connectivity test with timing and actionable error details. |
|
|
58
|
+
|
|
59
|
+
### Diagnostics
|
|
60
|
+
|
|
61
|
+
| Tool | Description |
|
|
62
|
+
|------|-------------|
|
|
63
|
+
| `ssh_diagnose` | Full SSH environment diagnostic. Checks agent, keys, config, known_hosts, and connectivity. Returns exact fix commands for every failure. |
|
|
64
|
+
|
|
65
|
+
### Remote operations
|
|
66
|
+
|
|
67
|
+
| Tool | Description |
|
|
68
|
+
|------|-------------|
|
|
69
|
+
| `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code. |
|
|
70
|
+
| `ssh_read_file` | Read a file from a remote host via SFTP. |
|
|
71
|
+
| `ssh_write_file` | Write content to a file on a remote host via SFTP. |
|
|
72
|
+
| `ssh_upload` | Upload a local file to a remote host via SFTP. |
|
|
73
|
+
| `ssh_download` | Download a file from a remote host to local filesystem. |
|
|
74
|
+
| `ssh_ls` | List files in a directory on a remote host. |
|
|
75
|
+
|
|
76
|
+
### Higher-level operations
|
|
77
|
+
|
|
78
|
+
Tools that wrap common patterns agents build with ssh_exec — faster and less error-prone.
|
|
79
|
+
|
|
80
|
+
| Tool | Description |
|
|
81
|
+
|------|-------------|
|
|
82
|
+
| `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. |
|
|
83
|
+
| `ssh_find` | Search for files remotely with structured parameters (name, type, size, depth). |
|
|
84
|
+
| `ssh_tail` | Read the last N lines of a file, optionally filtered by a grep pattern. |
|
|
85
|
+
| `ssh_service_status` | Check systemd service status (active, PID, uptime, description). |
|
|
86
|
+
|
|
87
|
+
### Auto-diagnostics
|
|
88
|
+
|
|
89
|
+
When any remote operation fails, ssh-mcp automatically runs diagnostics and includes the results in the error response. Your agent doesn't need to call `ssh_diagnose` separately — it gets told what's wrong and how to fix it right in the error message.
|
|
90
|
+
|
|
91
|
+
### Connection pooling
|
|
92
|
+
|
|
93
|
+
Remote operations reuse SSH connections automatically. When your agent makes multiple calls to the same host, the first call opens a connection and subsequent calls reuse it. Connections are kept alive for 60 seconds after the last use, then closed automatically.
|
|
94
|
+
|
|
95
|
+
### SSH config support
|
|
96
|
+
|
|
97
|
+
All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, identity files, and ProxyJump settings are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves everything.
|
|
98
|
+
|
|
99
|
+
**ProxyJump / bastion hosts** are supported automatically. If your SSH config has `ProxyJump bastion` for a host, ssh-mcp connects through the bastion transparently. Chained proxies work too.
|
|
100
|
+
|
|
101
|
+
### Host key verification
|
|
102
|
+
|
|
103
|
+
All remote operations verify the server's host key against `~/.ssh/known_hosts`:
|
|
104
|
+
|
|
105
|
+
- **Known host, key matches** — accept.
|
|
106
|
+
- **Known host, key changed** — reject (MITM protection).
|
|
107
|
+
- **Unknown host** — accept on first connection (TOFU). Use `ssh_known_hosts_fix` to pin the key for future mismatch detection.
|
|
108
|
+
|
|
109
|
+
For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first.
|
|
110
|
+
|
|
111
|
+
The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
|
|
112
|
+
|
|
113
|
+
### Windows support
|
|
114
|
+
|
|
115
|
+
On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
|
|
116
|
+
|
|
117
|
+
## Authentication
|
|
118
|
+
|
|
119
|
+
All remote operations accept connection parameters:
|
|
120
|
+
|
|
121
|
+
| Parameter | Description | Default |
|
|
122
|
+
|-----------|-------------|---------|
|
|
123
|
+
| `host` | SSH hostname or IP (required) | — |
|
|
124
|
+
| `port` | SSH port | From SSH config or `22` |
|
|
125
|
+
| `username` | SSH username | From SSH config or current user |
|
|
126
|
+
| `privateKeyPath` | Path to SSH private key | Auto-detect |
|
|
127
|
+
| `password` | SSH password (prefer keys) | — |
|
|
128
|
+
|
|
129
|
+
**Auth resolution order:** ssh-mcp picks the first match from this list and does not fall through to later entries — this makes the auth method deterministic and predictable.
|
|
130
|
+
|
|
131
|
+
1. Explicit `privateKeyPath`
|
|
132
|
+
2. Explicit `password`
|
|
133
|
+
3. ssh-agent (`SSH_AUTH_SOCK` on Unix, `\\.\pipe\openssh-ssh-agent` on Windows)
|
|
134
|
+
4. Identity files from `~/.ssh/config` for the host
|
|
135
|
+
5. Default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`)
|
|
136
|
+
|
|
137
|
+
## Example workflows
|
|
138
|
+
|
|
139
|
+
### Agent can't git pull
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
Agent calls ssh_git_check → "Permission denied. Your SSH key is not registered with github.com."
|
|
143
|
+
Agent calls ssh_key_list → finds id_ed25519 exists but is not loaded
|
|
144
|
+
Agent calls ssh_key_load("~/.ssh/id_ed25519") → "Key loaded"
|
|
145
|
+
Agent calls ssh_git_check → "Git SSH authentication to github.com succeeded as username"
|
|
146
|
+
Agent runs git pull → works
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Host key changed after instance recreation
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
Agent calls ssh_exec on server → error: "Host key verification failed"
|
|
153
|
+
(auto-diagnostics included in error: "Fix with ssh_known_hosts_fix")
|
|
154
|
+
Agent calls ssh_known_hosts_fix("my-server") → "Host key refreshed"
|
|
155
|
+
Agent calls ssh_exec → works
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### First-time connection to a new server
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
Agent calls ssh_test("new-server") → "Connection refused at new-server:22"
|
|
162
|
+
Agent calls ssh_diagnose("new-server") → full report showing agent running, keys loaded, but host unreachable
|
|
163
|
+
Agent reports: "SSH server isn't running on new-server or port 22 is blocked"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Programmatic usage
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
import { connect, exec, diagnose, ensureAgent, listSshKeys, checkGitSsh, ConnectionPool } from '@yawlabs/ssh-mcp';
|
|
170
|
+
|
|
171
|
+
// Fix SSH environment
|
|
172
|
+
const agent = ensureAgent();
|
|
173
|
+
console.log(agent.message);
|
|
174
|
+
|
|
175
|
+
// Check git access
|
|
176
|
+
const git = checkGitSsh('github.com');
|
|
177
|
+
console.log(git.message);
|
|
178
|
+
|
|
179
|
+
// List available keys
|
|
180
|
+
const keys = listSshKeys();
|
|
181
|
+
for (const key of keys) {
|
|
182
|
+
console.log(`${key.name} (${key.type}) - ${key.loadedInAgent ? 'loaded' : 'not loaded'}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Run a remote command (one-off)
|
|
186
|
+
const client = await connect({ host: 'my-server', username: 'deploy' });
|
|
187
|
+
const result = await exec(client, 'uptime');
|
|
188
|
+
console.log(result.stdout);
|
|
189
|
+
client.end();
|
|
190
|
+
|
|
191
|
+
// Run multiple commands with connection pooling
|
|
192
|
+
const pool = new ConnectionPool();
|
|
193
|
+
await pool.withConnection({ host: 'my-server' }, async (client) => {
|
|
194
|
+
const r1 = await exec(client, 'uptime');
|
|
195
|
+
console.log(r1.stdout);
|
|
196
|
+
});
|
|
197
|
+
// Connection stays open for 60s — next call reuses it
|
|
198
|
+
await pool.withConnection({ host: 'my-server' }, async (client) => {
|
|
199
|
+
const r2 = await exec(client, 'df -h');
|
|
200
|
+
console.log(r2.stdout);
|
|
201
|
+
});
|
|
202
|
+
pool.drain(); // close all connections when done
|
|
203
|
+
|
|
204
|
+
// Diagnose issues
|
|
205
|
+
const report = diagnose('my-server');
|
|
206
|
+
console.log(report.overall); // "ok" | "warning" | "error"
|
|
207
|
+
for (const check of report.checks) {
|
|
208
|
+
console.log(`[${check.status}] ${check.name}: ${check.message}`);
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Requirements
|
|
213
|
+
|
|
214
|
+
- Node.js 18+
|
|
215
|
+
- SSH client installed (for diagnostics and environment management)
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
6
|
// src/env.ts
|
|
7
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
7
8
|
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
8
9
|
import { homedir as homedir2 } from "os";
|
|
9
10
|
import { join as join2 } from "path";
|
|
@@ -201,8 +202,9 @@ function checkSshConfig(host) {
|
|
|
201
202
|
inHostBlock = patterns.some((p) => {
|
|
202
203
|
if (p === "*") return true;
|
|
203
204
|
if (p === host) return true;
|
|
204
|
-
if (p.includes("*")) {
|
|
205
|
-
const
|
|
205
|
+
if (p.includes("*") || p.includes("?")) {
|
|
206
|
+
const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
|
|
207
|
+
const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
206
208
|
return regex.test(host);
|
|
207
209
|
}
|
|
208
210
|
return false;
|
|
@@ -259,9 +261,58 @@ function diagnose(host, port = 22) {
|
|
|
259
261
|
return { overall, checks, suggestions };
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
// src/ssh-config.ts
|
|
265
|
+
function parseSshConfigOutput(stdout) {
|
|
266
|
+
const all = {};
|
|
267
|
+
const identityFiles = [];
|
|
268
|
+
for (const line of stdout.split("\n")) {
|
|
269
|
+
const spaceIdx = line.indexOf(" ");
|
|
270
|
+
if (spaceIdx > 0) {
|
|
271
|
+
const key = line.substring(0, spaceIdx);
|
|
272
|
+
const value = line.substring(spaceIdx + 1);
|
|
273
|
+
if (key === "identityfile") {
|
|
274
|
+
identityFiles.push(value);
|
|
275
|
+
} else {
|
|
276
|
+
all[key] = value;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { all, identityFiles };
|
|
281
|
+
}
|
|
282
|
+
|
|
262
283
|
// src/env.ts
|
|
284
|
+
function runArgsWithEnv(cmd, args, extraEnv) {
|
|
285
|
+
const env = {};
|
|
286
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
287
|
+
if (typeof v === "string") env[k] = v;
|
|
288
|
+
}
|
|
289
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
290
|
+
if (v === void 0) {
|
|
291
|
+
delete env[k];
|
|
292
|
+
} else {
|
|
293
|
+
env[k] = v;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
const stdout = execFileSync2(cmd, args, {
|
|
298
|
+
env,
|
|
299
|
+
encoding: "utf8",
|
|
300
|
+
timeout: 1e4,
|
|
301
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
302
|
+
});
|
|
303
|
+
return { stdout: stdout.trim(), ok: true };
|
|
304
|
+
} catch (e) {
|
|
305
|
+
const err = e;
|
|
306
|
+
const so = err.stdout?.toString().trim() || "";
|
|
307
|
+
const se = err.stderr?.toString().trim() || "";
|
|
308
|
+
const output = [so, se].filter(Boolean).join("\n") || err.message || "";
|
|
309
|
+
return { stdout: output, ok: false };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
263
312
|
function probeAgent(socket, agentLabel) {
|
|
264
|
-
const
|
|
313
|
+
const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
|
|
314
|
+
const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
|
|
315
|
+
const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
|
|
265
316
|
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
266
317
|
if (!ok && !noIdentities) return null;
|
|
267
318
|
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
@@ -289,7 +340,7 @@ function ensureAgent() {
|
|
|
289
340
|
const result = probeAgent(sock, "ssh-agent");
|
|
290
341
|
if (result) return result;
|
|
291
342
|
}
|
|
292
|
-
if (
|
|
343
|
+
if (process.platform === "win32") {
|
|
293
344
|
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
294
345
|
if (result) return result;
|
|
295
346
|
}
|
|
@@ -343,6 +394,13 @@ function detectKeyType(filePath, fileName) {
|
|
|
343
394
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
344
395
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
345
396
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
397
|
+
if (content.includes("OPENSSH PRIVATE KEY")) {
|
|
398
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
|
|
399
|
+
if (ok) {
|
|
400
|
+
const match = stdout.match(/\(([^)]+)\)\s*$/);
|
|
401
|
+
if (match) return match[1].toLowerCase();
|
|
402
|
+
}
|
|
403
|
+
}
|
|
346
404
|
} catch {
|
|
347
405
|
}
|
|
348
406
|
return "unknown";
|
|
@@ -401,10 +459,10 @@ function loadKey(keyPath) {
|
|
|
401
459
|
if (ok) {
|
|
402
460
|
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
403
461
|
}
|
|
404
|
-
if (stdout.includes("
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
462
|
+
if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
|
|
463
|
+
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
464
|
+
}
|
|
465
|
+
if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
|
|
408
466
|
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
409
467
|
}
|
|
410
468
|
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
@@ -417,20 +475,7 @@ function configLookup(host) {
|
|
|
417
475
|
if (!ok) {
|
|
418
476
|
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
419
477
|
}
|
|
420
|
-
const all =
|
|
421
|
-
const identityFiles = [];
|
|
422
|
-
for (const line of stdout.split("\n")) {
|
|
423
|
-
const spaceIdx = line.indexOf(" ");
|
|
424
|
-
if (spaceIdx > 0) {
|
|
425
|
-
const key = line.substring(0, spaceIdx);
|
|
426
|
-
const value = line.substring(spaceIdx + 1);
|
|
427
|
-
if (key === "identityfile") {
|
|
428
|
-
identityFiles.push(value);
|
|
429
|
-
} else {
|
|
430
|
-
all[key] = value;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
}
|
|
478
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
434
479
|
return {
|
|
435
480
|
hostname: all.hostname || host,
|
|
436
481
|
user: all.user || "",
|
|
@@ -561,26 +606,13 @@ function resolveFromSshConfig(host) {
|
|
|
561
606
|
try {
|
|
562
607
|
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
563
608
|
if (!ok) return null;
|
|
564
|
-
const
|
|
565
|
-
const identityFiles = [];
|
|
566
|
-
for (const line of stdout.split("\n")) {
|
|
567
|
-
const spaceIdx = line.indexOf(" ");
|
|
568
|
-
if (spaceIdx > 0) {
|
|
569
|
-
const key = line.substring(0, spaceIdx);
|
|
570
|
-
const value = line.substring(spaceIdx + 1);
|
|
571
|
-
if (key === "identityfile") {
|
|
572
|
-
identityFiles.push(value);
|
|
573
|
-
} else {
|
|
574
|
-
config[key] = value;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
}
|
|
609
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
578
610
|
return {
|
|
579
|
-
hostname:
|
|
580
|
-
user:
|
|
581
|
-
port:
|
|
611
|
+
hostname: all.hostname || host,
|
|
612
|
+
user: all.user || "",
|
|
613
|
+
port: all.port || "22",
|
|
582
614
|
identityFiles,
|
|
583
|
-
proxyJump:
|
|
615
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
|
|
584
616
|
};
|
|
585
617
|
} catch {
|
|
586
618
|
return null;
|
|
@@ -718,10 +750,16 @@ async function connectWithProxy(resolved) {
|
|
|
718
750
|
const jumpClient = await connectWithProxy(jumpResolved);
|
|
719
751
|
const targetHost = resolved.connectConfig.host;
|
|
720
752
|
const targetPort = resolved.connectConfig.port;
|
|
753
|
+
const endJump = () => {
|
|
754
|
+
try {
|
|
755
|
+
jumpClient.end();
|
|
756
|
+
} catch {
|
|
757
|
+
}
|
|
758
|
+
};
|
|
721
759
|
const stream = await new Promise((resolve, reject) => {
|
|
722
760
|
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
723
761
|
if (err) {
|
|
724
|
-
|
|
762
|
+
endJump();
|
|
725
763
|
return reject(err);
|
|
726
764
|
}
|
|
727
765
|
resolve(stream2);
|
|
@@ -730,10 +768,10 @@ async function connectWithProxy(resolved) {
|
|
|
730
768
|
return new Promise((resolve, reject) => {
|
|
731
769
|
const client = new Client();
|
|
732
770
|
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
733
|
-
|
|
771
|
+
endJump();
|
|
734
772
|
reject(err);
|
|
735
773
|
}).on("close", () => {
|
|
736
|
-
|
|
774
|
+
endJump();
|
|
737
775
|
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
738
776
|
});
|
|
739
777
|
}
|
|
@@ -801,14 +839,19 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
|
|
|
801
839
|
stderrTruncated = true;
|
|
802
840
|
}
|
|
803
841
|
};
|
|
804
|
-
stream.on("close", (code) => {
|
|
842
|
+
stream.on("close", (code, signal) => {
|
|
805
843
|
let stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
806
844
|
let stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
807
845
|
if (stdoutTruncated) stdout += `
|
|
808
846
|
[output truncated at ${maxBytes} bytes]`;
|
|
809
847
|
if (stderrTruncated) stderr += `
|
|
810
848
|
[stderr truncated at ${maxBytes} bytes]`;
|
|
811
|
-
|
|
849
|
+
const exitCode = typeof code === "number" ? code : -1;
|
|
850
|
+
const result = { stdout, stderr, code: exitCode };
|
|
851
|
+
if (stdoutTruncated) result.stdoutTruncated = true;
|
|
852
|
+
if (stderrTruncated) result.stderrTruncated = true;
|
|
853
|
+
if (signal) result.signal = signal;
|
|
854
|
+
settle(() => resolve(result));
|
|
812
855
|
}).on("data", appendStdout).on("error", (err2) => {
|
|
813
856
|
settle(() => reject(err2));
|
|
814
857
|
});
|
|
@@ -905,6 +948,12 @@ async function listDir(client, remotePath) {
|
|
|
905
948
|
}
|
|
906
949
|
|
|
907
950
|
// src/pool.ts
|
|
951
|
+
function defaultMaxPoolSize() {
|
|
952
|
+
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
953
|
+
if (!raw) return 100;
|
|
954
|
+
const parsed = Number.parseInt(raw, 10);
|
|
955
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
956
|
+
}
|
|
908
957
|
var ConnectionPool = class {
|
|
909
958
|
entries = /* @__PURE__ */ new Map();
|
|
910
959
|
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
@@ -915,9 +964,13 @@ var ConnectionPool = class {
|
|
|
915
964
|
// Total number of successful connects ever made by this pool. Useful for
|
|
916
965
|
// introspection and for tests that want to prove connection reuse.
|
|
917
966
|
_connectCount = 0;
|
|
967
|
+
// Once drained, the pool stays drained — new acquires reject and any in-flight
|
|
968
|
+
// factory closes the freshly-connected client instead of registering it.
|
|
969
|
+
// Consumers must construct a new pool to use again.
|
|
970
|
+
drained = false;
|
|
918
971
|
constructor(options) {
|
|
919
972
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
920
|
-
this.maxPoolSize = options?.maxPoolSize ??
|
|
973
|
+
this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
|
|
921
974
|
}
|
|
922
975
|
async acquire(config) {
|
|
923
976
|
const resolved = resolveConfig(config);
|
|
@@ -926,6 +979,9 @@ var ConnectionPool = class {
|
|
|
926
979
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
927
980
|
let lastErr;
|
|
928
981
|
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
982
|
+
if (this.drained) {
|
|
983
|
+
throw new Error("ConnectionPool was drained");
|
|
984
|
+
}
|
|
929
985
|
const existing = this.entries.get(key);
|
|
930
986
|
if (existing && !existing.dead) {
|
|
931
987
|
existing.refCount++;
|
|
@@ -961,6 +1017,13 @@ var ConnectionPool = class {
|
|
|
961
1017
|
pending = (async () => {
|
|
962
1018
|
try {
|
|
963
1019
|
const client2 = await connectWithProxy(resolved);
|
|
1020
|
+
if (this.drained) {
|
|
1021
|
+
try {
|
|
1022
|
+
client2.end();
|
|
1023
|
+
} catch {
|
|
1024
|
+
}
|
|
1025
|
+
throw new Error("ConnectionPool was drained while connecting");
|
|
1026
|
+
}
|
|
964
1027
|
this._connectCount++;
|
|
965
1028
|
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
966
1029
|
const markDead = () => {
|
|
@@ -1047,6 +1110,7 @@ ${diag}`);
|
|
|
1047
1110
|
}
|
|
1048
1111
|
}
|
|
1049
1112
|
drain() {
|
|
1113
|
+
this.drained = true;
|
|
1050
1114
|
for (const entry of this.entries.values()) {
|
|
1051
1115
|
if (entry.idleTimer) {
|
|
1052
1116
|
clearTimeout(entry.idleTimer);
|
|
@@ -1057,6 +1121,7 @@ ${diag}`);
|
|
|
1057
1121
|
}
|
|
1058
1122
|
}
|
|
1059
1123
|
this.entries.clear();
|
|
1124
|
+
this.pending.clear();
|
|
1060
1125
|
}
|
|
1061
1126
|
get size() {
|
|
1062
1127
|
return this.entries.size;
|
|
@@ -1476,11 +1541,12 @@ ${result.stderr}`);
|
|
|
1476
1541
|
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1477
1542
|
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1478
1543
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1544
|
+
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
|
1479
1545
|
timeout: TimeoutSchema
|
|
1480
1546
|
},
|
|
1481
|
-
async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
|
|
1547
|
+
async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
|
|
1482
1548
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1483
|
-
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1549
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeout || 3e4);
|
|
1484
1550
|
if (files.length === 0) {
|
|
1485
1551
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
1486
1552
|
}
|
|
@@ -1495,7 +1561,7 @@ ${files.join("\n")}` }] };
|
|
|
1495
1561
|
{
|
|
1496
1562
|
...connectionParams,
|
|
1497
1563
|
path: z.string().describe("Absolute path to the file to tail"),
|
|
1498
|
-
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1564
|
+
lines: z.number().int().positive().optional().describe("Number of lines to read from the end (default: 100). Must be a positive integer."),
|
|
1499
1565
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1500
1566
|
timeout: TimeoutSchema
|
|
1501
1567
|
},
|
|
@@ -1535,7 +1601,7 @@ ${files.join("\n")}` }] };
|
|
|
1535
1601
|
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1536
1602
|
lines.push("");
|
|
1537
1603
|
lines.push(status.raw);
|
|
1538
|
-
return { content: [{ type: "text", text: lines.join("\n") }]
|
|
1604
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1539
1605
|
});
|
|
1540
1606
|
}
|
|
1541
1607
|
);
|
|
@@ -1558,13 +1624,24 @@ async function main() {
|
|
|
1558
1624
|
const pool = new ConnectionPool();
|
|
1559
1625
|
const server = createServer(pool);
|
|
1560
1626
|
const transport = new StdioServerTransport();
|
|
1561
|
-
|
|
1627
|
+
let shuttingDown = false;
|
|
1628
|
+
const shutdown = async () => {
|
|
1629
|
+
if (shuttingDown) return;
|
|
1630
|
+
shuttingDown = true;
|
|
1631
|
+
try {
|
|
1632
|
+
await server.close();
|
|
1633
|
+
} catch {
|
|
1634
|
+
}
|
|
1562
1635
|
pool.drain();
|
|
1563
1636
|
killStartedAgent();
|
|
1564
|
-
process.exit(0);
|
|
1637
|
+
setTimeout(() => process.exit(0), 100);
|
|
1565
1638
|
};
|
|
1566
|
-
process.on("SIGINT",
|
|
1567
|
-
|
|
1639
|
+
process.on("SIGINT", () => {
|
|
1640
|
+
shutdown().catch(() => process.exit(1));
|
|
1641
|
+
});
|
|
1642
|
+
process.on("SIGTERM", () => {
|
|
1643
|
+
shutdown().catch(() => process.exit(1));
|
|
1644
|
+
});
|
|
1568
1645
|
await server.connect(transport);
|
|
1569
1646
|
}
|
|
1570
1647
|
main().catch((err) => {
|
package/dist/server.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ interface ExecResult {
|
|
|
13
13
|
stdout: string;
|
|
14
14
|
stderr: string;
|
|
15
15
|
code: number;
|
|
16
|
+
/** True when stdout was truncated at the byte cap. */
|
|
17
|
+
stdoutTruncated?: boolean;
|
|
18
|
+
/** True when stderr was truncated at the byte cap. */
|
|
19
|
+
stderrTruncated?: boolean;
|
|
20
|
+
/** Signal name (e.g. "TERM") if the remote channel closed via signal instead of exit. */
|
|
21
|
+
signal?: string;
|
|
16
22
|
}
|
|
17
23
|
interface ResolvedConfig {
|
|
18
24
|
connectConfig: ConnectConfig;
|
|
@@ -34,7 +40,13 @@ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
|
|
|
34
40
|
interface PoolOptions {
|
|
35
41
|
/** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
|
|
36
42
|
idleTtlMs?: number;
|
|
37
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Maximum number of connections in the pool. Default: 100, overridable via the
|
|
45
|
+
* `SSH_MCP_MAX_POOL_SIZE` env var. When at capacity, the pool first tries to evict
|
|
46
|
+
* an idle entry; if every entry is in use, `acquire()` rejects with
|
|
47
|
+
* "Connection pool is full". Bump this for fan-out workloads against many distinct
|
|
48
|
+
* hosts (e.g. `ssh_multi_exec` across a large fleet).
|
|
49
|
+
*/
|
|
38
50
|
maxPoolSize?: number;
|
|
39
51
|
}
|
|
40
52
|
declare class ConnectionPool {
|
|
@@ -43,6 +55,7 @@ declare class ConnectionPool {
|
|
|
43
55
|
private idleTtlMs;
|
|
44
56
|
private maxPoolSize;
|
|
45
57
|
private _connectCount;
|
|
58
|
+
private drained;
|
|
46
59
|
constructor(options?: PoolOptions);
|
|
47
60
|
acquire(config: SSHConfig): Promise<Client>;
|
|
48
61
|
release(client: Client): void;
|
package/dist/server.js
CHANGED
|
@@ -200,8 +200,9 @@ function checkSshConfig(host) {
|
|
|
200
200
|
inHostBlock = patterns.some((p) => {
|
|
201
201
|
if (p === "*") return true;
|
|
202
202
|
if (p === host) return true;
|
|
203
|
-
if (p.includes("*")) {
|
|
204
|
-
const
|
|
203
|
+
if (p.includes("*") || p.includes("?")) {
|
|
204
|
+
const escaped = p.replace(/[\\^$.|+()[\]{}]/g, "\\$&");
|
|
205
|
+
const regex = new RegExp("^" + escaped.replace(/\*/g, ".*").replace(/\?/g, ".") + "$");
|
|
205
206
|
return regex.test(host);
|
|
206
207
|
}
|
|
207
208
|
return false;
|
|
@@ -259,11 +260,63 @@ function diagnose(host, port = 22) {
|
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
// src/env.ts
|
|
263
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
262
264
|
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
263
265
|
import { homedir as homedir2 } from "os";
|
|
264
266
|
import { join as join2 } from "path";
|
|
267
|
+
|
|
268
|
+
// src/ssh-config.ts
|
|
269
|
+
function parseSshConfigOutput(stdout) {
|
|
270
|
+
const all = {};
|
|
271
|
+
const identityFiles = [];
|
|
272
|
+
for (const line of stdout.split("\n")) {
|
|
273
|
+
const spaceIdx = line.indexOf(" ");
|
|
274
|
+
if (spaceIdx > 0) {
|
|
275
|
+
const key = line.substring(0, spaceIdx);
|
|
276
|
+
const value = line.substring(spaceIdx + 1);
|
|
277
|
+
if (key === "identityfile") {
|
|
278
|
+
identityFiles.push(value);
|
|
279
|
+
} else {
|
|
280
|
+
all[key] = value;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { all, identityFiles };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/env.ts
|
|
288
|
+
function runArgsWithEnv(cmd, args, extraEnv) {
|
|
289
|
+
const env = {};
|
|
290
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
291
|
+
if (typeof v === "string") env[k] = v;
|
|
292
|
+
}
|
|
293
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
294
|
+
if (v === void 0) {
|
|
295
|
+
delete env[k];
|
|
296
|
+
} else {
|
|
297
|
+
env[k] = v;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const stdout = execFileSync2(cmd, args, {
|
|
302
|
+
env,
|
|
303
|
+
encoding: "utf8",
|
|
304
|
+
timeout: 1e4,
|
|
305
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
306
|
+
});
|
|
307
|
+
return { stdout: stdout.trim(), ok: true };
|
|
308
|
+
} catch (e) {
|
|
309
|
+
const err = e;
|
|
310
|
+
const so = err.stdout?.toString().trim() || "";
|
|
311
|
+
const se = err.stderr?.toString().trim() || "";
|
|
312
|
+
const output = [so, se].filter(Boolean).join("\n") || err.message || "";
|
|
313
|
+
return { stdout: output, ok: false };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
265
316
|
function probeAgent(socket, agentLabel) {
|
|
266
|
-
const
|
|
317
|
+
const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
|
|
318
|
+
const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
|
|
319
|
+
const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
|
|
267
320
|
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
268
321
|
if (!ok && !noIdentities) return null;
|
|
269
322
|
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
@@ -283,7 +336,7 @@ function ensureAgent() {
|
|
|
283
336
|
const result = probeAgent(sock, "ssh-agent");
|
|
284
337
|
if (result) return result;
|
|
285
338
|
}
|
|
286
|
-
if (
|
|
339
|
+
if (process.platform === "win32") {
|
|
287
340
|
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
288
341
|
if (result) return result;
|
|
289
342
|
}
|
|
@@ -337,6 +390,13 @@ function detectKeyType(filePath, fileName) {
|
|
|
337
390
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
338
391
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
339
392
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
393
|
+
if (content.includes("OPENSSH PRIVATE KEY")) {
|
|
394
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
|
|
395
|
+
if (ok) {
|
|
396
|
+
const match = stdout.match(/\(([^)]+)\)\s*$/);
|
|
397
|
+
if (match) return match[1].toLowerCase();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
340
400
|
} catch {
|
|
341
401
|
}
|
|
342
402
|
return "unknown";
|
|
@@ -395,10 +455,10 @@ function loadKey(keyPath) {
|
|
|
395
455
|
if (ok) {
|
|
396
456
|
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
397
457
|
}
|
|
398
|
-
if (stdout.includes("
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
458
|
+
if (stdout.includes("UNPROTECTED PRIVATE KEY") || stdout.includes("too open") || stdout.includes("bad permissions")) {
|
|
459
|
+
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
460
|
+
}
|
|
461
|
+
if (stdout.includes("passphrase") || stdout.includes("incorrect")) {
|
|
402
462
|
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
403
463
|
}
|
|
404
464
|
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
@@ -411,20 +471,7 @@ function configLookup(host) {
|
|
|
411
471
|
if (!ok) {
|
|
412
472
|
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
413
473
|
}
|
|
414
|
-
const all =
|
|
415
|
-
const identityFiles = [];
|
|
416
|
-
for (const line of stdout.split("\n")) {
|
|
417
|
-
const spaceIdx = line.indexOf(" ");
|
|
418
|
-
if (spaceIdx > 0) {
|
|
419
|
-
const key = line.substring(0, spaceIdx);
|
|
420
|
-
const value = line.substring(spaceIdx + 1);
|
|
421
|
-
if (key === "identityfile") {
|
|
422
|
-
identityFiles.push(value);
|
|
423
|
-
} else {
|
|
424
|
-
all[key] = value;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
}
|
|
474
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
428
475
|
return {
|
|
429
476
|
hostname: all.hostname || host,
|
|
430
477
|
user: all.user || "",
|
|
@@ -555,26 +602,13 @@ function resolveFromSshConfig(host) {
|
|
|
555
602
|
try {
|
|
556
603
|
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
557
604
|
if (!ok) return null;
|
|
558
|
-
const
|
|
559
|
-
const identityFiles = [];
|
|
560
|
-
for (const line of stdout.split("\n")) {
|
|
561
|
-
const spaceIdx = line.indexOf(" ");
|
|
562
|
-
if (spaceIdx > 0) {
|
|
563
|
-
const key = line.substring(0, spaceIdx);
|
|
564
|
-
const value = line.substring(spaceIdx + 1);
|
|
565
|
-
if (key === "identityfile") {
|
|
566
|
-
identityFiles.push(value);
|
|
567
|
-
} else {
|
|
568
|
-
config[key] = value;
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
605
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
572
606
|
return {
|
|
573
|
-
hostname:
|
|
574
|
-
user:
|
|
575
|
-
port:
|
|
607
|
+
hostname: all.hostname || host,
|
|
608
|
+
user: all.user || "",
|
|
609
|
+
port: all.port || "22",
|
|
576
610
|
identityFiles,
|
|
577
|
-
proxyJump:
|
|
611
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
|
|
578
612
|
};
|
|
579
613
|
} catch {
|
|
580
614
|
return null;
|
|
@@ -712,10 +746,16 @@ async function connectWithProxy(resolved) {
|
|
|
712
746
|
const jumpClient = await connectWithProxy(jumpResolved);
|
|
713
747
|
const targetHost = resolved.connectConfig.host;
|
|
714
748
|
const targetPort = resolved.connectConfig.port;
|
|
749
|
+
const endJump = () => {
|
|
750
|
+
try {
|
|
751
|
+
jumpClient.end();
|
|
752
|
+
} catch {
|
|
753
|
+
}
|
|
754
|
+
};
|
|
715
755
|
const stream = await new Promise((resolve, reject) => {
|
|
716
756
|
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
717
757
|
if (err) {
|
|
718
|
-
|
|
758
|
+
endJump();
|
|
719
759
|
return reject(err);
|
|
720
760
|
}
|
|
721
761
|
resolve(stream2);
|
|
@@ -724,10 +764,10 @@ async function connectWithProxy(resolved) {
|
|
|
724
764
|
return new Promise((resolve, reject) => {
|
|
725
765
|
const client = new Client();
|
|
726
766
|
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
727
|
-
|
|
767
|
+
endJump();
|
|
728
768
|
reject(err);
|
|
729
769
|
}).on("close", () => {
|
|
730
|
-
|
|
770
|
+
endJump();
|
|
731
771
|
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
732
772
|
});
|
|
733
773
|
}
|
|
@@ -813,14 +853,19 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
|
|
|
813
853
|
stderrTruncated = true;
|
|
814
854
|
}
|
|
815
855
|
};
|
|
816
|
-
stream.on("close", (code) => {
|
|
856
|
+
stream.on("close", (code, signal) => {
|
|
817
857
|
let stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
818
858
|
let stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
819
859
|
if (stdoutTruncated) stdout += `
|
|
820
860
|
[output truncated at ${maxBytes} bytes]`;
|
|
821
861
|
if (stderrTruncated) stderr += `
|
|
822
862
|
[stderr truncated at ${maxBytes} bytes]`;
|
|
823
|
-
|
|
863
|
+
const exitCode = typeof code === "number" ? code : -1;
|
|
864
|
+
const result = { stdout, stderr, code: exitCode };
|
|
865
|
+
if (stdoutTruncated) result.stdoutTruncated = true;
|
|
866
|
+
if (stderrTruncated) result.stderrTruncated = true;
|
|
867
|
+
if (signal) result.signal = signal;
|
|
868
|
+
settle(() => resolve(result));
|
|
824
869
|
}).on("data", appendStdout).on("error", (err2) => {
|
|
825
870
|
settle(() => reject(err2));
|
|
826
871
|
});
|
|
@@ -999,6 +1044,12 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
999
1044
|
}
|
|
1000
1045
|
|
|
1001
1046
|
// src/pool.ts
|
|
1047
|
+
function defaultMaxPoolSize() {
|
|
1048
|
+
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
1049
|
+
if (!raw) return 100;
|
|
1050
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1051
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1052
|
+
}
|
|
1002
1053
|
var ConnectionPool = class {
|
|
1003
1054
|
entries = /* @__PURE__ */ new Map();
|
|
1004
1055
|
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
@@ -1009,9 +1060,13 @@ var ConnectionPool = class {
|
|
|
1009
1060
|
// Total number of successful connects ever made by this pool. Useful for
|
|
1010
1061
|
// introspection and for tests that want to prove connection reuse.
|
|
1011
1062
|
_connectCount = 0;
|
|
1063
|
+
// Once drained, the pool stays drained — new acquires reject and any in-flight
|
|
1064
|
+
// factory closes the freshly-connected client instead of registering it.
|
|
1065
|
+
// Consumers must construct a new pool to use again.
|
|
1066
|
+
drained = false;
|
|
1012
1067
|
constructor(options) {
|
|
1013
1068
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
1014
|
-
this.maxPoolSize = options?.maxPoolSize ??
|
|
1069
|
+
this.maxPoolSize = options?.maxPoolSize ?? defaultMaxPoolSize();
|
|
1015
1070
|
}
|
|
1016
1071
|
async acquire(config) {
|
|
1017
1072
|
const resolved = resolveConfig(config);
|
|
@@ -1020,6 +1075,9 @@ var ConnectionPool = class {
|
|
|
1020
1075
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
1021
1076
|
let lastErr;
|
|
1022
1077
|
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
1078
|
+
if (this.drained) {
|
|
1079
|
+
throw new Error("ConnectionPool was drained");
|
|
1080
|
+
}
|
|
1023
1081
|
const existing = this.entries.get(key);
|
|
1024
1082
|
if (existing && !existing.dead) {
|
|
1025
1083
|
existing.refCount++;
|
|
@@ -1055,6 +1113,13 @@ var ConnectionPool = class {
|
|
|
1055
1113
|
pending = (async () => {
|
|
1056
1114
|
try {
|
|
1057
1115
|
const client2 = await connectWithProxy(resolved);
|
|
1116
|
+
if (this.drained) {
|
|
1117
|
+
try {
|
|
1118
|
+
client2.end();
|
|
1119
|
+
} catch {
|
|
1120
|
+
}
|
|
1121
|
+
throw new Error("ConnectionPool was drained while connecting");
|
|
1122
|
+
}
|
|
1058
1123
|
this._connectCount++;
|
|
1059
1124
|
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
1060
1125
|
const markDead = () => {
|
|
@@ -1141,6 +1206,7 @@ ${diag}`);
|
|
|
1141
1206
|
}
|
|
1142
1207
|
}
|
|
1143
1208
|
drain() {
|
|
1209
|
+
this.drained = true;
|
|
1144
1210
|
for (const entry of this.entries.values()) {
|
|
1145
1211
|
if (entry.idleTimer) {
|
|
1146
1212
|
clearTimeout(entry.idleTimer);
|
|
@@ -1151,6 +1217,7 @@ ${diag}`);
|
|
|
1151
1217
|
}
|
|
1152
1218
|
}
|
|
1153
1219
|
this.entries.clear();
|
|
1220
|
+
this.pending.clear();
|
|
1154
1221
|
}
|
|
1155
1222
|
get size() {
|
|
1156
1223
|
return this.entries.size;
|
|
@@ -1479,11 +1546,12 @@ ${result.stderr}`);
|
|
|
1479
1546
|
maxdepth: z.number().optional().describe("Maximum directory depth to search"),
|
|
1480
1547
|
minsize: z.string().optional().describe("Minimum file size (e.g. '1M', '100k')"),
|
|
1481
1548
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1549
|
+
newer: z.string().optional().describe("Reference file path -- find matches files modified more recently than this file"),
|
|
1482
1550
|
timeout: TimeoutSchema
|
|
1483
1551
|
},
|
|
1484
|
-
async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
|
|
1552
|
+
async ({ path, name, type, maxdepth, minsize, maxsize, newer, timeout, ...conn }) => {
|
|
1485
1553
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1486
|
-
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1554
|
+
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize, newer }, timeout || 3e4);
|
|
1487
1555
|
if (files.length === 0) {
|
|
1488
1556
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
1489
1557
|
}
|
|
@@ -1498,7 +1566,7 @@ ${files.join("\n")}` }] };
|
|
|
1498
1566
|
{
|
|
1499
1567
|
...connectionParams,
|
|
1500
1568
|
path: z.string().describe("Absolute path to the file to tail"),
|
|
1501
|
-
lines: z.number().optional().describe("Number of lines to read from the end (default: 100)"),
|
|
1569
|
+
lines: z.number().int().positive().optional().describe("Number of lines to read from the end (default: 100). Must be a positive integer."),
|
|
1502
1570
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1503
1571
|
timeout: TimeoutSchema
|
|
1504
1572
|
},
|
|
@@ -1538,7 +1606,7 @@ ${files.join("\n")}` }] };
|
|
|
1538
1606
|
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1539
1607
|
lines.push("");
|
|
1540
1608
|
lines.push(status.raw);
|
|
1541
|
-
return { content: [{ type: "text", text: lines.join("\n") }]
|
|
1609
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1542
1610
|
});
|
|
1543
1611
|
}
|
|
1544
1612
|
);
|
package/package.json
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "MCP server for SSH operations with built-in diagnostics",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"ssh-mcp": "dist/index.js"
|
|
8
|
-
},
|
|
9
|
-
"exports": {
|
|
10
|
-
".": {
|
|
11
|
-
"import": "./dist/server.js",
|
|
12
|
-
"types": "./dist/server.d.ts"
|
|
13
|
-
}
|
|
14
|
-
},
|
|
15
|
-
"files": [
|
|
16
|
-
"dist",
|
|
17
|
-
"LICENSE",
|
|
18
|
-
"README.md"
|
|
19
|
-
],
|
|
20
|
-
"scripts": {
|
|
21
|
-
"build": "tsup",
|
|
22
|
-
"dev": "tsup --watch",
|
|
23
|
-
"lint": "biome check src/",
|
|
24
|
-
"lint:fix": "biome check --write src/",
|
|
25
|
-
"typecheck": "tsc --noEmit",
|
|
26
|
-
"test": "vitest run",
|
|
27
|
-
"test:integration": "docker compose -f test/docker/docker-compose.yml up -d --build --wait && SSH_MCP_INTEGRATION=1 vitest run src/tests/integration.test.ts; docker compose -f test/docker/docker-compose.yml down",
|
|
28
|
-
"test:ci": "npm run build && npm test",
|
|
29
|
-
"prepublishOnly": "npm run build"
|
|
30
|
-
},
|
|
31
|
-
"keywords": [
|
|
32
|
-
"mcp",
|
|
33
|
-
"ssh",
|
|
34
|
-
"remote",
|
|
35
|
-
"model-context-protocol",
|
|
36
|
-
"ai",
|
|
37
|
-
"devops"
|
|
38
|
-
],
|
|
39
|
-
"author": "Yaw Labs <contact@yaw.sh>",
|
|
40
|
-
"license": "MIT",
|
|
41
|
-
"repository": {
|
|
42
|
-
"type": "git",
|
|
43
|
-
"url": "git+https://github.com/YawLabs/ssh-mcp.git"
|
|
44
|
-
},
|
|
45
|
-
"engines": {
|
|
46
|
-
"node": ">=18"
|
|
47
|
-
},
|
|
48
|
-
"dependencies": {
|
|
49
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
|
-
"ssh2": "^1.
|
|
51
|
-
"zod": "^4.3
|
|
52
|
-
},
|
|
53
|
-
"devDependencies": {
|
|
54
|
-
"@biomejs/biome": "^2.4.
|
|
55
|
-
"@types/node": "^25.
|
|
56
|
-
"@types/ssh2": "^1.15.
|
|
57
|
-
"tsup": "^8.5.1",
|
|
58
|
-
"typescript": "^6.0.3",
|
|
59
|
-
"vitest": "^4.1.
|
|
60
|
-
}
|
|
61
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@yawlabs/ssh-mcp",
|
|
3
|
+
"version": "0.9.2",
|
|
4
|
+
"description": "MCP server for SSH operations with built-in diagnostics",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ssh-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/server.js",
|
|
12
|
+
"types": "./dist/server.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"dev": "tsup --watch",
|
|
23
|
+
"lint": "biome check src/",
|
|
24
|
+
"lint:fix": "biome check --write src/",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:integration": "docker compose -f test/docker/docker-compose.yml up -d --build --wait && SSH_MCP_INTEGRATION=1 vitest run src/tests/integration.test.ts; docker compose -f test/docker/docker-compose.yml down",
|
|
28
|
+
"test:ci": "npm run build && npm test",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"mcp",
|
|
33
|
+
"ssh",
|
|
34
|
+
"remote",
|
|
35
|
+
"model-context-protocol",
|
|
36
|
+
"ai",
|
|
37
|
+
"devops"
|
|
38
|
+
],
|
|
39
|
+
"author": "Yaw Labs <contact@yaw.sh>",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/YawLabs/ssh-mcp.git"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
50
|
+
"ssh2": "^1.17.0",
|
|
51
|
+
"zod": "^4.4.3"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@biomejs/biome": "^2.4.15",
|
|
55
|
+
"@types/node": "^25.7.0",
|
|
56
|
+
"@types/ssh2": "^1.15.5",
|
|
57
|
+
"tsup": "^8.5.1",
|
|
58
|
+
"typescript": "^6.0.3",
|
|
59
|
+
"vitest": "^4.1.6"
|
|
60
|
+
}
|
|
61
|
+
}
|