@yawlabs/ssh-mcp 0.10.0 → 0.11.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
@@ -66,12 +66,15 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
66
66
 
67
67
  | Tool | Description |
68
68
  |------|-------------|
69
- | `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code (or `[signal: NAME]` and `code: -1` when the channel closed signal-only). Subject to [command policy](#command-policy) if configured. |
69
+ | `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code (or `[signal: NAME]` and `code: -1` when the channel closed signal-only). Optional `env` param sets per-call environment variables (POSIX-safe prefix, works regardless of sshd's `AcceptEnv`). Subject to [command policy](#command-policy) if configured. |
70
70
  | `ssh_read_file` | Read a file from a remote host via SFTP. |
71
71
  | `ssh_write_file` | Write content to a file on a remote host via SFTP. |
72
72
  | `ssh_upload` | Upload a local file to a remote host via SFTP. |
73
73
  | `ssh_download` | Download a file from a remote host to local filesystem. |
74
74
  | `ssh_ls` | List files in a directory on a remote host. |
75
+ | `ssh_stat` | Get metadata for a file or directory (size, mode in octal, uid/gid, mtime/atime, isFile/isDirectory/isSymbolicLink). Use instead of parsing `ls -la`. |
76
+ | `ssh_mkdir` | Create a directory via SFTP. Set `recursive: true` for `mkdir -p` behavior. |
77
+ | `ssh_delete` | Delete a file or empty directory via SFTP. Auto-dispatches unlink vs rmdir based on the path's type. Recursive directory delete is intentionally NOT supported -- use `ssh_exec rm -rf` if you need it. |
75
78
 
76
79
  ### Higher-level operations
77
80
 
package/dist/index.js CHANGED
@@ -953,6 +953,77 @@ async function listDir(client, remotePath) {
953
953
  sftp.end();
954
954
  }
955
955
  }
956
+ async function statFile(client, remotePath) {
957
+ const sftp = await getSftp(client);
958
+ try {
959
+ return await new Promise((resolve, reject) => {
960
+ sftp.stat(remotePath, (err, stats) => {
961
+ if (err) return reject(err);
962
+ resolve({
963
+ size: stats.size,
964
+ mode: stats.mode,
965
+ modeOctal: (stats.mode & 4095).toString(8).padStart(4, "0"),
966
+ uid: stats.uid,
967
+ gid: stats.gid,
968
+ mtime: stats.mtime,
969
+ atime: stats.atime,
970
+ isFile: stats.isFile(),
971
+ isDirectory: stats.isDirectory(),
972
+ isSymbolicLink: stats.isSymbolicLink()
973
+ });
974
+ });
975
+ });
976
+ } finally {
977
+ sftp.end();
978
+ }
979
+ }
980
+ async function deleteFile(client, remotePath) {
981
+ const sftp = await getSftp(client);
982
+ try {
983
+ const stats = await new Promise((resolve, reject) => {
984
+ sftp.stat(remotePath, (err, stats2) => {
985
+ if (err) return reject(err);
986
+ resolve(stats2);
987
+ });
988
+ });
989
+ await new Promise((resolve, reject) => {
990
+ const done = (err) => err ? reject(err) : resolve();
991
+ if (stats.isDirectory()) {
992
+ sftp.rmdir(remotePath, done);
993
+ } else {
994
+ sftp.unlink(remotePath, done);
995
+ }
996
+ });
997
+ } finally {
998
+ sftp.end();
999
+ }
1000
+ }
1001
+ async function makeDir(client, remotePath, recursive = false) {
1002
+ const sftp = await getSftp(client);
1003
+ try {
1004
+ const mkOne = (path) => new Promise((resolve, reject) => {
1005
+ sftp.mkdir(path, (err) => err ? reject(err) : resolve());
1006
+ });
1007
+ if (!recursive) {
1008
+ await mkOne(remotePath);
1009
+ return;
1010
+ }
1011
+ const isAbsolute = remotePath.startsWith("/");
1012
+ const parts = remotePath.split("/").filter(Boolean);
1013
+ let cur = isAbsolute ? "" : ".";
1014
+ for (let i = 0; i < parts.length; i++) {
1015
+ cur = isAbsolute ? `${cur}/${parts[i]}` : `${cur}/${parts[i]}`;
1016
+ const isLeaf = i === parts.length - 1;
1017
+ try {
1018
+ await mkOne(cur);
1019
+ } catch (e) {
1020
+ if (isLeaf) throw e;
1021
+ }
1022
+ }
1023
+ } finally {
1024
+ sftp.end();
1025
+ }
1026
+ }
956
1027
 
957
1028
  // src/pool.ts
