@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 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";
@@ -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 regex = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
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 { stdout, ok } = runArgs("ssh-add", ["-l"]);
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 (!sock && process.platform === "win32") {
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("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
405
- if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
406
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
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 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
- }
609
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
578
610
  return {
579
- hostname: config.hostname || host,
580
- user: config.user || "",
581
- port: config.port || "22",
611
+ hostname: all.hostname || host,
612
+ user: all.user || "",
613
+ port: all.port || "22",
582
614
  identityFiles,
583
- proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
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
- jumpClient.end();
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
- jumpClient.end();
771
+ endJump();
734
772
  reject(err);
735
773
  }).on("close", () => {
736
- jumpClient.end();
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
- settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
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 ?? 100;
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") }], isError: !status.active };
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
- const shutdown = () => {
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", shutdown);
1567
- process.on("SIGTERM", shutdown);
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
- /** Maximum number of connections in the pool. Default: 100 */
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 regex = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
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 { stdout, ok } = runArgs("ssh-add", ["-l"]);
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 (!sock && process.platform === "win32") {
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("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
399
- if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
400
- return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
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 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
- }
605
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
572
606
  return {
573
- hostname: config.hostname || host,
574
- user: config.user || "",
575
- port: config.port || "22",
607
+ hostname: all.hostname || host,
608
+ user: all.user || "",
609
+ port: all.port || "22",
576
610
  identityFiles,
577
- proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
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
- jumpClient.end();
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
- jumpClient.end();
767
+ endJump();
728
768
  reject(err);
729
769
  }).on("close", () => {
730
- jumpClient.end();
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
- settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
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 ?? 100;
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") }], isError: !status.active };
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.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.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
+ }