@yawlabs/ssh-mcp 0.11.5 → 0.13.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.
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Runtime launcher for @yawlabs/ssh-mcp.
4
+ *
5
+ * Prefers the oam runtime (https://oamjs.org) and falls back to the Node
6
+ * process already running this file.
7
+ *
8
+ *
9
+ * WHY THE FALLBACK COSTS NOTHING
10
+ * npm has already started Node to run this launcher, so falling back is a
11
+ * plain `import()` of the server into THIS process: no extra spawn, no extra
12
+ * startup, byte-identical to invoking dist/index.js directly. Discovery is
13
+ * stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
14
+ *
15
+ * WHAT THE OAM PATH COSTS
16
+ * Reaching oam through an npm `bin` means Node boots first and oam boots
17
+ * second, so the launcher is slower than either runtime alone. Measured on
18
+ * npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
19
+ * oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
20
+ * launcher is the slowest path -- it exists for `npx` convenience.
21
+ *
22
+ * For an MCP host config, point straight at oam and skip this file:
23
+ * { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
24
+ *
25
+ * SELECTION
26
+ * SSH_MCP_RUNTIME=oam require oam; fail loudly if it is missing
27
+ * SSH_MCP_RUNTIME=node never use oam
28
+ * SSH_MCP_RUNTIME=auto prefer oam, silently fall back (default)
29
+ * OAM_BIN=/path/to/oam explicit binary, checked before any discovery
30
+ */
31
+
32
+ import { spawn } from "node:child_process";
33
+ import { existsSync } from "node:fs";
34
+ import { constants, homedir } from "node:os";
35
+ import { delimiter, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ // Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
39
+ // with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
40
+ // in-process fallback must use the file:// URL. spawn() needs a real path.
41
+ const SERVER_URL = new URL("../dist/index.js", import.meta.url);
42
+ const SERVER_ENTRY = fileURLToPath(SERVER_URL);
43
+ const isWin = process.platform === "win32";
44
+ const exe = isWin ? "oam.exe" : "oam";
45
+
46
+ /** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
47
+ function findOam() {
48
+ // 1. Explicit override wins and is never second-guessed.
49
+ const override = process.env.OAM_BIN;
50
+ if (override) return existsSync(override) ? override : null;
51
+
52
+ // 2. Installed locations, BEFORE PATH. Someone who develops oam itself
53
+ // usually has oam/target/release on PATH, and a build directory is the
54
+ // wrong thing for a user-facing launcher to bind to: cargo replaces the
55
+ // binary underneath running processes, and the dev build is not the
56
+ // release the user installed. Preferring the installed copy makes the
57
+ // default path "what a normal user has", and OAM_BIN remains the way to
58
+ // point deliberately at a dev build.
59
+ //
60
+ // Both forms are checked on Windows: the installer defaults to
61
+ // %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
62
+ // OAM_INSTALL_DIR can pick either, so checking one silently misses a real
63
+ // install.
64
+ const installed = [join(homedir(), ".oam", "bin", exe)];
65
+ if (isWin) {
66
+ installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
67
+ }
68
+ for (const candidate of installed) {
69
+ if (existsSync(candidate)) return candidate;
70
+ }
71
+
72
+ // 3. PATH, resolved manually rather than by spawning `which`/`where`, which
73
+ // would cost a subprocess on every launch just to decide whether to spawn.
74
+ const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
75
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
76
+ if (!dir) continue;
77
+ for (const ext of isWin ? pathExt : [""]) {
78
+ const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
79
+ if (existsSync(candidate)) return candidate;
80
+ }
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ /** Run the server in THIS process. The zero-overhead fallback. */
87
+ async function runInProcess() {
88
+ // A server may gate its bootstrap on being the process ENTRY POINT --
89
+ // `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
90
+ // test file can import the module for unit tests without connecting a stdio
91
+ // transport. aws-mcp does exactly this. Importing the server here would leave
92
+ // argv[1] pointing at THIS launcher, the guard would read false, and the
93
+ // server would load but never serve: the MCP handshake just hangs.
94
+ //
95
+ // Point argv[1] at the server first, so the in-process path is
96
+ // indistinguishable from having executed the file directly. The spawn path
97
+ // needs no equivalent -- there argv[1] is already the server.
98
+ process.argv[1] = SERVER_ENTRY;
99
+ await import(SERVER_URL.href);
100
+ }
101
+
102
+ const mode = (process.env.SSH_MCP_RUNTIME ?? "auto").toLowerCase();
103
+
104
+ if (mode === "node") {
105
+ await runInProcess();
106
+ } else {
107
+ const oam = findOam();
108
+
109
+ if (!oam) {
110
+ if (mode === "oam") {
111
+ // Explicitly demanded, so this is a real misconfiguration. writeSync
112
+ // because stderr is async for TTYs/pipes on Windows and process.exit
113
+ // truncates pending writes.
114
+ const { writeSync } = await import("node:fs");
115
+ writeSync(
116
+ 2,
117
+ "ssh-mcp: SSH_MCP_RUNTIME=oam but no oam binary was found.\n" +
118
+ "Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use SSH_MCP_RUNTIME=node.\n",
119
+ );
120
+ process.exit(1);
121
+ }
122
+ await runInProcess();
123
+ } else {
124
+ // `--` separates oam's own flags from the script's argv, so `ssh-mcp
125
+ // --version` and any host-supplied flags survive the hop unchanged.
126
+ const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
127
+ // inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
128
+ // stdin/stdout is untouched and the host's stdin-close still reaches the
129
+ // server's shutdown path.
130
+ stdio: "inherit",
131
+ env: process.env,
132
+ windowsHide: true,
133
+ });
134
+
135
+ // If oam cannot be executed at all (deleted between the stat and the spawn,
136
+ // wrong arch, permission), fall back rather than failing the whole server.
137
+ // `spawned` prevents falling back AFTER the child started, which would
138
+ // double-start the server on the same stdio.
139
+ let spawned = false;
140
+ child.on("spawn", () => {
141
+ spawned = true;
142
+ });
143
+ child.on("error", (err) => {
144
+ if (spawned) return;
145
+ if (mode === "oam") {
146
+ process.stderr.write(`ssh-mcp: failed to launch oam (${err.message})\n`);
147
+ process.exit(1);
148
+ }
149
+ void runInProcess();
150
+ });
151
+
152
+ // Forward termination so the server's own shutdown path runs in the child
153
+ // rather than the child being orphaned. No-op on Windows, harmless to add.
154
+ for (const sig of ["SIGINT", "SIGTERM"]) {
155
+ process.on(sig, () => {
156
+ if (!child.killed) child.kill(sig);
157
+ });
158
+ }
159
+
160
+ child.on("exit", (code, signal) => {
161
+ // Mirror the child's fate: a signal death becomes 128+n so callers see a
162
+ // conventional shell exit status rather than a bare 0.
163
+ if (signal) {
164
+ process.exit(128 + (constants.signals[signal] ?? 15));
165
+ }
166
+ process.exit(code ?? 0);
167
+ });
168
+ }
169
+ }
package/dist/index.js CHANGED
@@ -214,8 +214,6 @@ function checkSshConfig(host) {
214
214
  if (inHostBlock) hostConfig.push(trimmed);
215
215
  } else if (inHostBlock && trimmed) {
216
216
  hostConfig.push(trimmed);
217
- } else if (inHostBlock && !trimmed) {
218
- inHostBlock = false;
219
217
  }
220
218
  }