958
1029
  function defaultMaxPoolSize() {
@@ -1291,16 +1362,24 @@ function registerTools(server, pool) {
1291
1362
  const connectionPool = pool ?? new ConnectionPool();
1292
1363
  server.tool(
1293
1364
  "ssh_exec",
1294
- "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured.",
1365
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
1295
1366
  {
1296
1367
  ...connectionParams,
1297
1368
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1369
+ env: z.record(z.string(), z.string()).optional().describe(
1370
+ "Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. Values are POSIX-single-quoted, so any byte is safe."
1371
+ ),
1298
1372
  timeout: TimeoutSchema
1299
1373
  },
1300
- async ({ command, timeout, ...conn }) => {
1301
- enforcePolicy(command);
1374
+ async ({ command, env, timeout, ...conn }) => {
1375
+ let finalCommand = command;
1376
+ if (env && Object.keys(env).length > 0) {
1377
+ const prefix = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ");
1378
+ finalCommand = `${prefix} ${command}`;
1379
+ }
1380
+ enforcePolicy(finalCommand);
1302
1381
  return connectionPool.withConnection(conn, async (client) => {
1303
- const result = await exec(client, command, timeout || 3e4);
1382
+ const result = await exec(client, finalCommand, timeout || 3e4);
1304
1383
  const parts = [];
1305
1384
  if (result.stdout) parts.push(result.stdout);
1306
1385
  if (result.stderr) parts.push(`[stderr]
@@ -1384,6 +1463,57 @@ ${result.stderr}`);
1384
1463
  });
1385
1464
  }
1386
1465
  );
1466
+ server.tool(
1467
+ "ssh_stat",
1468
+ "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.",
1469
+ {
1470
+ ...connectionParams,
1471
+ path: z.string().describe("Absolute path to the remote file or directory")
1472
+ },
1473
+ async ({ path, ...conn }) => {
1474
+ return connectionPool.withConnection(conn, async (client) => {
1475
+ const stats = await statFile(client, path);
1476
+ const lines = [];
1477
+ const kind = stats.isDirectory ? "directory" : stats.isSymbolicLink ? "symlink" : stats.isFile ? "file" : "other";
1478
+ lines.push(`${path}: ${kind}`);
1479
+ lines.push(` Size: ${stats.size} bytes`);
1480
+ lines.push(` Mode: ${stats.modeOctal}`);
1481
+ lines.push(` Owner: uid=${stats.uid} gid=${stats.gid}`);
1482
+ lines.push(` Modified: ${new Date(stats.mtime * 1e3).toISOString()}`);
1483
+ lines.push(` Accessed: ${new Date(stats.atime * 1e3).toISOString()}`);
1484
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1485
+ });
1486
+ }
1487
+ );
1488
+ server.tool(
1489
+ "ssh_mkdir",
1490
+ "Create a directory on a remote host via SFTP. Set `recursive: true` to create parent directories as needed (like `mkdir -p`). Existing intermediate dirs are tolerated; an existing leaf path is still an error.",
1491
+ {
1492
+ ...connectionParams,
1493
+ path: z.string().describe("Absolute path of the directory to create"),
1494
+ recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
1495
+ },
1496
+ async ({ path, recursive, ...conn }) => {
1497
+ return connectionPool.withConnection(conn, async (client) => {
1498
+ await makeDir(client, path, recursive ?? false);
1499
+ return { content: [{ type: "text", text: `Created directory ${path}` }] };
1500
+ });
1501
+ }
1502
+ );
1503
+ server.tool(
1504
+ "ssh_delete",
1505
+ "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.",
1506
+ {
1507
+ ...connectionParams,
1508
+ path: z.string().describe("Absolute path of the file or empty directory to delete")
1509
+ },
1510
+ async ({ path, ...conn }) => {
1511
+ return connectionPool.withConnection(conn, async (client) => {
1512
+ await deleteFile(client, path);
1513
+ return { content: [{ type: "text", text: `Deleted ${path}` }] };
1514
+ });
1515
+ }
1516
+ );
1387
1517
  server.tool(
1388
1518
  "ssh_diagnose",
1389
1519
  "Diagnose SSH connectivity issues. Checks ssh-agent status, loaded keys, known_hosts, SSH config, and attempts a test connection. Use this BEFORE attempting SSH operations if you suspect connectivity issues, or AFTER a failed SSH operation to understand why it failed.",
package/dist/server.d.ts CHANGED
@@ -36,6 +36,25 @@ declare function writeFile(client: Client, remotePath: string, content: string):
36
36
  declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
37
37
  declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
38
38
  declare function listDir(client: Client, remotePath: string): Promise<string[]>;
39
+ interface FileStats {
40
+ size: number;
41
+ /** POSIX mode as a decimal number. Use modeOctal for the human-readable form. */
42
+ mode: number;
43
+ /** POSIX mode formatted as a 4-digit octal string (e.g. "0755"). */
44
+ modeOctal: string;
45
+ uid: number;
46
+ gid: number;
47
+ /** Unix timestamp (seconds since epoch) of last modification. */
48
+ mtime: number;
49
+ /** Unix timestamp (seconds since epoch) of last access. */
50
+ atime: number;
51
+ isFile: boolean;
52
+ isDirectory: boolean;
53
+ isSymbolicLink: boolean;
54
+ }
55
+ declare function statFile(client: Client, remotePath: string): Promise<FileStats>;
56
+ declare function deleteFile(client: Client, remotePath: string): Promise<void>;
57
+ declare function makeDir(client: Client, remotePath: string, recursive?: boolean): Promise<void>;
39
58
 
40
59
  interface PoolOptions {
41
60
  /** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
@@ -200,4 +219,4 @@ declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
200
219
 
201
220
  declare function createServer(pool?: ConnectionPool): McpServer;
202
221
 
203
- 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, enforcePolicy, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, isPolicyConfigured, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
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 };
package/dist/server.js CHANGED
@@ -967,6 +967,77 @@ async function listDir(client, remotePath) {
967
967
  sftp.end();
968
968
  }
969
969
  }
970
+ async function statFile(client, remotePath) {
971
+ const sftp = await getSftp(client);
972
+ try {
973
+ return await new Promise((resolve, reject) => {
974
+ sftp.stat(remotePath, (err, stats) => {
975
+ if (err) return reject(err);
976
+ resolve({
977
+ size: stats.size,
978
+ mode: stats.mode,
979
+ modeOctal: (stats.mode & 4095).toString(8).padStart(4, "0"),
980
+ uid: stats.uid,
981
+ gid: stats.gid,
982
+ mtime: stats.mtime,
983
+ atime: stats.atime,
984
+ isFile: stats.isFile(),
985
+ isDirectory: stats.isDirectory(),
986
+ isSymbolicLink: stats.isSymbolicLink()
987
+ });
988
+ });
989
+ });
990
+ } finally {
991
+ sftp.end();
992
+ }
993
+ }
994
+ async function deleteFile(client, remotePath) {
995
+ const sftp = await getSftp(client);
996
+ try {
997
+ const stats = await new Promise((resolve, reject) => {
998
+ sftp.stat(remotePath, (err, stats2) => {
999
+ if (err) return reject(err);
1000
+ resolve(stats2);
1001
+ });
1002
+ });
1003
+ await new Promise((resolve, reject) => {
1004
+ const done = (err) => err ? reject(err) : resolve();
1005
+ if (stats.isDirectory()) {
1006
+ sftp.rmdir(remotePath, done);
1007
+ } else {
1008
+ sftp.unlink(remotePath, done);
1009
+ }
1010
+ });
1011
+ } finally {
1012
+ sftp.end();
1013
+ }
1014
+ }
1015
+ async function makeDir(client, remotePath, recursive = false) {
1016
+ const sftp = await getSftp(client);
1017
+ try {
1018
+ const mkOne = (path) => new Promise((resolve, reject) => {
1019
+ sftp.mkdir(path, (err) => err ? reject(err) : resolve());
1020
+ });
1021
+ if (!recursive) {
1022
+ await mkOne(remotePath);
1023
+ return;
1024
+ }
1025
+ const isAbsolute = remotePath.startsWith("/");
1026
+ const parts = remotePath.split("/").filter(Boolean);
1027
+ let cur = isAbsolute ? "" : ".";
1028
+ for (let i = 0; i < parts.length; i++) {
1029
+ cur = isAbsolute ? `${cur}/${parts[i]}` : `${cur}/${parts[i]}`;
1030
+ const isLeaf = i === parts.length - 1;
1031
+ try {
1032
+ await mkOne(cur);
1033
+ } catch (e) {
1034
+ if (isLeaf) throw e;
1035
+ }
1036
+ }
1037
+ } finally {
1038
+ sftp.end();
1039
+ }
1040
+ }
970
1041
 
971
1042
  // src/ops.ts
972
1043
  function shellQuote(s) {
@@ -1299,16 +1370,24 @@ function registerTools(server, pool) {
1299
1370
  const connectionPool = pool ?? new ConnectionPool();
1300
1371
  server.tool(
1301
1372
  "ssh_exec",
1302
- "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured.",
1373
+ "Execute a command on a remote host via SSH. The command is interpreted by the remote login shell \u2014 pipes, redirects, globs, and other shell metacharacters work as expected. Returns stdout, stderr, and exit code. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
1303
1374
  {
1304
1375
  ...connectionParams,
1305
1376
  command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
1377
+ env: z.record(z.string(), z.string()).optional().describe(
1378
+ "Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. Values are POSIX-single-quoted, so any byte is safe."
1379
+ ),
1306
1380
  timeout: TimeoutSchema
1307
1381
  },
1308
- async ({ command, timeout, ...conn }) => {
1309
- enforcePolicy(command);
1382
+ async ({ command, env, timeout, ...conn }) => {
1383
+ let finalCommand = command;
1384
+ if (env && Object.keys(env).length > 0) {
1385
+ const prefix = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ");
1386
+ finalCommand = `${prefix} ${command}`;
1387
+ }
1388
+ enforcePolicy(finalCommand);
1310
1389
  return connectionPool.withConnection(conn, async (client) => {
1311
- const result = await exec(client, command, timeout || 3e4);
1390
+ const result = await exec(client, finalCommand, timeout || 3e4);
1312
1391
  const parts = [];
1313
1392
  if (result.stdout) parts.push(result.stdout);
1314
1393
  if (result.stderr) parts.push(`[stderr]
@@ -1392,6 +1471,57 @@ ${result.stderr}`);
1392
1471
  });
1393
1472
  }
1394
1473
  );
1474
+ server.tool(
1475
+ "ssh_stat",
1476
+ "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.",
1477
+ {
1478
+ ...connectionParams,
1479
+ path: z.string().describe("Absolute path to the remote file or directory")
1480
+ },
1481
+ async ({ path, ...conn }) => {
1482
+ return connectionPool.withConnection(conn, async (client) => {
1483
+ const stats = await statFile(client, path);
1484
+ const lines = [];
1485
+ const kind = stats.isDirectory ? "directory" : stats.isSymbolicLink ? "symlink" : stats.isFile ? "file" : "other";
1486
+ lines.push(`${path}: ${kind}`);
1487
+ lines.push(` Size: ${stats.size} bytes`);
1488
+ lines.push(` Mode: ${stats.modeOctal}`);
1489
+ lines.push(` Owner: uid=${stats.uid} gid=${stats.gid}`);
1490
+ lines.push(` Modified: ${new Date(stats.mtime * 1e3).toISOString()}`);
1491
+ lines.push(` Accessed: ${new Date(stats.atime * 1e3).toISOString()}`);
1492
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1493
+ });
1494
+ }
1495
+ );
1496
+ server.tool(
1497
+ "ssh_mkdir",
1498
+ "Create a directory on a remote host via SFTP. Set `recursive: true` to create parent directories as needed (like `mkdir -p`). Existing intermediate dirs are tolerated; an existing leaf path is still an error.",
1499
+ {
1500
+ ...connectionParams,
1501
+ path: z.string().describe("Absolute path of the directory to create"),
1502
+ recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
1503
+ },
1504
+ async ({ path, recursive, ...conn }) => {
1505
+ return connectionPool.withConnection(conn, async (client) => {
1506
+ await makeDir(client, path, recursive ?? false);
1507
+ return { content: [{ type: "text", text: `Created directory ${path}` }] };
1508
+ });
1509
+ }
1510
+ );
1511
+ server.tool(
1512
+ "ssh_delete",
1513
+ "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.",
1514
+ {
1515
+ ...connectionParams,
1516
+ path: z.string().describe("Absolute path of the file or empty directory to delete")
1517
+ },
1518
+ async ({ path, ...conn }) => {
1519
+ return connectionPool.withConnection(conn, async (client) => {
1520
+ await deleteFile(client, path);
1521
+ return { content: [{ type: "text", text: `Deleted ${path}` }] };
1522
+ });
1523
+ }
1524
+ );
1395
1525
  server.tool(
1396
1526
  "ssh_diagnose",
1397
1527
  "Diagnose SSH connectivity issues. Checks ssh-agent status, loaded keys, known_hosts, SSH config, and attempts a test connection. Use this BEFORE attempting SSH operations if you suspect connectivity issues, or AFTER a failed SSH operation to understand why it failed.",
@@ -1681,6 +1811,7 @@ export {
1681
1811
  connectRaw,
1682
1812
  connectWithProxy,
1683
1813
  createServer,
1814
+ deleteFile,
1684
1815
  diagnose,
1685
1816
  downloadFile,
1686
1817
  enforcePolicy,
@@ -1693,12 +1824,14 @@ export {
1693
1824
  listDir,
1694
1825
  listSshKeys,
1695
1826
  loadKey,
1827
+ makeDir,
1696
1828
  multiExec,
1697
1829
  readFile,
1698
1830
  readKnownHostsKeys,
1699
1831
  registerTools,
1700
1832
  resolveConfig,
1701
1833
  serviceStatus,
1834
+ statFile,
1702
1835
  tail,
1703
1836
  testConnection,
1704
1837
  uploadFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {