@yawlabs/ssh-mcp 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.d.ts CHANGED
@@ -43,6 +43,7 @@ declare class ConnectionPool {
43
43
  private idleTtlMs;
44
44
  private maxPoolSize;
45
45
  private _connectCount;
46
+ private drained;
46
47
  constructor(options?: PoolOptions);
47
48
  acquire(config: SSHConfig): Promise<Client>;
48
49
  release(client: Client): void;
@@ -57,8 +58,6 @@ declare class ConnectionPool {
57
58
  get connectCount(): number;
58
59
  }
59
60
 
60
- declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
61
-
62
61
  interface DiagnosticResult {
63
62
  status: "ok" | "warning" | "error";
64
63
  message: string;
@@ -77,43 +76,6 @@ declare function checkConnectivity(host: string, port?: number): DiagnosticResul
77
76
  declare function checkSshConfig(host: string): DiagnosticResult;
78
77
  declare function diagnose(host: string, port?: number): DiagnosticReport;
79
78
 
80
- interface MultiExecResult {
81
- host: string;
82
- stdout: string;
83
- stderr: string;
84
- code: number;
85
- error?: string;
86
- }
87
- interface MultiExecHost {
88
- host: string;
89
- port?: number;
90
- username?: string;
91
- privateKeyPath?: string;
92
- password?: string;
93
- }
94
- declare function multiExec(pool: ConnectionPool, hosts: MultiExecHost[], command: string, timeoutMs?: number): Promise<MultiExecResult[]>;
95
- interface FindOptions {
96
- path: string;
97
- name?: string;
98
- type?: "f" | "d" | "l";
99
- maxdepth?: number;
100
- minsize?: string;
101
- maxsize?: string;
102
- newer?: string;
103
- }
104
- declare function find(client: Client, options: FindOptions, timeoutMs?: number): Promise<string[]>;
105
- declare function tail(client: Client, path: string, lines?: number, grep?: string, timeoutMs?: number): Promise<string>;
106
- interface ServiceStatus {
107
- name: string;
108
- active: boolean;
109
- status: string;
110
- description?: string;
111
- since?: string;
112
- pid?: number;
113
- raw: string;
114
- }
115
- declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
116
-
117
79
  interface KeyInfo {
118
80
  name: string;
119
81
  path: string;
@@ -167,6 +129,45 @@ declare function testConnection(host: string, port?: number): {
167
129
  message: string;
168
130
  };
169
131
 
132
+ interface MultiExecResult {
133
+ host: string;
134
+ stdout: string;
135
+ stderr: string;
136
+ code: number;
137
+ error?: string;
138
+ }
139
+ interface MultiExecHost {
140
+ host: string;
141
+ port?: number;
142
+ username?: string;
143
+ privateKeyPath?: string;
144
+ password?: string;
145
+ }
146
+ declare function multiExec(pool: ConnectionPool, hosts: MultiExecHost[], command: string, timeoutMs?: number): Promise<MultiExecResult[]>;
147
+ interface FindOptions {
148
+ path: string;
149
+ name?: string;
150
+ type?: "f" | "d" | "l";
151
+ maxdepth?: number;
152
+ minsize?: string;
153
+ maxsize?: string;
154
+ newer?: string;
155
+ }
156
+ declare function find(client: Client, options: FindOptions, timeoutMs?: number): Promise<string[]>;
157
+ declare function tail(client: Client, path: string, lines?: number, grep?: string, timeoutMs?: number): Promise<string>;
158
+ interface ServiceStatus {
159
+ name: string;
160
+ active: boolean;
161
+ status: string;
162
+ description?: string;
163
+ since?: string;
164
+ pid?: number;
165
+ raw: string;
166
+ }
167
+ declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
168
+
169
+ declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
170
+
170
171
  declare function createServer(pool?: ConnectionPool): McpServer;
171
172
 
172
173
  export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
package/dist/server.js CHANGED
@@ -1,4 +1,7 @@
1
1
  // src/server.ts
2
+ import { readFileSync as readFileSync4 } from "fs";
3
+ import { dirname, join as join4 } from "path";
4
+ import { fileURLToPath } from "url";
2
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
6
 
4
7
  // src/tools.ts
@@ -6,7 +9,7 @@ import { z } from "zod";
6
9
 
7
10
  // src/diagnose.ts
8
11
  import { execFileSync } from "child_process";
9
- import { existsSync, readFileSync, readdirSync } from "fs";
12
+ import { existsSync, readdirSync, readFileSync } from "fs";
10
13
  import { homedir } from "os";
11
14
  import { join } from "path";
12
15
  function isValidHostname(host) {
@@ -14,7 +17,7 @@ function isValidHostname(host) {
14
17
  if (host.startsWith("[")) {
15
18
  return /^\[[0-9a-fA-F:]+\]$/.test(host);
16
19
  }
17
- return /^[a-zA-Z0-9._\-]+$/.test(host);
20
+ return /^[a-zA-Z0-9._-]+$/.test(host);
18
21
  }
19
22
  function runArgs(cmd, args) {
20
23
  try {
@@ -256,11 +259,63 @@ function diagnose(host, port = 22) {
256
259
  }
257
260
 
258
261
  // src/env.ts
259
- import { appendFileSync, existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync } from "fs";
262
+ import { execFileSync as execFileSync2 } from "child_process";
263
+ import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
260
264
  import { homedir as homedir2 } from "os";
261
265
  import { join as join2 } from "path";
266
+
267
+ // src/ssh-config.ts
268
+ function parseSshConfigOutput(stdout) {
269
+ const all = {};
270
+ const identityFiles = [];
271
+ for (const line of stdout.split("\n")) {
272
+ const spaceIdx = line.indexOf(" ");
273
+ if (spaceIdx > 0) {
274
+ const key = line.substring(0, spaceIdx);
275
+ const value = line.substring(spaceIdx + 1);
276
+ if (key === "identityfile") {
277
+ identityFiles.push(value);
278
+ } else {
279
+ all[key] = value;
280
+ }
281
+ }
282
+ }
283
+ return { all, identityFiles };
284
+ }
285
+
286
+ // src/env.ts
287
+ function runArgsWithEnv(cmd, args, extraEnv) {
288
+ const env = {};
289
+ for (const [k, v] of Object.entries(process.env)) {
290
+ if (typeof v === "string") env[k] = v;
291
+ }
292
+ for (const [k, v] of Object.entries(extraEnv)) {
293
+ if (v === void 0) {
294
+ delete env[k];
295
+ } else {
296
+ env[k] = v;
297
+ }
298
+ }
299
+ try {
300
+ const stdout = execFileSync2(cmd, args, {
301
+ env,
302
+ encoding: "utf8",
303
+ timeout: 1e4,
304
+ stdio: ["pipe", "pipe", "pipe"]
305
+ });
306
+ return { stdout: stdout.trim(), ok: true };
307
+ } catch (e) {
308
+ const err = e;
309
+ const so = err.stdout?.toString().trim() || "";
310
+ const se = err.stderr?.toString().trim() || "";
311
+ const output = [so, se].filter(Boolean).join("\n") || err.message || "";
312
+ return { stdout: output, ok: false };
313
+ }
314
+ }
262
315
  function probeAgent(socket, agentLabel) {
263
- const { stdout, ok } = runArgs("ssh-add", ["-l"]);
316
+ const isWindowsNamedPipe = socket.startsWith("\\\\.\\pipe\\");
317
+ const extraEnv = isWindowsNamedPipe ? { SSH_AUTH_SOCK: void 0 } : { SSH_AUTH_SOCK: socket };
318
+ const { stdout, ok } = runArgsWithEnv("ssh-add", ["-l"], extraEnv);
264
319
  const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
265
320
  if (!ok && !noIdentities) return null;
266
321
  const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
@@ -273,6 +328,7 @@ function probeAgent(socket, agentLabel) {
273
328
  message: keys.length > 0 ? `${agentLabel} running with ${keys.length} key(s) loaded` : `${agentLabel} running but no keys loaded. Use ssh_key_load to add one.`
274
329
  };
275
330
  }
331
+ var startedAgentPid = null;
276
332
  function ensureAgent() {
277
333
  const sock = process.env.SSH_AUTH_SOCK;
278
334
  if (sock) {
@@ -289,7 +345,10 @@ function ensureAgent() {
289
345
  const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
290
346
  if (sockMatch) {
291
347
  process.env.SSH_AUTH_SOCK = sockMatch[1];
292
- if (pidMatch) process.env.SSH_AGENT_PID = pidMatch[1];
348
+ if (pidMatch) {
349
+ process.env.SSH_AGENT_PID = pidMatch[1];
350
+ startedAgentPid = Number.parseInt(pidMatch[1], 10);
351
+ }
293
352
  return {
294
353
  running: true,
295
354
  reachable: true,
@@ -330,6 +389,13 @@ function detectKeyType(filePath, fileName) {
330
389
  if (content.includes("RSA PRIVATE KEY")) return "rsa";
331
390
  if (content.includes("EC PRIVATE KEY")) return "ecdsa";
332
391
  if (content.includes("DSA PRIVATE KEY")) return "dsa";
392
+ if (content.includes("OPENSSH PRIVATE KEY")) {
393
+ const { stdout, ok } = runArgs("ssh-keygen", ["-l", "-f", filePath]);
394
+ if (ok) {
395
+ const match = stdout.match(/\(([^)]+)\)\s*$/);
396
+ if (match) return match[1].toLowerCase();
397
+ }
398
+ }
333
399
  } catch {
334
400
  }
335
401
  return "unknown";
@@ -404,20 +470,7 @@ function configLookup(host) {
404
470
  if (!ok) {
405
471
  return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
406
472
  }
407
- const all = {};
408
- const identityFiles = [];
409
- for (const line of stdout.split("\n")) {
410
- const spaceIdx = line.indexOf(" ");
411
- if (spaceIdx > 0) {
412
- const key = line.substring(0, spaceIdx);
413
- const value = line.substring(spaceIdx + 1);
414
- if (key === "identityfile") {
415
- identityFiles.push(value);
416
- } else {
417
- all[key] = value;
418
- }
419
- }
420
- }
473
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
421
474
  return {
422
475
  hostname: all.hostname || host,
423
476
  user: all.user || "",
@@ -548,26 +601,13 @@ function resolveFromSshConfig(host) {
548
601
  try {
549
602
  const { stdout, ok } = runArgs("ssh", ["-G", host]);
550
603
  if (!ok) return null;
551
- const config = {};
552
- const identityFiles = [];
553
- for (const line of stdout.split("\n")) {
554
- const spaceIdx = line.indexOf(" ");
555
- if (spaceIdx > 0) {
556
- const key = line.substring(0, spaceIdx);
557
- const value = line.substring(spaceIdx + 1);
558
- if (key === "identityfile") {
559
- identityFiles.push(value);
560
- } else {
561
- config[key] = value;
562
- }
563
- }
564
- }
604
+ const { all, identityFiles } = parseSshConfigOutput(stdout);
565
605
  return {
566
- hostname: config.hostname || host,
567
- user: config.user || "",
568
- port: config.port || "22",
606
+ hostname: all.hostname || host,
607
+ user: all.user || "",
608
+ port: all.port || "22",
569
609
  identityFiles,
570
- proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
610
+ proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0
571
611
  };
572
612
  } catch {
573
613
  return null;
@@ -746,6 +786,7 @@ var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
746
786
  function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
747
787
  return new Promise((resolve, reject) => {
748
788
  let settled = false;
789
+ let activeStream = null;
749
790
  const settle = (fn) => {
750
791
  if (settled) return;
751
792
  settled = true;
@@ -753,6 +794,16 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
753
794
  fn();
754
795
  };
755
796
  const timer = setTimeout(() => {
797
+ if (activeStream) {
798
+ try {
799
+ activeStream.signal("TERM");
800
+ } catch {
801
+ }
802
+ try {
803
+ activeStream.close();
804
+ } catch {
805
+ }
806
+ }
756
807
  settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
757
808
  }, timeoutMs);
758
809
  client.exec(command, (err, stream) => {
@@ -760,6 +811,7 @@ function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTE
760
811
  settle(() => reject(err));
761
812
  return;
762
813
  }
814
+ activeStream = stream;
763
815
  const stdoutChunks = [];
764
816
  const stderrChunks = [];
765
817
  let stdoutBytes = 0;
@@ -935,7 +987,7 @@ async function find(client, options, timeoutMs = 3e4) {
935
987
  `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
936
988
  );
937
989
  }
938
- const args = [shellQuote(options.path)];
990
+ const args = ["--", shellQuote(options.path)];
939
991
  if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
940
992
  if (options.type) args.push("-type", options.type);
941
993
  if (options.name) args.push("-name", shellQuote(options.name));
@@ -950,9 +1002,9 @@ async function find(client, options, timeoutMs = 3e4) {
950
1002
  return result.stdout.split("\n").filter(Boolean);
951
1003
  }
952
1004
  async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
953
- let command = `tail -n ${lines} ${shellQuote(path)}`;
1005
+ let command = `tail -n ${lines} -- ${shellQuote(path)}`;
954
1006
  if (grep) {
955
- command += ` | grep -i ${shellQuote(grep)}`;
1007
+ command += ` | grep -i -e ${shellQuote(grep)}`;
956
1008
  }
957
1009
  const result = await exec(client, command, timeoutMs);
958
1010
  if (result.stderr.trim()) {
@@ -961,16 +1013,17 @@ async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
961
1013
  return result.stdout;
962
1014
  }
963
1015
  async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
964
- const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
1016
+ const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
965
1017
  const raw = result.stdout;
966
1018
  const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
967
1019
  const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
968
1020
  const pidMatch = raw.match(/Main PID:\s+(\d+)/);
969
1021
  const sinceMatch = raw.match(/since\s+(.+?);/);
1022
+ const fallbackStatus = result.code === 0 ? "active" : "inactive";
970
1023
  return {
971
1024
  name: serviceName,
972
1025
  active: activeMatch?.[1] === "active",
973
- status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : result.code === 0 ? "active" : "unknown",
1026
+ status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
974
1027
  description: descMatch?.[1]?.trim(),
975
1028
  since: sinceMatch?.[1]?.trim(),
976
1029
  pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
@@ -989,6 +1042,10 @@ var ConnectionPool = class {
989
1042
  // Total number of successful connects ever made by this pool. Useful for
990
1043
  // introspection and for tests that want to prove connection reuse.
991
1044
  _connectCount = 0;
1045
+ // Once drained, the pool stays drained — new acquires reject and any in-flight
1046
+ // factory closes the freshly-connected client instead of registering it.
1047
+ // Consumers must construct a new pool to use again.
1048
+ drained = false;
992
1049
  constructor(options) {
993
1050
  this.idleTtlMs = options?.idleTtlMs ?? 6e4;
994
1051
  this.maxPoolSize = options?.maxPoolSize ?? 100;
@@ -1000,6 +1057,9 @@ var ConnectionPool = class {
1000
1057
  const MAX_ACQUIRE_ATTEMPTS = 3;
1001
1058
  let lastErr;
1002
1059
  for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
1060
+ if (this.drained) {
1061
+ throw new Error("ConnectionPool was drained");
1062
+ }
1003
1063
  const existing = this.entries.get(key);
1004
1064
  if (existing && !existing.dead) {
1005
1065
  existing.refCount++;
@@ -1035,6 +1095,13 @@ var ConnectionPool = class {
1035
1095
  pending = (async () => {
1036
1096
  try {
1037
1097
  const client2 = await connectWithProxy(resolved);
1098
+ if (this.drained) {
1099
+ try {
1100
+ client2.end();
1101
+ } catch {
1102
+ }
1103
+ throw new Error("ConnectionPool was drained while connecting");
1104
+ }
1038
1105
  this._connectCount++;
1039
1106
  const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
1040
1107
  const markDead = () => {
@@ -1121,6 +1188,7 @@ ${diag}`);
1121
1188
  }
1122
1189
  }
1123
1190
  drain() {
1191
+ this.drained = true;
1124
1192
  for (const entry of this.entries.values()) {
1125
1193
  if (entry.idleTimer) {
1126
1194
  clearTimeout(entry.idleTimer);
@@ -1131,6 +1199,7 @@ ${diag}`);
1131
1199
  }
1132
1200
  }
1133
1201
  this.entries.clear();
1202
+ this.pending.clear();
1134
1203
  }
1135
1204
  get size() {
1136
1205
  return this.entries.size;
@@ -1525,10 +1594,12 @@ ${files.join("\n")}` }] };
1525
1594
  }
1526
1595
 
1527
1596
  // src/server.ts
1597
+ var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1598
+ var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
1528
1599
  function createServer(pool) {
1529
1600
  const server = new McpServer({
1530
1601
  name: "ssh-mcp",
1531
- version: "0.7.0"
1602
+ version
1532
1603
  });
1533
1604
  registerTools(server, pool);
1534
1605
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,14 +48,14 @@
48
48
  "dependencies": {
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
50
  "ssh2": "^1.16.0",
51
- "zod": "^3.24.4"
51
+ "zod": "^4.3.6"
52
52
  },
53
53
  "devDependencies": {
54
- "@biomejs/biome": "^1.9.4",
55
- "@types/node": "^22.15.2",
54
+ "@biomejs/biome": "^2.4.13",
55
+ "@types/node": "^25.6.0",
56
56
  "@types/ssh2": "^1.15.4",
57
57
  "tsup": "^8.5.1",
58
- "typescript": "^5.8.3",
59
- "vitest": "^3.2.4"
58
+ "typescript": "^6.0.3",
59
+ "vitest": "^4.1.5"
60
60
  }
61
61
  }