@yawlabs/ssh-mcp 0.3.0 → 0.4.0

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
@@ -77,6 +77,14 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
77
77
 
78
78
  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.
79
79
 
80
+ ### Connection pooling
81
+
82
+ 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.
83
+
84
+ ### SSH config support
85
+
86
+ All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, and identity files from your SSH config are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves the real hostname, user, port, and identity file.
87
+
80
88
  ## Authentication
81
89
 
82
90
  All remote operations accept connection parameters:
@@ -84,12 +92,12 @@ All remote operations accept connection parameters:
84
92
  | Parameter | Description | Default |
85
93
  |-----------|-------------|---------|
86
94
  | `host` | SSH hostname or IP (required) | — |
87
- | `port` | SSH port | `22` |
88
- | `username` | SSH username | Current user |
95
+ | `port` | SSH port | From SSH config or `22` |
96
+ | `username` | SSH username | From SSH config or current user |
89
97
  | `privateKeyPath` | Path to SSH private key | Auto-detect |
90
98
  | `password` | SSH password (prefer keys) | — |
91
99
 
92
- **Auth resolution order:** explicit key > explicit password > ssh-agent (`SSH_AUTH_SOCK`) > default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`).
100
+ **Auth resolution order:** explicit key > explicit password > ssh-agent (`SSH_AUTH_SOCK`) > SSH config identity files > default key paths (`~/.ssh/id_ed25519`, `id_rsa`, `id_ecdsa`).
93
101
 
94
102
  ## Example workflows
95
103
 
@@ -123,7 +131,7 @@ Agent reports: "SSH server isn't running on new-server or port 22 is blocked"
123
131
  ## Programmatic usage
124
132
 
125
133
  ```typescript
126
- import { connect, exec, diagnose, ensureAgent, listSshKeys, checkGitSsh } from '@yawlabs/ssh-mcp';
134
+ import { connect, exec, diagnose, ensureAgent, listSshKeys, checkGitSsh, ConnectionPool } from '@yawlabs/ssh-mcp';
127
135
 
128
136
  // Fix SSH environment
129
137
  const agent = ensureAgent();
@@ -139,12 +147,25 @@ for (const key of keys) {
139
147
  console.log(`${key.name} (${key.type}) - ${key.loadedInAgent ? 'loaded' : 'not loaded'}`);
140
148
  }
141
149
 
142
- // Run a remote command
150
+ // Run a remote command (one-off)
143
151
  const client = await connect({ host: 'my-server', username: 'deploy' });
144
152
  const result = await exec(client, 'uptime');
145
153
  console.log(result.stdout);
146
154
  client.end();
147
155
 
156
+ // Run multiple commands with connection pooling
157
+ const pool = new ConnectionPool();
158
+ await pool.withConnection({ host: 'my-server' }, async (client) => {
159
+ const r1 = await exec(client, 'uptime');
160
+ console.log(r1.stdout);
161
+ });
162
+ // Connection stays open for 60s — next call reuses it
163
+ await pool.withConnection({ host: 'my-server' }, async (client) => {
164
+ const r2 = await exec(client, 'df -h');
165
+ console.log(r2.stdout);
166
+ });
167
+ pool.drain(); // close all connections when done
168
+
148
169
  // Diagnose issues
149
170
  const report = diagnose('my-server');
150
171
  console.log(report.overall); // "ok" | "warning" | "error"
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@
3
3
  // src/index.ts
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
- // src/server.ts
7
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
-
9
- // src/tools.ts
10
- import { z } from "zod";
6
+ // src/ssh.ts
7
+ import { readFileSync as readFileSync2 } from "fs";
8
+ import { homedir as homedir2 } from "os";
9
+ import { join as join2 } from "path";
10
+ import { Client } from "ssh2";
11
11
 
12
12
  // src/diagnose.ts
13
13
  import { execFileSync } from "child_process";
@@ -238,10 +238,330 @@ function diagnose(host, port = 22) {
238
238
  return { overall, checks, suggestions };
239
239
  }
240
240
 
