@yawlabs/ssh-mcp 0.9.0 → 0.9.1
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 +95 -37
- package/dist/server.d.ts +1 -0
- package/dist/server.js +82 -33
- 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";
|
|
@@ -259,9 +260,58 @@ function diagnose(host, port = 22) {
|
|
|
259
260
|
return { overall, checks, suggestions };
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
// src/ssh-config.ts
|
|
264
|
+
function parseSshConfigOutput(stdout) {
|
|
265
|
+
const all = {};
|
|
266
|
+
const identityFiles = [];
|
|
267
|
+
for (const line of stdout.split("\n")) {
|
|
268
|
+
const spaceIdx = line.indexOf(" ");
|
|
269
|
+
if (spaceIdx > 0) {
|
|
270
|
+
const key = line.substring(0, spaceIdx);
|
|
271
|
+
const value = line.substring(spaceIdx + 1);
|
|
272
|
+
if (key === "identityfile") {
|
|
273
|
+
identityFiles.push(value);
|
|
274
|
+
} else {
|
|
275
|
+
all[key] = value;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { all, identityFiles };
|
|
280
|
+
}
|
|
281
|
+
|
|
262
282
|
// src/env.ts
|
|
283
|
+
function runArgsWithEnv(cmd, args, extraEnv) {
|
|
284
|
+
const env = {};
|
|
285
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
286
|
+
if (typeof v === "string") env[k] = v;
|
|
287
|
+
}
|
|
288
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
289
|
+
if (v === void 0) {
|
|
290
|
+
delete env[k];
|
|
291
|
+
} else {
|
|
292
|
+
env[k] = v;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const stdout = execFileSync2(cmd, args, {
|
|
297
|
+
env,
|
|
298
|
+
encoding: "utf8",
|
|
299
|
+
timeout: 1e4,
|
|
300
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
301
|
+
});
|
|
302
|
+
return { stdout: stdout.trim(), ok: true };
|
|
303
|
+
} catch (e) {
|
|
304
|
+
const err = e;
|
|
305
|
+
const so = err.stdout?.toString().trim() || "";
|
|
306
|
+
const se = err.stderr?.toString().trim() || "";
|
|
307
|
+
const output = [so, se].filter(Boolean).join("\n") || err.message || "";
|
|
308
|
+
return { stdout: output, ok: false };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
263
311
|
function probeAgent(socket, agentLabel) {
|
|
264
|
-
const
|
|
312
|
+
const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
|
|
313
|
+
const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
|
|
314
|
+
const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
|
|
265
315
|
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
266
316
|
if (!ok && !noIdentities) return null;
|
|
267
317
|
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
@@ -343,6 +393,13 @@ function detectKeyType(filePath, fileName) {
|
|
|
343
393
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
344
394
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
345
395
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
396
|
+
if (content.includes("OPENSSH PRIVATE KEY")) {
|
|
397
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
|
|
398
|
+
if (ok) {
|
|
399
|
+
const match = stdout.match(/\(([^)]+)\)\s*$/);
|
|
400
|
+
if (match) return match[1].toLowerCase();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
346
403
|
} catch {
|
|
347
404
|
}
|
|
348
405
|
return "unknown";
|
|
@@ -417,20 +474,7 @@ function configLookup(host) {
|
|
|
417
474
|
if (!ok) {
|
|
418
475
|
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
419
476
|
}
|
|
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
|
-
}
|
|
477
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
434
478
|
return {
|
|
435
479
|
hostname: all.hostname || host,
|
|
436
480
|
user: all.user || "",
|
|
@@ -561,26 +605,13 @@ function resolveFromSshConfig(host) {
|
|
|
561
605
|
try {
|
|
562
606
|
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
563
607
|
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
|
-
}
|
|
608
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
578
609
|
return {
|
|
579
|
-
hostname:
|
|
580
|
-
user:
|
|
581
|
-
port:
|
|
610
|
+
hostname: all.hostname || host,
|
|
611
|
+
user: all.user || "",
|
|
612
|
+
port: all.port || "22",
|
|
582
613
|
identityFiles,
|
|
583
|
-
proxyJump:
|
|
614
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
|
|
584
615
|
};
|
|
585
616
|
} catch {
|
|
586
617
|
return null;
|
|
@@ -915,6 +946,10 @@ var ConnectionPool = class {
|
|
|
915
946
|
// Total number of successful connects ever made by this pool. Useful for
|
|
916
947
|
// introspection and for tests that want to prove connection reuse.
|
|
917
948
|
_connectCount = 0;
|
|
949
|
+
// Once drained, the pool stays drained — new acquires reject and any in-flight
|
|
950
|
+
// factory closes the freshly-connected client instead of registering it.
|
|
951
|
+
// Consumers must construct a new pool to use again.
|
|
952
|
+
drained = false;
|
|
918
953
|
constructor(options) {
|
|
919
954
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
920
955
|
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
@@ -926,6 +961,9 @@ var ConnectionPool = class {
|
|
|
926
961
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
927
962
|
let lastErr;
|
|
928
963
|
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
964
|
+
if (this.drained) {
|
|
965
|
+
throw new Error("ConnectionPool was drained");
|
|
966
|
+
}
|
|
929
967
|
const existing = this.entries.get(key);
|
|
930
968
|
if (existing && !existing.dead) {
|
|
931
969
|
existing.refCount++;
|
|
@@ -961,6 +999,13 @@ var ConnectionPool = class {
|
|
|
961
999
|
pending = (async () => {
|
|
962
1000
|
try {
|
|
963
1001
|
const client2 = await connectWithProxy(resolved);
|
|
1002
|
+
if (this.drained) {
|
|
1003
|
+
try {
|
|
1004
|
+
client2.end();
|
|
1005
|
+
} catch {
|
|
1006
|
+
}
|
|
1007
|
+
throw new Error("ConnectionPool was drained while connecting");
|
|
1008
|
+
}
|
|
964
1009
|
this._connectCount++;
|
|
965
1010
|
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
966
1011
|
const markDead = () => {
|
|
@@ -1047,6 +1092,7 @@ ${diag}`);
|
|
|
1047
1092
|
}
|
|
1048
1093
|
}
|
|
1049
1094
|
drain() {
|
|
1095
|
+
this.drained = true;
|
|
1050
1096
|
for (const entry of this.entries.values()) {
|
|
1051
1097
|
if (entry.idleTimer) {
|
|
1052
1098
|
clearTimeout(entry.idleTimer);
|
|
@@ -1057,6 +1103,7 @@ ${diag}`);
|
|
|
1057
1103
|
}
|
|
1058
1104
|
}
|
|
1059
1105
|
this.entries.clear();
|
|
1106
|
+
this.pending.clear();
|
|
1060
1107
|
}
|
|
1061
1108
|
get size() {
|
|
1062
1109
|
return this.entries.size;
|
|
@@ -1558,13 +1605,24 @@ async function main() {
|
|
|
1558
1605
|
const pool = new ConnectionPool();
|
|
1559
1606
|
const server = createServer(pool);
|
|
1560
1607
|
const transport = new StdioServerTransport();
|
|
1561
|
-
|
|
1608
|
+
let shuttingDown = false;
|
|
1609
|
+
const shutdown = async () => {
|
|
1610
|
+
if (shuttingDown) return;
|
|
1611
|
+
shuttingDown = true;
|
|
1612
|
+
try {
|
|
1613
|
+
await server.close();
|
|
1614
|
+
} catch {
|
|
1615
|
+
}
|
|
1562
1616
|
pool.drain();
|
|
1563
1617
|
killStartedAgent();
|
|
1564
|
-
process.exit(0);
|
|
1618
|
+
setTimeout(() => process.exit(0), 100);
|
|
1565
1619
|
};
|
|
1566
|
-
process.on("SIGINT",
|
|
1567
|
-
|
|
1620
|
+
process.on("SIGINT", () => {
|
|
1621
|
+
shutdown().catch(() => process.exit(1));
|
|
1622
|
+
});
|
|
1623
|
+
process.on("SIGTERM", () => {
|
|
1624
|
+
shutdown().catch(() => process.exit(1));
|
|
1625
|
+
});
|
|
1568
1626
|
await server.connect(transport);
|
|
1569
1627
|
}
|
|
1570
1628
|
main().catch((err) => {
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -259,11 +259,63 @@ function diagnose(host, port = 22) {
|
|
|
259
259
|
}
|
|
260
260
|
|
|
261
261
|
// src/env.ts
|
|
262
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
262
263
|
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
263
264
|
import { homedir as homedir2 } from "os";
|
|
264
265
|
import { join as join2 } from "path";
|
|
266
|
+
|
|
267
|
+
// src/ssh-config.ts
|
|
268
|
+
function parseSshConfigOutput(stdout) {
|
|
269
|
+
const all = {};
|
|
270
|
+
const identityFiles = [];
|
|
271
|
+
for (const line of stdout.split("\n")) {
|
|
272
|
+
const spaceIdx = line.indexOf(" ");
|
|
273
|
+
if (spaceIdx > 0) {
|
|
274
|
+
const key = line.substring(0, spaceIdx);
|
|
275
|
+
const value = line.substring(spaceIdx + 1);
|
|
276
|
+
if (key === "identityfile") {
|
|
277
|
+
identityFiles.push(value);
|
|
278
|
+
} else {
|
|
279
|
+
all[key] = value;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { all, identityFiles };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/env.ts
|
|
287
|
+
function runArgsWithEnv(cmd, args, extraEnv) {
|
|
288
|
+
const env = {};
|
|
289
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
290
|
+
if (typeof v === "string") env[k] = v;
|
|
291
|
+
}
|
|
292
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
293
|
+
if (v === void 0) {
|
|
294
|
+
delete env[k];
|
|
295
|
+
} else {
|
|
296
|
+
env[k] = v;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const stdout = execFileSync2(cmd, args, {
|
|
301
|
+
env,
|
|
302
|
+
encoding: "utf8",
|
|
303
|
+
timeout: 1e4,
|
|
304
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
305
|
+
});
|
|
306
|
+
return { stdout: stdout.trim(), ok: true };
|
|
307
|
+
} catch (e) {
|
|
308
|
+
const err = e;
|
|
309
|
+
const so = err.stdout?.toString().trim() || "";
|
|
310
|
+
const se = err.stderr?.toString().trim() || "";
|
|
311
|
+
const output = [so, se].filter(Boolean).join("\n") || err.message || "";
|
|
312
|
+
return { stdout: output, ok: false };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
265
315
|
function probeAgent(socket, agentLabel) {
|
|
266
|
-
const
|
|
316
|
+
const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
|
|
317
|
+
const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
|
|
318
|
+
const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
|
|
267
319
|
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
268
320
|
if (!ok && !noIdentities) return null;
|
|
269
321
|
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
@@ -337,6 +389,13 @@ function detectKeyType(filePath, fileName) {
|
|
|
337
389
|
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
338
390
|
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
339
391
|
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
392
|
+
if (content.includes("OPENSSH PRIVATE KEY")) {
|
|
393
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
|
|
394
|
+
if (ok) {
|
|
395
|
+
const match = stdout.match(/\(([^)]+)\)\s*$/);
|
|
396
|
+
if (match) return match[1].toLowerCase();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
340
399
|
} catch {
|
|
341
400
|
}
|
|
342
401
|
return "unknown";
|
|
@@ -411,20 +470,7 @@ function configLookup(host) {
|
|
|
411
470
|
if (!ok) {
|
|
412
471
|
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
413
472
|
}
|
|
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
|
-
}
|
|
473
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
428
474
|
return {
|
|
429
475
|
hostname: all.hostname || host,
|
|
430
476
|
user: all.user || "",
|
|
@@ -555,26 +601,13 @@ function resolveFromSshConfig(host) {
|
|
|
555
601
|
try {
|
|
556
602
|
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
557
603
|
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
|
-
}
|
|
604
|
+
const { all, identityFiles } = parseSshConfigOutput(stdout);
|
|
572
605
|
return {
|
|
573
|
-
hostname:
|
|
574
|
-
user:
|
|
575
|
-
port:
|
|
606
|
+
hostname: all.hostname || host,
|
|
607
|
+
user: all.user || "",
|
|
608
|
+
port: all.port || "22",
|
|
576
609
|
identityFiles,
|
|
577
|
-
proxyJump:
|
|
610
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
|
|
578
611
|
};
|
|
579
612
|
} catch {
|
|
580
613
|
return null;
|
|
@@ -1009,6 +1042,10 @@ var ConnectionPool = class {
|
|
|
1009
1042
|
// Total number of successful connects ever made by this pool. Useful for
|
|
1010
1043
|
// introspection and for tests that want to prove connection reuse.
|
|
1011
1044
|
_connectCount = 0;
|
|
1045
|
+
// Once drained, the pool stays drained — new acquires reject and any in-flight
|
|
1046
|
+
// factory closes the freshly-connected client instead of registering it.
|
|
1047
|
+
// Consumers must construct a new pool to use again.
|
|
1048
|
+
drained = false;
|
|
1012
1049
|
constructor(options) {
|
|
1013
1050
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
1014
1051
|
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
@@ -1020,6 +1057,9 @@ var ConnectionPool = class {
|
|
|
1020
1057
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
1021
1058
|
let lastErr;
|
|
1022
1059
|
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
1060
|
+
if (this.drained) {
|
|
1061
|
+
throw new Error("ConnectionPool was drained");
|
|
1062
|
+
}
|
|
1023
1063
|
const existing = this.entries.get(key);
|
|
1024
1064
|
if (existing && !existing.dead) {
|
|
1025
1065
|
existing.refCount++;
|
|
@@ -1055,6 +1095,13 @@ var ConnectionPool = class {
|
|
|
1055
1095
|
pending = (async () => {
|
|
1056
1096
|
try {
|
|
1057
1097
|
const client2 = await connectWithProxy(resolved);
|
|
1098
|
+
if (this.drained) {
|
|
1099
|
+
try {
|
|
1100
|
+
client2.end();
|
|
1101
|
+
} catch {
|
|
1102
|
+
}
|
|
1103
|
+
throw new Error("ConnectionPool was drained while connecting");
|
|
1104
|
+
}
|
|
1058
1105
|
this._connectCount++;
|
|
1059
1106
|
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
1060
1107
|
const markDead = () => {
|
|
@@ -1141,6 +1188,7 @@ ${diag}`);
|
|
|
1141
1188
|
}
|
|
1142
1189
|
}
|
|
1143
1190
|
drain() {
|
|
1191
|
+
this.drained = true;
|
|
1144
1192
|
for (const entry of this.entries.values()) {
|
|
1145
1193
|
if (entry.idleTimer) {
|
|
1146
1194
|
clearTimeout(entry.idleTimer);
|
|
@@ -1151,6 +1199,7 @@ ${diag}`);
|
|
|
1151
1199
|
}
|
|
1152
1200
|
}
|
|
1153
1201
|
this.entries.clear();
|
|
1202
|
+
this.pending.clear();
|
|
1154
1203
|
}
|
|
1155
1204
|
get size() {
|
|
1156
1205
|
return this.entries.size;
|
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.16.0",
|
|
51
|
-
"zod": "^4.3.6"
|
|
52
|
-
},
|
|
53
|
-
"devDependencies": {
|
|
54
|
-
"@biomejs/biome": "^2.4.13",
|
|
55
|
-
"@types/node": "^25.6.0",
|
|
56
|
-
"@types/ssh2": "^1.15.4",
|
|
57
|
-
"tsup": "^8.5.1",
|
|
58
|
-
"typescript": "^6.0.3",
|
|
59
|
-
"vitest": "^4.1.5"
|
|
60
|
-
}
|
|
61
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@yawlabs/ssh-mcp",
|
|
3
|
+
"version": "0.9.1",
|
|
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.16.0",
|
|
51
|
+
"zod": "^4.3.6"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@biomejs/biome": "^2.4.13",
|
|
55
|
+
"@types/node": "^25.6.0",
|
|
56
|
+
"@types/ssh2": "^1.15.4",
|
|
57
|
+
"tsup": "^8.5.1",
|
|
58
|
+
"typescript": "^6.0.3",
|
|
59
|
+
"vitest": "^4.1.5"
|
|
60
|
+
}
|
|
61
|
+
}
|