221
219
  if (hostConfig.length === 0) {
@@ -615,7 +613,15 @@ import { readFileSync as readFileSync3 } from "fs";
615
613
  import { homedir as homedir3 } from "os";
616
614
  import { join as join3 } from "path";
617
615
  import { Client } from "ssh2";
616
+ var sshConfigCache = /* @__PURE__ */ new Map();
618
617
  function resolveFromSshConfig(host) {
618
+ const cached = sshConfigCache.get(host);
619
+ if (cached !== void 0) return cached;
620
+ const result = resolveFromSshConfigUncached(host);
621
+ sshConfigCache.set(host, result);
622
+ return result;
623
+ }
624
+ function resolveFromSshConfigUncached(host) {
619
625
  try {
620
626
  const { stdout, ok } = runArgs("ssh", ["-G", host]);
621
627
  if (!ok) return null;
@@ -695,8 +701,10 @@ function resolveConfig(config) {
695
701
  keepaliveCountMax: 3,
696
702
  hostVerifier: buildHostVerifier(verifierHosts, port)
697
703
  };
704
+ const home = homedir3();
698
705
  if (config.privateKeyPath) {
699
- connectConfig.privateKey = readFileSync3(config.privateKeyPath);
706
+ const keyPath = config.privateKeyPath.startsWith("~") ? join3(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
707
+ connectConfig.privateKey = readFileSync3(keyPath);
700
708
  } else if (config.password) {
701
709
  connectConfig.password = config.password;
702
710
  } else {
@@ -704,7 +712,6 @@ function resolveConfig(config) {
704
712
  if (agentSock) {
705
713
  connectConfig.agent = agentSock;
706
714
  }
707
- const home = homedir3();
708
715
  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")];
709
716
  for (const keyPath of keyPaths) {
710
717
  try {
@@ -941,10 +948,11 @@ async function writeFile(client, remotePath, content) {
941
948
  }
942
949
  }
943
950
  async function uploadFile(client, localPath, remotePath) {
951
+ const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
944
952
  const sftp = await getSftp(client);
945
953
  try {
946
954
  await new Promise((resolve, reject) => {
947
- sftp.fastPut(localPath, remotePath, (err) => {
955
+ sftp.fastPut(resolvedLocal, remotePath, (err) => {
948
956
  if (err) return reject(err);
949
957
  resolve();
950
958
  });
@@ -954,10 +962,11 @@ async function uploadFile(client, localPath, remotePath) {
954
962
  }
955
963
  }
956
964
  async function downloadFile(client, remotePath, localPath) {
965
+ const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
957
966
  const sftp = await getSftp(client);
958
967
  try {
959
968
  await new Promise((resolve, reject) => {
960
- sftp.fastGet(remotePath, localPath, (err) => {
969
+ sftp.fastGet(remotePath, resolvedLocal, (err) => {
961
970
  if (err) return reject(err);
962
971
  resolve();
963
972
  });
@@ -1296,16 +1305,16 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1296
1305
  };
1297
1306
  });
1298
1307
  }
1299
- var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
1308
+ var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1300
1309
  async function find(client, options, timeoutMs = 3e4) {
1301
1310
  if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1302
1311
  throw new Error(
1303
- `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
1312
+ `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
1304
1313
  );
1305
1314
  }
1306
1315
  if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
1307
1316
  throw new Error(
1308
- `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
1317
+ `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1309
1318
  );
1310
1319
  }
1311
1320
  const args = ["--", shellQuote(options.path)];
@@ -1386,6 +1395,9 @@ function enforcePolicy(command) {
1386
1395
 
1387
1396
  // src/tools.ts
1388
1397
  var HostSchema = z.string().describe("SSH hostname or IP address");
1398
+ var AbsoluteRemotePathSchema = z.string().refine((p) => p.startsWith("/"), {
1399
+ message: "Path must be absolute (start with /). SFTP does not expand ~ or resolve relative paths through a shell."
1400
+ });
1389
1401
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
1390
1402
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
1391
1403
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
@@ -1453,7 +1465,7 @@ ${result.stderr}`);
1453
1465
  "Write content to a file on a remote host via SFTP. Creates or overwrites the file.",
1454
1466
  {
1455
1467
  ...connectionParams,
1456
- path: z.string().describe("Absolute path to the remote file"),
1468
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1457
1469
  content: z.string().describe("File content to write")
1458
1470
  },
1459
1471
  async ({ path, content, ...conn }) => {
@@ -1469,7 +1481,7 @@ ${result.stderr}`);
1469
1481
  {
1470
1482
  ...connectionParams,
1471
1483
  localPath: z.string().describe("Path to the local file to upload"),
1472
- remotePath: z.string().describe("Absolute path on the remote host")
1484
+ remotePath: AbsoluteRemotePathSchema.describe("Absolute path on the remote host. Must start with /.")
1473
1485
  },
1474
1486
  async ({ localPath, remotePath, ...conn }) => {
1475
1487
  return connectionPool.withConnection(conn, async (client) => {
@@ -1483,7 +1495,7 @@ ${result.stderr}`);
1483
1495
  "Download a file from a remote host to local filesystem via SFTP.",
1484
1496
  {
1485
1497
  ...connectionParams,
1486
- remotePath: z.string().describe("Absolute path to the remote file"),
1498
+ remotePath: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1487
1499
  localPath: z.string().describe("Local path to save the downloaded file")
1488
1500
  },
1489
1501
  async ({ remotePath, localPath, ...conn }) => {
@@ -1498,7 +1510,7 @@ ${result.stderr}`);
1498
1510
  "List files in a directory on a remote host via SFTP.",
1499
1511
  {
1500
1512
  ...connectionParams,
1501
- path: z.string().describe("Absolute path to the remote directory")
1513
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
1502
1514
  },
1503
1515
  async ({ path, ...conn }) => {
1504
1516
  return connectionPool.withConnection(conn, async (client) => {
@@ -1512,7 +1524,7 @@ ${result.stderr}`);
1512
1524
  "Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and type flags (isFile, isDirectory, isSymbolicLink). Use this instead of parsing `ls -la` output.",
1513
1525
  {
1514
1526
  ...connectionParams,
1515
- path: z.string().describe("Absolute path to the remote file or directory")
1527
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
1516
1528
  },
1517
1529
  async ({ path, ...conn }) => {
1518
1530
  return connectionPool.withConnection(conn, async (client) => {
@@ -1549,7 +1561,9 @@ ${result.stderr}`);
1549
1561
  "Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with `rm -rf` explicitly so the destructive intent is visible in the tool trace.",
1550
1562
  {
1551
1563
  ...connectionParams,
1552
- path: z.string().describe("Absolute path of the file or empty directory to delete")
1564
+ path: AbsoluteRemotePathSchema.describe(
1565
+ "Absolute path of the file or empty directory to delete. Must start with /."
1566
+ )
1553
1567
  },
1554
1568
  async ({ path, ...conn }) => {
1555
1569
  return connectionPool.withConnection(conn, async (client) => {
@@ -1824,8 +1838,7 @@ ${files.join("\n")}` }] };
1824
1838
  }
1825
1839
 
1826
1840
  // src/server.ts
1827
- var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1828
- var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
1841
+ var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
1829
1842
  function createServer(pool) {
1830
1843
  const server = new McpServer({
1831
1844
  name: "ssh-mcp",
@@ -1836,6 +1849,10 @@ function createServer(pool) {
1836
1849
  }
1837
1850
 
1838
1851
  // src/index.ts
1852
+ if (process.argv[2] === "--version" || process.argv[2] === "version") {
1853
+ console.log(version);
1854
+ process.exit(0);
1855
+ }
1839
1856
  async function main() {
1840
1857
  const pool = new ConnectionPool();
1841
1858
  const server = createServer(pool);
package/dist/server.d.ts CHANGED
@@ -217,6 +217,7 @@ declare function isPolicyConfigured(): boolean;
217
217
 
218
218
  declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
219
219
 
220
+ declare const version: string;
220
221
  declare function createServer(pool?: ConnectionPool): McpServer;
221
222
 
222
- export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FileStats, 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, deleteFile, diagnose, downloadFile, enforcePolicy, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, isPolicyConfigured, listDir, listSshKeys, loadKey, makeDir, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, statFile, tail, testConnection, uploadFile, writeFile };
223
+ export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FileStats, 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, deleteFile, diagnose, downloadFile, enforcePolicy, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, isPolicyConfigured, listDir, listSshKeys, loadKey, makeDir, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, statFile, tail, testConnection, uploadFile, version, writeFile };
package/dist/server.js CHANGED
@@ -212,8 +212,6 @@ function checkSshConfig(host) {
212
212
  if (inHostBlock) hostConfig.push(trimmed);
213
213
  } else if (inHostBlock && trimmed) {
214
214
  hostConfig.push(trimmed);
215
- } else if (inHostBlock && !trimmed) {
216
- inHostBlock = false;
217
215
  }
218
216
  }
219
217
  if (hostConfig.length === 0) {
@@ -608,7 +606,15 @@ import { readFileSync as readFileSync3 } from "fs";
608
606
  import { homedir as homedir3 } from "os";
609
607
  import { join as join3 } from "path";
610
608
  import { Client } from "ssh2";
609
+ var sshConfigCache = /* @__PURE__ */ new Map();
611
610
  function resolveFromSshConfig(host) {
611
+ const cached = sshConfigCache.get(host);
612
+ if (cached !== void 0) return cached;
613
+ const result = resolveFromSshConfigUncached(host);
614
+ sshConfigCache.set(host, result);
615
+ return result;
616
+ }
617
+ function resolveFromSshConfigUncached(host) {
612
618
  try {
613
619
  const { stdout, ok } = runArgs("ssh", ["-G", host]);
614
620
  if (!ok) return null;
@@ -688,8 +694,10 @@ function resolveConfig(config) {
688
694
  keepaliveCountMax: 3,
689
695
  hostVerifier: buildHostVerifier(verifierHosts, port)
690
696
  };
697
+ const home = homedir3();
691
698
  if (config.privateKeyPath) {
692
- connectConfig.privateKey = readFileSync3(config.privateKeyPath);
699
+ const keyPath = config.privateKeyPath.startsWith("~") ? join3(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
700
+ connectConfig.privateKey = readFileSync3(keyPath);
693
701
  } else if (config.password) {
694
702
  connectConfig.password = config.password;
695
703
  } else {
@@ -697,7 +705,6 @@ function resolveConfig(config) {
697
705
  if (agentSock) {
698
706
  connectConfig.agent = agentSock;
699
707
  }
700
- const home = homedir3();
701
708
  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")];
702
709
  for (const keyPath of keyPaths) {
703
710
  try {
@@ -952,10 +959,11 @@ async function writeFile(client, remotePath, content) {
952
959
  }
953
960
  }
954
961
  async function uploadFile(client, localPath, remotePath) {
962
+ const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
955
963
  const sftp = await getSftp(client);
956
964
  try {
957
965
  await new Promise((resolve, reject) => {
958
- sftp.fastPut(localPath, remotePath, (err) => {
966
+ sftp.fastPut(resolvedLocal, remotePath, (err) => {
959
967
  if (err) return reject(err);
960
968
  resolve();
961
969
  });
@@ -965,10 +973,11 @@ async function uploadFile(client, localPath, remotePath) {
965
973
  }
966
974
  }
967
975
  async function downloadFile(client, remotePath, localPath) {
976
+ const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
968
977
  const sftp = await getSftp(client);
969
978
  try {
970
979
  await new Promise((resolve, reject) => {
971
- sftp.fastGet(remotePath, localPath, (err) => {
980
+ sftp.fastGet(remotePath, resolvedLocal, (err) => {
972
981
  if (err) return reject(err);
973
982
  resolve();
974
983
  });
@@ -1088,16 +1097,16 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
1088
1097
  };
1089
1098
  });
1090
1099
  }
1091
- var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
1100
+ var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
1092
1101
  async function find(client, options, timeoutMs = 3e4) {
1093
1102
  if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
1094
1103
  throw new Error(
1095
- `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
1104
+ `Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
1096
1105
  );
1097
1106
  }
1098
1107
  if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
1099
1108
  throw new Error(
1100
- `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
1109
+ `Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
1101
1110
  );
1102
1111
  }
1103
1112
  const args = ["--", shellQuote(options.path)];
@@ -1392,6 +1401,9 @@ ${diag}`);
1392
1401
 
1393
1402
  // src/tools.ts
1394
1403
  var HostSchema = z.string().describe("SSH hostname or IP address");
1404
+ var AbsoluteRemotePathSchema = z.string().refine((p) => p.startsWith("/"), {
1405
+ message: "Path must be absolute (start with /). SFTP does not expand ~ or resolve relative paths through a shell."
1406
+ });
1395
1407
  var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
1396
1408
  var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
1397
1409
  var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
@@ -1459,7 +1471,7 @@ ${result.stderr}`);
1459
1471
  "Write content to a file on a remote host via SFTP. Creates or overwrites the file.",
1460
1472
  {
1461
1473
  ...connectionParams,
1462
- path: z.string().describe("Absolute path to the remote file"),
1474
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1463
1475
  content: z.string().describe("File content to write")
1464
1476
  },
1465
1477
  async ({ path, content, ...conn }) => {
@@ -1475,7 +1487,7 @@ ${result.stderr}`);
1475
1487
  {
1476
1488
  ...connectionParams,
1477
1489
  localPath: z.string().describe("Path to the local file to upload"),
1478
- remotePath: z.string().describe("Absolute path on the remote host")
1490
+ remotePath: AbsoluteRemotePathSchema.describe("Absolute path on the remote host. Must start with /.")
1479
1491
  },
1480
1492
  async ({ localPath, remotePath, ...conn }) => {
1481
1493
  return connectionPool.withConnection(conn, async (client) => {
@@ -1489,7 +1501,7 @@ ${result.stderr}`);
1489
1501
  "Download a file from a remote host to local filesystem via SFTP.",
1490
1502
  {
1491
1503
  ...connectionParams,
1492
- remotePath: z.string().describe("Absolute path to the remote file"),
1504
+ remotePath: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
1493
1505
  localPath: z.string().describe("Local path to save the downloaded file")
1494
1506
  },
1495
1507
  async ({ remotePath, localPath, ...conn }) => {
@@ -1504,7 +1516,7 @@ ${result.stderr}`);
1504
1516
  "List files in a directory on a remote host via SFTP.",
1505
1517
  {
1506
1518
  ...connectionParams,
1507
- path: z.string().describe("Absolute path to the remote directory")
1519
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
1508
1520
  },
1509
1521
  async ({ path, ...conn }) => {
1510
1522
  return connectionPool.withConnection(conn, async (client) => {
@@ -1518,7 +1530,7 @@ ${result.stderr}`);
1518
1530
  "Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and type flags (isFile, isDirectory, isSymbolicLink). Use this instead of parsing `ls -la` output.",
1519
1531
  {
1520
1532
  ...connectionParams,
1521
- path: z.string().describe("Absolute path to the remote file or directory")
1533
+ path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
1522
1534
  },
1523
1535
  async ({ path, ...conn }) => {
1524
1536
  return connectionPool.withConnection(conn, async (client) => {
@@ -1555,7 +1567,9 @@ ${result.stderr}`);
1555
1567
  "Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with `rm -rf` explicitly so the destructive intent is visible in the tool trace.",
1556
1568
  {
1557
1569
  ...connectionParams,
1558
- path: z.string().describe("Absolute path of the file or empty directory to delete")
1570
+ path: AbsoluteRemotePathSchema.describe(
1571
+ "Absolute path of the file or empty directory to delete. Must start with /."
1572
+ )
1559
1573
  },
1560
1574
  async ({ path, ...conn }) => {
1561
1575
  return connectionPool.withConnection(conn, async (client) => {
@@ -1830,8 +1844,7 @@ ${files.join("\n")}` }] };
1830
1844
  }
1831
1845
 
1832
1846
  // src/server.ts
1833
- var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1834
- var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
1847
+ var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
1835
1848
  function createServer(pool) {
1836
1849
  const server = new McpServer({
1837
1850
  name: "ssh-mcp",
@@ -1877,5 +1890,6 @@ export {
1877
1890
  tail,
1878
1891
  testConnection,
1879
1892
  uploadFile,
1893
+ version,
1880
1894
  writeFile
1881
1895
  };
package/package.json CHANGED
@@ -1,62 +1,68 @@
1
- {
2
- "name": "@yawlabs/ssh-mcp",
3
- "version": "0.11.5",
4
- "mcpName": "io.github.YawLabs/ssh-mcp",
5
- "description": "MCP server for SSH operations with built-in diagnostics",
6
- "type": "module",
7
- "bin": {
8
- "ssh-mcp": "dist/index.js"
9
- },
10
- "exports": {
11
- ".": {
12
- "import": "./dist/server.js",
13
- "types": "./dist/server.d.ts"
14
- }
15
- },
16
- "files": [
17
- "dist",
18
- "LICENSE",
19
- "README.md"
20
- ],
21
- "scripts": {
22
- "build": "tsup",
23
- "dev": "tsup --watch",
24
- "lint": "biome check src/",
25
- "lint:fix": "biome check --write src/",
26
- "typecheck": "tsc --noEmit",
27
- "test": "vitest run",
28
- "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",
29
- "test:ci": "npm run build && npm test",
30
- "prepublishOnly": "npm run build"
31
- },
32
- "keywords": [
33
- "mcp",
34
- "ssh",
35
- "remote",
36
- "model-context-protocol",
37
- "ai",
38
- "devops"
39
- ],
40
- "author": "Yaw Labs <contact@yaw.sh>",
41
- "license": "MIT",
42
- "repository": {
43
- "type": "git",
44
- "url": "git+https://github.com/YawLabs/ssh-mcp.git"
45
- },
46
- "engines": {
47
- "node": ">=18"
48
- },
49
- "dependencies": {
50
- "@modelcontextprotocol/sdk": "^1.29.0",
51
- "ssh2": "^1.17.0",
52
- "zod": "^4.4.3"
53
- },
54
- "devDependencies": {
55
- "@biomejs/biome": "^2.4.15",
56
- "@types/node": "^25.7.0",
57
- "@types/ssh2": "^1.15.5",
58
- "tsup": "^8.5.1",
59
- "typescript": "^6.0.3",
60
- "vitest": "^4.1.6"
61
- }
62
- }
1
+ {
2
+ "name": "@yawlabs/ssh-mcp",
3
+ "version": "0.13.0",
4
+ "mcpName": "io.github.YawLabs/ssh-mcp",
5
+ "description": "MCP server for SSH operations with built-in diagnostics",
6
+ "type": "module",
7
+ "bin": {
8
+ "ssh-mcp": "bin/ssh-mcp.mjs"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/server.js",
13
+ "types": "./dist/server.d.ts"
14
+ }
15
+ },
16
+ "files": [
17
+ "bin/ssh-mcp.mjs",
18
+ "dist",
19
+ "LICENSE",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "dev": "tsup --watch",
25
+ "lint": "biome check src/",
26
+ "lint:fix": "biome check --write src/",
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "vitest run",
29
+ "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",
30
+ "test:ci": "npm run build && npm test",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "keywords": [
34
+ "mcp",
35
+ "ssh",
36
+ "remote",
37
+ "model-context-protocol",
38
+ "ai",
39
+ "devops"
40
+ ],
41
+ "author": "Yaw Labs <contact@yaw.sh>",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/YawLabs/ssh-mcp.git"
46
+ },
47
+ "engines": {
48
+ "node": ">=18"
49
+ },
50
+ "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.29.0",
52
+ "ssh2": "^1.17.0",
53
+ "zod": "^4.4.3"
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "^2.4.15",
57
+ "@types/node": "^26.1.1",
58
+ "@types/ssh2": "^1.15.5",
59
+ "esbuild": "^0.28.1",
60
+ "postject": "^1.0.0-alpha.6",
61
+ "tsup": "^8.5.1",
62
+ "typescript": "^7.0.2",
63
+ "vitest": "^4.1.6"
64
+ },
65
+ "overrides": {
66
+ "esbuild": "^0.28.1"
67
+ }
68
+ }