@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 CHANGED
@@ -1,219 +1,219 @@
1
- # @yawlabs/ssh-mcp
2
-
3
- [![npm version](https://img.shields.io/npm/v/@yawlabs/ssh-mcp)](https://www.npmjs.com/package/@yawlabs/ssh-mcp)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
+ [![npm version](https://img.shields.io/npm/v/@yawlabs/ssh-mcp)](https://www.npmjs.com/package/@yawlabs/ssh-mcp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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 { stdout, ok } = runArgs("ssh-add", ["-l"]);
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 config = {};
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: config.hostname || host,
580
- user: config.user || "",
581
- port: config.port || "22",
610
+ hostname: all.hostname || host,
611
+ user: all.user || "",
612
+ port: all.port || "22",
582
613
  identityFiles,
583
- proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
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
- const shutdown = () => {
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", shutdown);
1567
- process.on("SIGTERM", shutdown);
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
@@ -43,6 +43,7 @@ declare class ConnectionPool {
43
43
  private idleTtlMs;
44
44
  private maxPoolSize;
45
45
  private _connectCount;
46
+ private drained;
46
47
  constructor(options?: PoolOptions);
47
48
  acquire(config: SSHConfig): Promise<Client>;
48
49
  release(client: Client): void;
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 { stdout, ok } = runArgs("ssh-add", ["-l"]);
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 config = {};
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: config.hostname || host,
574
- user: config.user || "",
575
- port: config.port || "22",
606
+ hostname: all.hostname || host,
607
+ user: all.user || "",
608
+ port: all.port || "22",
576
609
  identityFiles,
577
- proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
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.0",
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
+ }