241
+ // src/ssh.ts
242
+ function resolveFromSshConfig(host) {
243
+ try {
244
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
245
+ if (!ok) return null;
246
+ const config = {};
247
+ const identityFiles = [];
248
+ for (const line of stdout.split("\n")) {
249
+ const spaceIdx = line.indexOf(" ");
250
+ if (spaceIdx > 0) {
251
+ const key = line.substring(0, spaceIdx);
252
+ const value = line.substring(spaceIdx + 1);
253
+ if (key === "identityfile") {
254
+ identityFiles.push(value);
255
+ } else {
256
+ config[key] = value;
257
+ }
258
+ }
259
+ }
260
+ return {
261
+ hostname: config.hostname || host,
262
+ user: config.user || "",
263
+ port: config.port || "22",
264
+ identityFiles
265
+ };
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+ function resolveConfig(config) {
271
+ const sshConfig = resolveFromSshConfig(config.host);
272
+ const connectConfig = {
273
+ host: sshConfig?.hostname || config.host,
274
+ port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
275
+ username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
276
+ keepaliveInterval: 15e3,
277
+ keepaliveCountMax: 3
278
+ };
279
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
280
+ if (agentSock) {
281
+ connectConfig.agent = agentSock;
282
+ }
283
+ if (config.password) {
284
+ connectConfig.password = config.password;
285
+ }
286
+ if (config.privateKeyPath) {
287
+ connectConfig.privateKey = readFileSync2(config.privateKeyPath);
288
+ } else if (!agentSock) {
289
+ const home = homedir2();
290
+ const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join2(home, p.slice(1)) : p) : [join2(home, ".ssh", "id_ed25519"), join2(home, ".ssh", "id_rsa"), join2(home, ".ssh", "id_ecdsa")];
291
+ for (const keyPath of keyPaths) {
292
+ try {
293
+ connectConfig.privateKey = readFileSync2(keyPath);
294
+ break;
295
+ } catch {
296
+ }
297
+ }
298
+ }
299
+ return connectConfig;
300
+ }
301
+ function formatDiagnostics(host) {
302
+ try {
303
+ const checks = [
304
+ { name: "SSH Agent", ...checkSshAgent() },
305
+ { name: "SSH Keys", ...checkSshKeys() },
306
+ { name: "SSH Config", ...checkSshConfig(host) },
307
+ { name: "Known Hosts", ...checkKnownHosts(host) }
308
+ ];
309
+ const parts = [];
310
+ const suggestions = [];
311
+ for (const check of checks) {
312
+ if (check.status !== "ok") {
313
+ parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
314
+ }
315
+ }
316
+ const agent = checks[0];
317
+ if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
318
+ if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
319
+ const keys = checks[1];
320
+ if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
321
+ const known = checks[3];
322
+ if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
323
+ if (suggestions.length > 0) {
324
+ parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
325
+ }
326
+ return parts.length > 0 ? parts.join("\n") : "";
327
+ } catch {
328
+ return "";
329
+ }
330
+ }
331
+ function connectRaw(connectConfig) {
332
+ return new Promise((resolve, reject) => {
333
+ const client = new Client();
334
+ client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
335
+ });
336
+ }
337
+ function exec(client, command, timeoutMs = 3e4) {
338
+ return new Promise((resolve, reject) => {
339
+ let settled = false;
340
+ const settle = (fn) => {
341
+ if (settled) return;
342
+ settled = true;
343
+ clearTimeout(timer);
344
+ fn();
345
+ };
346
+ const timer = setTimeout(() => {
347
+ settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
348
+ }, timeoutMs);
349
+ client.exec(command, (err, stream) => {
350
+ if (err) {
351
+ settle(() => reject(err));
352
+ return;
353
+ }
354
+ let stdout = "";
355
+ let stderr = "";
356
+ stream.on("close", (code) => {
357
+ settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
358
+ }).on("data", (data) => {
359
+ stdout += data.toString();
360
+ }).on("error", (err2) => {
361
+ settle(() => reject(err2));
362
+ });
363
+ stream.stderr.on("data", (data) => {
364
+ stderr += data.toString();
365
+ }).on("error", (err2) => {
366
+ settle(() => reject(err2));
367
+ });
368
+ });
369
+ });
370
+ }
371
+ function getSftp(client) {
372
+ return new Promise((resolve, reject) => {
373
+ client.sftp((err, sftp) => {
374
+ if (err) return reject(err);
375
+ resolve(sftp);
376
+ });
377
+ });
378
+ }
379
+ async function readFile(client, remotePath) {
380
+ const sftp = await getSftp(client);
381
+ try {
382
+ return await new Promise((resolve, reject) => {
383
+ sftp.readFile(remotePath, (err, data) => {
384
+ if (err) return reject(err);
385
+ resolve(data.toString("utf8"));
386
+ });
387
+ });
388
+ } finally {
389
+ sftp.end();
390
+ }
391
+ }
392
+ async function writeFile(client, remotePath, content) {
393
+ const sftp = await getSftp(client);
394
+ try {
395
+ await new Promise((resolve, reject) => {
396
+ sftp.writeFile(remotePath, content, (err) => {
397
+ if (err) return reject(err);
398
+ resolve();
399
+ });
400
+ });
401
+ } finally {
402
+ sftp.end();
403
+ }
404
+ }
405
+ async function uploadFile(client, localPath, remotePath) {
406
+ const sftp = await getSftp(client);
407
+ try {
408
+ await new Promise((resolve, reject) => {
409
+ sftp.fastPut(localPath, remotePath, (err) => {
410
+ if (err) return reject(err);
411
+ resolve();
412
+ });
413
+ });
414
+ } finally {
415
+ sftp.end();
416
+ }
417
+ }
418
+ async function downloadFile(client, remotePath, localPath) {
419
+ const sftp = await getSftp(client);
420
+ try {
421
+ await new Promise((resolve, reject) => {
422
+ sftp.fastGet(remotePath, localPath, (err) => {
423
+ if (err) return reject(err);
424
+ resolve();
425
+ });
426
+ });
427
+ } finally {
428
+ sftp.end();
429
+ }
430
+ }
431
+ async function listDir(client, remotePath) {
432
+ const sftp = await getSftp(client);
433
+ try {
434
+ return await new Promise((resolve, reject) => {
435
+ sftp.readdir(remotePath, (err, list) => {
436
+ if (err) return reject(err);
437
+ resolve(list.map((item) => item.filename));
438
+ });
439
+ });
440
+ } finally {
441
+ sftp.end();
442
+ }
443
+ }
444
+
445
+ // src/pool.ts
446
+ var ConnectionPool = class {
447
+ entries = /* @__PURE__ */ new Map();
448
+ idleTtlMs;
449
+ constructor(options) {
450
+ this.idleTtlMs = options?.idleTtlMs ?? 6e4;
451
+ }
452
+ async acquire(config) {
453
+ const connectConfig = resolveConfig(config);
454
+ const key = `${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`;
455
+ const existing = this.entries.get(key);
456
+ if (existing && !existing.dead) {
457
+ existing.refCount++;
458
+ if (existing.idleTimer) {
459
+ clearTimeout(existing.idleTimer);
460
+ existing.idleTimer = null;
461
+ }
462
+ return existing.client;
463
+ }
464
+ if (existing?.dead) {
465
+ this.entries.delete(key);
466
+ }
467
+ try {
468
+ const client = await connectRaw(connectConfig);
469
+ const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
470
+ const markDead = () => {
471
+ entry.dead = true;
472
+ if (entry.idleTimer) {
473
+ clearTimeout(entry.idleTimer);
474
+ entry.idleTimer = null;
475
+ }
476
+ if (this.entries.get(key) === entry) {
477
+ this.entries.delete(key);
478
+ }
479
+ };
480
+ client.on("close", markDead);
481
+ client.on("end", markDead);
482
+ client.on("error", markDead);
483
+ this.entries.set(key, entry);
484
+ return client;
485
+ } catch (err) {
486
+ const diag = formatDiagnostics(config.host);
487
+ if (diag) {
488
+ const message = err instanceof Error ? err.message : String(err);
489
+ const enhanced = new Error(`${message}
490
+
491
+ SSH Diagnostics:
492
+ ${diag}`);
493
+ enhanced.cause = err;
494
+ throw enhanced;
495
+ }
496
+ throw err;
497
+ }
498
+ }
499
+ release(client) {
500
+ for (const entry of this.entries.values()) {
501
+ if (entry.client === client) {
502
+ entry.refCount = Math.max(0, entry.refCount - 1);
503
+ if (entry.refCount === 0 && !entry.dead) {
504
+ entry.idleTimer = setTimeout(() => {
505
+ try {
506
+ entry.client.end();
507
+ } catch {
508
+ }
509
+ this.entries.delete(entry.key);
510
+ }, this.idleTtlMs);
511
+ entry.idleTimer.unref();
512
+ }
513
+ return;
514
+ }
515
+ }
516
+ try {
517
+ client.end();
518
+ } catch {
519
+ }
520
+ }
521
+ async withConnection(config, fn) {
522
+ const client = await this.acquire(config);
523
+ try {
524
+ return await fn(client);
525
+ } finally {
526
+ this.release(client);
527
+ }
528
+ }
529
+ drain() {
530
+ for (const entry of this.entries.values()) {
531
+ if (entry.idleTimer) {
532
+ clearTimeout(entry.idleTimer);
533
+ }
534
+ try {
535
+ entry.client.end();
536
+ } catch {
537
+ }
538
+ }
539
+ this.entries.clear();
540
+ }
541
+ get size() {
542
+ return this.entries.size;
543
+ }
544
+ get stats() {
545
+ let active = 0;
546
+ let idle = 0;
547
+ for (const entry of this.entries.values()) {
548
+ if (entry.refCount > 0) active++;
549
+ else idle++;
550
+ }
551
+ return { active, idle };
552
+ }
553
+ };
554
+
555
+ // src/server.ts
556
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
557
+
558
+ // src/tools.ts
559
+ import { z } from "zod";
560
+
241
561
  // src/env.ts
242
- import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync } from "fs";
243
- import { homedir as homedir2 } from "os";
244
- import { join as join2 } from "path";
562
+ import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync } from "fs";
563
+ import { homedir as homedir3 } from "os";
564
+ import { join as join3 } from "path";
245
565
  function ensureAgent() {
246
566
  const sock = process.env.SSH_AUTH_SOCK;
247
567
  if (sock) {
@@ -289,7 +609,7 @@ function detectKeyType(filePath, fileName) {
289
609
  const pubPath = `${filePath}.pub`;
290
610
  if (existsSync2(pubPath)) {
291
611
  try {
292
- const pub = readFileSync2(pubPath, "utf8");
612
+ const pub = readFileSync3(pubPath, "utf8");
293
613
  if (pub.includes("ssh-ed25519")) return "ed25519";
294
614
  if (pub.includes("ssh-rsa")) return "rsa";
295
615
  if (pub.includes("ecdsa")) return "ecdsa";
@@ -302,7 +622,7 @@ function detectKeyType(filePath, fileName) {
302
622
  if (fileName.includes("ecdsa")) return "ecdsa";
303
623
  if (fileName.includes("dsa")) return "dsa";
304
624
  try {
305
- const content = readFileSync2(filePath, "utf8");
625
+ const content = readFileSync3(filePath, "utf8");
306
626
  if (content.includes("RSA PRIVATE KEY")) return "rsa";
307
627
  if (content.includes("EC PRIVATE KEY")) return "ecdsa";
308
628
  if (content.includes("DSA PRIVATE KEY")) return "dsa";
@@ -311,7 +631,7 @@ function detectKeyType(filePath, fileName) {
311
631
  return "unknown";
312
632
  }
313
633
  function listSshKeys() {
314
- const sshDir = join2(homedir2(), ".ssh");
634
+ const sshDir = join3(homedir3(), ".ssh");
315
635
  if (!existsSync2(sshDir)) return [];
316
636
  const loadedFingerprints = /* @__PURE__ */ new Set();
317
637
  const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
@@ -331,11 +651,11 @@ function listSshKeys() {
331
651
  }
332
652
  for (const file of files) {
333
653
  if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
334
- const filePath = join2(sshDir, file);
654
+ const filePath = join3(sshDir, file);
335
655
  try {
336
656
  const stat = statSync(filePath);
337
657
  if (!stat.isFile()) continue;
338
- const content = readFileSync2(filePath, "utf8");
658
+ const content = readFileSync3(filePath, "utf8");
339
659
  if (!content.includes("PRIVATE KEY")) continue;
340
660
  const type = detectKeyType(filePath, file);
341
661
  let fingerprint;
@@ -356,7 +676,7 @@ function loadKey(keyPath) {
356
676
  if (!agent.reachable) {
357
677
  return { status: "error", message: agent.message };
358
678
  }
359
- const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
679
+ const resolved = keyPath.startsWith("~") ? join3(homedir3(), keyPath.slice(1)) : keyPath;
360
680
  if (!existsSync2(resolved)) {
361
681
  return { status: "error", message: `Key not found: ${resolved}` };
362
682
  }
@@ -422,7 +742,7 @@ function fixKnownHosts(host, port = 22) {
422
742
  const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
423
743
  if (scanOk && scanOut.trim()) {
424
744
  try {
425
- const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
745
+ const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
426
746
  appendFileSync(knownHostsPath, `
427
747
  ${scanOut.trim()}
428
748
  `);
@@ -515,194 +835,6 @@ function testConnection(host, port = 22) {
515
835
  return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
516
836
  }
517
837
 
518
- // src/ssh.ts
519
- import { readFileSync as readFileSync3 } from "fs";
520
- import { homedir as homedir3 } from "os";
521
- import { join as join3 } from "path";
522
- import { Client } from "ssh2";
523
- function resolveConfig(config) {
524
- const connectConfig = {
525
- host: config.host,
526
- port: config.port || 22,
527
- username: config.username || process.env.USER || process.env.USERNAME || "root"
528
- };
529
- if (config.privateKeyPath) {
530
- connectConfig.privateKey = readFileSync3(config.privateKeyPath);
531
- } else if (config.password) {
532
- connectConfig.password = config.password;
533
- } else if (config.agent || process.env.SSH_AUTH_SOCK) {
534
- connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
535
- } else {
536
- const home = homedir3();
537
- const defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
538
- for (const keyName of defaultKeys) {
539
- const keyPath = join3(home, ".ssh", keyName);
540
- try {
541
- connectConfig.privateKey = readFileSync3(keyPath);
542
- break;
543
- } catch {
544
- }
545
- }
546
- }
547
- return connectConfig;
548
- }
549
- function formatDiagnostics(host) {
550
- try {
551
- const checks = [
552
- { name: "SSH Agent", ...checkSshAgent() },
553
- { name: "SSH Keys", ...checkSshKeys() },
554
- { name: "SSH Config", ...checkSshConfig(host) },
555
- { name: "Known Hosts", ...checkKnownHosts(host) }
556
- ];
557
- const parts = [];
558
- const suggestions = [];
559
- for (const check of checks) {
560
- if (check.status !== "ok") {
561
- parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
562
- }
563
- }
564
- const agent = checks[0];
565
- if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
566
- if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
567
- const keys = checks[1];
568
- if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
569
- const known = checks[3];
570
- if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
571
- if (suggestions.length > 0) {
572
- parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
573
- }
574
- return parts.length > 0 ? parts.join("\n") : "";
575
- } catch {
576
- return "";
577
- }
578
- }
579
- function connect(config) {
580
- return new Promise((resolve, reject) => {
581
- const client = new Client();
582
- const connectConfig = resolveConfig(config);
583
- client.on("ready", () => resolve(client)).on("error", (err) => {
584
- const diag = formatDiagnostics(config.host);
585
- if (diag) {
586
- const enhanced = new Error(`${err.message}
587
-
588
- SSH Diagnostics:
589
- ${diag}`);
590
- enhanced.cause = err;
591
- reject(enhanced);
592
- } else {
593
- reject(err);
594
- }
595
- }).connect(connectConfig);
596
- });
597
- }
598
- function exec(client, command, timeoutMs = 3e4) {
599
- return new Promise((resolve, reject) => {
600
- let settled = false;
601
- const settle = (fn) => {
602
- if (settled) return;
603
- settled = true;
604
- clearTimeout(timer);
605
- fn();
606
- };
607
- const timer = setTimeout(() => {
608
- settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
609
- }, timeoutMs);
610
- client.exec(command, (err, stream) => {
611
- if (err) {
612
- settle(() => reject(err));
613
- return;
614
- }
615
- let stdout = "";
616
- let stderr = "";
617
- stream.on("close", (code) => {
618
- settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
619
- }).on("data", (data) => {
620
- stdout += data.toString();
621
- }).on("error", (err2) => {
622
- settle(() => reject(err2));
623
- });
624
- stream.stderr.on("data", (data) => {
625
- stderr += data.toString();
626
- }).on("error", (err2) => {
627
- settle(() => reject(err2));
628
- });
629
- });
630
- });
631
- }
632
- function getSftp(client) {
633
- return new Promise((resolve, reject) => {
634
- client.sftp((err, sftp) => {
635
- if (err) return reject(err);
636
- resolve(sftp);
637
- });
638
- });
639
- }
640
- async function readFile(client, remotePath) {
641
- const sftp = await getSftp(client);
642
- try {
643
- return await new Promise((resolve, reject) => {
644
- sftp.readFile(remotePath, (err, data) => {
645
- if (err) return reject(err);
646
- resolve(data.toString("utf8"));
647
- });
648
- });
649
- } finally {
650
- sftp.end();
651
- }
652
- }
653
- async function writeFile(client, remotePath, content) {
654
- const sftp = await getSftp(client);
655
- try {
656
- await new Promise((resolve, reject) => {
657
- sftp.writeFile(remotePath, content, (err) => {
658
- if (err) return reject(err);
659
- resolve();
660
- });
661
- });
662
- } finally {
663
- sftp.end();
664
- }
665
- }
666
- async function uploadFile(client, localPath, remotePath) {
667
- const sftp = await getSftp(client);
668
- try {
669
- await new Promise((resolve, reject) => {
670
- sftp.fastPut(localPath, remotePath, (err) => {
671
- if (err) return reject(err);
672
- resolve();
673
- });
674
- });
675
- } finally {
676
- sftp.end();
677
- }
678
- }
679
- async function downloadFile(client, remotePath, localPath) {
680
- const sftp = await getSftp(client);
681
- try {
682
- await new Promise((resolve, reject) => {
683
- sftp.fastGet(remotePath, localPath, (err) => {
684
- if (err) return reject(err);
685
- resolve();
686
- });
687
- });
688
- } finally {
689
- sftp.end();
690
- }
691
- }
692
- async function listDir(client, remotePath) {
693
- const sftp = await getSftp(client);
694
- try {
695
- return await new Promise((resolve, reject) => {
696
- sftp.readdir(remotePath, (err, list) => {
697
- if (err) return reject(err);
698
- resolve(list.map((item) => item.filename));
699
- });
700
- });
701
- } finally {
702
- sftp.end();
703
- }
704
- }
705
-
706
838
  // src/tools.ts
707
839
  var HostSchema = z.string().describe("SSH hostname or IP address");
708
840
  var PortSchema = z.number().optional().describe("SSH port (default: 22)");
@@ -717,7 +849,8 @@ var connectionParams = {
717
849
  privateKeyPath: KeyPathSchema,
718
850
  password: PasswordSchema
719
851
  };
720
- function registerTools(server) {
852
+ function registerTools(server, pool) {
853
+ const connectionPool = pool ?? new ConnectionPool();
721
854
  server.tool(
722
855
  "ssh_exec",
723
856
  "Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
@@ -727,8 +860,7 @@ function registerTools(server) {
727
860
  timeout: TimeoutSchema
728
861
  },
729
862
  async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
730
- const client = await connect({ host, port, username, privateKeyPath, password });
731
- try {
863
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
732
864
  const result = await exec(client, command, timeout || 3e4);
733
865
  const parts = [];
734
866
  if (result.stdout) parts.push(result.stdout);
@@ -736,9 +868,7 @@ function registerTools(server) {
736
868
  ${result.stderr}`);
737
869
  parts.push(`[exit code: ${result.code}]`);
738
870
  return { content: [{ type: "text", text: parts.join("\n") }] };
739
- } finally {
740
- client.end();
741
- }
871
+ });
742
872
  }
743
873
  );
744
874
  server.tool(
@@ -749,13 +879,10 @@ ${result.stderr}`);
749
879
  path: z.string().describe("Absolute path to the remote file")
750
880
  },
751
881
  async ({ host, port, username, privateKeyPath, password, path }) => {
752
- const client = await connect({ host, port, username, privateKeyPath, password });
753
- try {
882
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
754
883
  const content = await readFile(client, path);
755
884
  return { content: [{ type: "text", text: content }] };
756
- } finally {
757
- client.end();
758
- }
885
+ });
759
886
  }
760
887
  );
761
888
  server.tool(
@@ -767,13 +894,10 @@ ${result.stderr}`);
767
894
  content: z.string().describe("File content to write")
768
895
  },
769
896
  async ({ host, port, username, privateKeyPath, password, path, content }) => {
770
- const client = await connect({ host, port, username, privateKeyPath, password });
771
- try {
897
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
772
898
  await writeFile(client, path, content);
773
899
  return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
774
- } finally {
775
- client.end();
776
- }
900
+ });
777
901
  }
778
902
  );
779
903
  server.tool(
@@ -785,13 +909,10 @@ ${result.stderr}`);
785
909
  remotePath: z.string().describe("Absolute path on the remote host")
786
910
  },
787
911
  async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
788
- const client = await connect({ host, port, username, privateKeyPath, password });
789
- try {
912
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
790
913
  await uploadFile(client, localPath, remotePath);
791
914
  return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
792
- } finally {
793
- client.end();
794
- }
915
+ });
795
916
  }
796
917
  );
797
918
  server.tool(
@@ -803,13 +924,10 @@ ${result.stderr}`);
803
924
  localPath: z.string().describe("Local path to save the downloaded file")
804
925
  },
805
926
  async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
806
- const client = await connect({ host, port, username, privateKeyPath, password });
807
- try {
927
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
808
928
  await downloadFile(client, remotePath, localPath);
809
929
  return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
810
- } finally {
811
- client.end();
812
- }
930
+ });
813
931
  }
814
932
  );
815
933
  server.tool(
@@ -820,13 +938,10 @@ ${result.stderr}`);
820
938
  path: z.string().describe("Absolute path to the remote directory")
821
939
  },
822
940
  async ({ host, port, username, privateKeyPath, password, path }) => {
823
- const client = await connect({ host, port, username, privateKeyPath, password });
824
- try {
941
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
825
942
  const files = await listDir(client, path);
826
943
  return { content: [{ type: "text", text: files.join("\n") }] };
827
- } finally {
828
- client.end();
829
- }
944
+ });
830
945
  }
831
946
  );
832
947
  server.tool(
@@ -988,19 +1103,26 @@ ${result.stderr}`);
988
1103
  }
989
1104
 
990
1105
  // src/server.ts
991
- function createServer() {
1106
+ function createServer(pool) {
992
1107
  const server = new McpServer({
993
1108
  name: "ssh-mcp",
994
- version: "0.3.0"
1109
+ version: "0.4.0"
995
1110
  });
996
- registerTools(server);
1111
+ registerTools(server, pool);
997
1112
  return server;
998
1113
  }
999
1114
 
1000
1115
  // src/index.ts
1001
1116
  async function main() {
1002
- const server = createServer();
1117
+ const pool = new ConnectionPool();
1118
+ const server = createServer(pool);
1003
1119
  const transport = new StdioServerTransport();
1120
+ const shutdown = () => {
1121
+ pool.drain();
1122
+ process.exit(0);
1123
+ };
1124
+ process.on("SIGINT", shutdown);
1125
+ process.on("SIGTERM", shutdown);
1004
1126
  await server.connect(transport);
1005
1127
  }
1006
1128
  main().catch((err) => {
package/dist/server.d.ts CHANGED
@@ -1,25 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { Client } from 'ssh2';
3
-
4
- declare function registerTools(server: McpServer): void;
5
-
6
- interface DiagnosticResult {
7
- status: "ok" | "warning" | "error";
8
- message: string;
9
- }
10
- interface DiagnosticReport {
11
- overall: "ok" | "warning" | "error";
12
- checks: Array<{
13
- name: string;
14
- } & DiagnosticResult>;
15
- suggestions: string[];
16
- }
17
- declare function checkSshAgent(): DiagnosticResult;
18
- declare function checkSshKeys(): DiagnosticResult;
19
- declare function checkKnownHosts(host: string): DiagnosticResult;
20
- declare function checkConnectivity(host: string, port?: number): DiagnosticResult;
21
- declare function checkSshConfig(host: string): DiagnosticResult;
22
- declare function diagnose(host: string, port?: number): DiagnosticReport;
2
+ import { Client, ConnectConfig } from 'ssh2';
23
3
 
24
4
  interface SSHConfig {
25
5
  host: string;
@@ -34,6 +14,8 @@ interface ExecResult {
34
14
  stderr: string;
35
15
  code: number;
36
16
  }
17
+ declare function resolveConfig(config: SSHConfig): ConnectConfig;
18
+ declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
37
19
  declare function connect(config: SSHConfig): Promise<Client>;
38
20
  declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
39
21
  declare function readFile(client: Client, remotePath: string): Promise<string>;
@@ -42,6 +24,45 @@ declare function uploadFile(client: Client, localPath: string, remotePath: strin
42
24
  declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
43
25
  declare function listDir(client: Client, remotePath: string): Promise<string[]>;
44
26
 
27
+ interface PoolOptions {
28
+ /** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
29
+ idleTtlMs?: number;
30
+ }
31
+ declare class ConnectionPool {
32
+ private entries;
33
+ private idleTtlMs;
34
+ constructor(options?: PoolOptions);
35
+ acquire(config: SSHConfig): Promise<Client>;
36
+ release(client: Client): void;
37
+ withConnection<T>(config: SSHConfig, fn: (client: Client) => Promise<T>): Promise<T>;
38
+ drain(): void;
39
+ get size(): number;
40
+ get stats(): {
41
+ active: number;
42
+ idle: number;
43
+ };
44
+ }
45
+
46
+ declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
47
+
48
+ interface DiagnosticResult {
49
+ status: "ok" | "warning" | "error";
50
+ message: string;
51
+ }
52
+ interface DiagnosticReport {
53
+ overall: "ok" | "warning" | "error";
54
+ checks: Array<{
55
+ name: string;
56
+ } & DiagnosticResult>;
57
+ suggestions: string[];
58
+ }
59
+ declare function checkSshAgent(): DiagnosticResult;
60
+ declare function checkSshKeys(): DiagnosticResult;
61
+ declare function checkKnownHosts(host: string): DiagnosticResult;
62
+ declare function checkConnectivity(host: string, port?: number): DiagnosticResult;
63
+ declare function checkSshConfig(host: string): DiagnosticResult;
64
+ declare function diagnose(host: string, port?: number): DiagnosticReport;
65
+
45
66
  interface KeyInfo {
46
67
  name: string;
47
68
  path: string;
@@ -95,6 +116,6 @@ declare function testConnection(host: string, port?: number): {
95
116
  message: string;
96
117
  };
97
118
 
98
- declare function createServer(): McpServer;
119
+ declare function createServer(pool?: ConnectionPool): McpServer;
99
120
 
100
- export { type AgentResult, type ConfigLookupResult, type DiagnosticReport, type DiagnosticResult, type ExecResult, type KeyInfo, type SSHConfig, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, createServer, diagnose, downloadFile, ensureAgent, exec, fixKnownHosts, listDir, listSshKeys, loadKey, readFile, registerTools, testConnection, uploadFile, writeFile };
121
+ export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type KeyInfo, type PoolOptions, type SSHConfig, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, createServer, diagnose, downloadFile, ensureAgent, exec, fixKnownHosts, listDir, listSshKeys, loadKey, readFile, registerTools, resolveConfig, testConnection, uploadFile, writeFile };
package/dist/server.js CHANGED
@@ -515,23 +515,56 @@ import { readFileSync as readFileSync3 } from "fs";
515
515
  import { homedir as homedir3 } from "os";
516
516
  import { join as join3 } from "path";
517
517
  import { Client } from "ssh2";
518
+ function resolveFromSshConfig(host) {
519
+ try {
520
+ const { stdout, ok } = runArgs("ssh", ["-G", host]);
521
+ if (!ok) return null;
522
+ const config = {};
523
+ const identityFiles = [];
524
+ for (const line of stdout.split("\n")) {
525
+ const spaceIdx = line.indexOf(" ");
526
+ if (spaceIdx > 0) {
527
+ const key = line.substring(0, spaceIdx);
528
+ const value = line.substring(spaceIdx + 1);
529
+ if (key === "identityfile") {
530
+ identityFiles.push(value);
531
+ } else {
532
+ config[key] = value;
533
+ }
534
+ }
535
+ }
536
+ return {
537
+ hostname: config.hostname || host,
538
+ user: config.user || "",
539
+ port: config.port || "22",
540
+ identityFiles
541
+ };
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
518
546
  function resolveConfig(config) {
547
+ const sshConfig = resolveFromSshConfig(config.host);
519
548
  const connectConfig = {
520
- host: config.host,
521
- port: config.port || 22,
522
- username: config.username || process.env.USER || process.env.USERNAME || "root"
549
+ host: sshConfig?.hostname || config.host,
550
+ port: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
551
+ username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
552
+ keepaliveInterval: 15e3,
553
+ keepaliveCountMax: 3
523
554
  };
555
+ const agentSock = config.agent || process.env.SSH_AUTH_SOCK;
556
+ if (agentSock) {
557
+ connectConfig.agent = agentSock;
558
+ }
559
+ if (config.password) {
560
+ connectConfig.password = config.password;
561
+ }
524
562
  if (config.privateKeyPath) {
525
563
  connectConfig.privateKey = readFileSync3(config.privateKeyPath);
526
- } else if (config.password) {
527
- connectConfig.password = config.password;
528
- } else if (config.agent || process.env.SSH_AUTH_SOCK) {
529
- connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
530
- } else {
564
+ } else if (!agentSock) {
531
565
  const home = homedir3();
532
- const defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
533
- for (const keyName of defaultKeys) {
534
- const keyPath = join3(home, ".ssh", keyName);
566
+ const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join3(home, p.slice(1)) : p) : [join3(home, ".ssh", "id_ed25519"), join3(home, ".ssh", "id_rsa"), join3(home, ".ssh", "id_ecdsa")];
567
+ for (const keyPath of keyPaths) {
535
568
  try {
536
569
  connectConfig.privateKey = readFileSync3(keyPath);
537
570
  break;
@@ -571,24 +604,29 @@ function formatDiagnostics(host) {
571
604
  return "";
572
605
  }
573
606
  }
574
- function connect(config) {
607
+ function connectRaw(connectConfig) {
575
608
  return new Promise((resolve, reject) => {
576
609
  const client = new Client();
577
- const connectConfig = resolveConfig(config);
578
- client.on("ready", () => resolve(client)).on("error", (err) => {
579
- const diag = formatDiagnostics(config.host);
580
- if (diag) {
581
- const enhanced = new Error(`${err.message}
610
+ client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
611
+ });
612
+ }
613
+ async function connect(config) {
614
+ const connectConfig = resolveConfig(config);
615
+ try {
616
+ return await connectRaw(connectConfig);
617
+ } catch (err) {
618
+ const diag = formatDiagnostics(config.host);
619
+ if (diag) {
620
+ const message = err instanceof Error ? err.message : String(err);
621
+ const enhanced = new Error(`${message}
582
622
 
583
623
  SSH Diagnostics:
584
624
  ${diag}`);
585
- enhanced.cause = err;
586
- reject(enhanced);
587
- } else {
588
- reject(err);
589
- }
590
- }).connect(connectConfig);
591
- });
625
+ enhanced.cause = err;
626
+ throw enhanced;
627
+ }
628
+ throw err;
629
+ }
592
630
  }
593
631
  function exec(client, command, timeoutMs = 3e4) {
594
632
  return new Promise((resolve, reject) => {
@@ -698,6 +736,116 @@ async function listDir(client, remotePath) {
698
736
  }
699
737
  }
700
738
 
739
+ // src/pool.ts
740
+ var ConnectionPool = class {
741
+ entries = /* @__PURE__ */ new Map();
742
+ idleTtlMs;
743
+ constructor(options) {
744
+ this.idleTtlMs = options?.idleTtlMs ?? 6e4;
745
+ }
746
+ async acquire(config) {
747
+ const connectConfig = resolveConfig(config);
748
+ const key = `${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`;
749
+ const existing = this.entries.get(key);
750
+ if (existing && !existing.dead) {
751
+ existing.refCount++;
752
+ if (existing.idleTimer) {
753
+ clearTimeout(existing.idleTimer);
754
+ existing.idleTimer = null;
755
+ }
756
+ return existing.client;
757
+ }
758
+ if (existing?.dead) {
759
+ this.entries.delete(key);
760
+ }
761
+ try {
762
+ const client = await connectRaw(connectConfig);
763
+ const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
764
+ const markDead = () => {
765
+ entry.dead = true;
766
+ if (entry.idleTimer) {
767
+ clearTimeout(entry.idleTimer);
768
+ entry.idleTimer = null;
769
+ }
770
+ if (this.entries.get(key) === entry) {
771
+ this.entries.delete(key);
772
+ }
773
+ };
774
+ client.on("close", markDead);
775
+ client.on("end", markDead);
776
+ client.on("error", markDead);
777
+ this.entries.set(key, entry);
778
+ return client;
779
+ } catch (err) {
780
+ const diag = formatDiagnostics(config.host);
781
+ if (diag) {
782
+ const message = err instanceof Error ? err.message : String(err);
783
+ const enhanced = new Error(`${message}
784
+
785
+ SSH Diagnostics:
786
+ ${diag}`);
787
+ enhanced.cause = err;
788
+ throw enhanced;
789
+ }
790
+ throw err;
791
+ }
792
+ }
793
+ release(client) {
794
+ for (const entry of this.entries.values()) {
795
+ if (entry.client === client) {
796
+ entry.refCount = Math.max(0, entry.refCount - 1);
797
+ if (entry.refCount === 0 && !entry.dead) {
798
+ entry.idleTimer = setTimeout(() => {
799
+ try {
800
+ entry.client.end();
801
+ } catch {
802
+ }
803
+ this.entries.delete(entry.key);
804
+ }, this.idleTtlMs);
805
+ entry.idleTimer.unref();
806
+ }
807
+ return;
808
+ }
809
+ }
810
+ try {
811
+ client.end();
812
+ } catch {
813
+ }
814
+ }
815
+ async withConnection(config, fn) {
816
+ const client = await this.acquire(config);
817
+ try {
818
+ return await fn(client);
819
+ } finally {
820
+ this.release(client);
821
+ }
822
+ }
823
+ drain() {
824
+ for (const entry of this.entries.values()) {
825
+ if (entry.idleTimer) {
826
+ clearTimeout(entry.idleTimer);
827
+ }
828
+ try {
829
+ entry.client.end();
830
+ } catch {
831
+ }
832
+ }
833
+ this.entries.clear();
834
+ }
835
+ get size() {
836
+ return this.entries.size;
837
+ }
838
+ get stats() {
839
+ let active = 0;
840
+ let idle = 0;
841
+ for (const entry of this.entries.values()) {
842
+ if (entry.refCount > 0) active++;
843
+ else idle++;
844
+ }
845
+ return { active, idle };
846
+ }
847
+ };
848
+
701
849
  // src/tools.ts
702
850
  var HostSchema = z.string().describe("SSH hostname or IP address");
703
851
  var PortSchema = z.number().optional().describe("SSH port (default: 22)");
@@ -712,7 +860,8 @@ var connectionParams = {
712
860
  privateKeyPath: KeyPathSchema,
713
861
  password: PasswordSchema
714
862
  };
715
- function registerTools(server) {
863
+ function registerTools(server, pool) {
864
+ const connectionPool = pool ?? new ConnectionPool();
716
865
  server.tool(
717
866
  "ssh_exec",
718
867
  "Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
@@ -722,8 +871,7 @@ function registerTools(server) {
722
871
  timeout: TimeoutSchema
723
872
  },
724
873
  async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
725
- const client = await connect({ host, port, username, privateKeyPath, password });
726
- try {
874
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
727
875
  const result = await exec(client, command, timeout || 3e4);
728
876
  const parts = [];
729
877
  if (result.stdout) parts.push(result.stdout);
@@ -731,9 +879,7 @@ function registerTools(server) {
731
879
  ${result.stderr}`);
732
880
  parts.push(`[exit code: ${result.code}]`);
733
881
  return { content: [{ type: "text", text: parts.join("\n") }] };
734
- } finally {
735
- client.end();
736
- }
882
+ });
737
883
  }
738
884
  );
739
885
  server.tool(
@@ -744,13 +890,10 @@ ${result.stderr}`);
744
890
  path: z.string().describe("Absolute path to the remote file")
745
891
  },
746
892
  async ({ host, port, username, privateKeyPath, password, path }) => {
747
- const client = await connect({ host, port, username, privateKeyPath, password });
748
- try {
893
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
749
894
  const content = await readFile(client, path);
750
895
  return { content: [{ type: "text", text: content }] };
751
- } finally {
752
- client.end();
753
- }
896
+ });
754
897
  }
755
898
  );
756
899
  server.tool(
@@ -762,13 +905,10 @@ ${result.stderr}`);
762
905
  content: z.string().describe("File content to write")
763
906
  },
764
907
  async ({ host, port, username, privateKeyPath, password, path, content }) => {
765
- const client = await connect({ host, port, username, privateKeyPath, password });
766
- try {
908
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
767
909
  await writeFile(client, path, content);
768
910
  return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
769
- } finally {
770
- client.end();
771
- }
911
+ });
772
912
  }
773
913
  );
774
914
  server.tool(
@@ -780,13 +920,10 @@ ${result.stderr}`);
780
920
  remotePath: z.string().describe("Absolute path on the remote host")
781
921
  },
782
922
  async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
783
- const client = await connect({ host, port, username, privateKeyPath, password });
784
- try {
923
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
785
924
  await uploadFile(client, localPath, remotePath);
786
925
  return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
787
- } finally {
788
- client.end();
789
- }
926
+ });
790
927
  }
791
928
  );
792
929
  server.tool(
@@ -798,13 +935,10 @@ ${result.stderr}`);
798
935
  localPath: z.string().describe("Local path to save the downloaded file")
799
936
  },
800
937
  async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
801
- const client = await connect({ host, port, username, privateKeyPath, password });
802
- try {
938
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
803
939
  await downloadFile(client, remotePath, localPath);
804
940
  return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
805
- } finally {
806
- client.end();
807
- }
941
+ });
808
942
  }
809
943
  );
810
944
  server.tool(
@@ -815,13 +949,10 @@ ${result.stderr}`);
815
949
  path: z.string().describe("Absolute path to the remote directory")
816
950
  },
817
951
  async ({ host, port, username, privateKeyPath, password, path }) => {
818
- const client = await connect({ host, port, username, privateKeyPath, password });
819
- try {
952
+ return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
820
953
  const files = await listDir(client, path);
821
954
  return { content: [{ type: "text", text: files.join("\n") }] };
822
- } finally {
823
- client.end();
824
- }
955
+ });
825
956
  }
826
957
  );
827
958
  server.tool(
@@ -983,15 +1114,16 @@ ${result.stderr}`);
983
1114
  }
984
1115
 
985
1116
  // src/server.ts
986
- function createServer() {
1117
+ function createServer(pool) {
987
1118
  const server = new McpServer({
988
1119
  name: "ssh-mcp",
989
- version: "0.3.0"
1120
+ version: "0.4.0"
990
1121
  });
991
- registerTools(server);
1122
+ registerTools(server, pool);
992
1123
  return server;
993
1124
  }
994
1125
  export {
1126
+ ConnectionPool,
995
1127
  checkConnectivity,
996
1128
  checkGitSsh,
997
1129
  checkKnownHosts,
@@ -1000,6 +1132,7 @@ export {
1000
1132
  checkSshKeys,
1001
1133
  configLookup,
1002
1134
  connect,
1135
+ connectRaw,
1003
1136
  createServer,
1004
1137
  diagnose,
1005
1138
  downloadFile,
@@ -1011,6 +1144,7 @@ export {
1011
1144
  loadKey,
1012
1145
  readFile,
1013
1146
  registerTools,
1147
+ resolveConfig,
1014
1148
  testConnection,
1015
1149
  uploadFile,
1016
1150
  writeFile
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {