@yawlabs/ssh-mcp 0.9.3 → 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 +32 -4
- package/dist/index.js +166 -4
- package/dist/server.d.ts +31 -1
- package/dist/server.js +174 -4
- package/package.json +1 -1
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. |
|
|
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
|
|
|
@@ -79,10 +82,10 @@ Tools that wrap common patterns agents build with ssh_exec — faster and less e
|
|
|
79
82
|
|
|
80
83
|
| Tool | Description |
|
|
81
84
|
|------|-------------|
|
|
82
|
-
| `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. |
|
|
83
|
-
| `ssh_find` | Search for files remotely with structured parameters (name
|
|
85
|
+
| `ssh_multi_exec` | Run a command on multiple hosts in parallel. Returns results per host. Subject to [command policy](#command-policy) if configured (policy is checked once before fan-out). |
|
|
86
|
+
| `ssh_find` | Search for files remotely with structured parameters (`name`, `type`, `size`, `depth`, `newer` — match files modified more recently than a reference path). |
|
|
84
87
|
| `ssh_tail` | Read the last N lines of a file, optionally filtered by a grep pattern. |
|
|
85
|
-
| `ssh_service_status` | Check systemd service status (active, PID, uptime, description). |
|
|
88
|
+
| `ssh_service_status` | Check systemd service status (active, PID, uptime, description). Flags `isError` only when the unit could not be found / queried, not when an existing unit is intentionally stopped. |
|
|
86
89
|
|
|
87
90
|
### Auto-diagnostics
|
|
88
91
|
|
|
@@ -92,6 +95,8 @@ When any remote operation fails, ssh-mcp automatically runs diagnostics and incl
|
|
|
92
95
|
|
|
93
96
|
Remote operations reuse SSH connections automatically. When your agent makes multiple calls to the same host, the first call opens a connection and subsequent calls reuse it. Connections are kept alive for 60 seconds after the last use, then closed automatically.
|
|
94
97
|
|
|
98
|
+
The pool caps at 100 active connections by default. Set `SSH_MCP_MAX_POOL_SIZE=<n>` to raise it for fan-out workloads against many distinct hosts (e.g. `ssh_multi_exec` across a large fleet). When the cap is reached, the pool evicts an idle entry to make room; if every entry is in use it rejects with `Connection pool is full`.
|
|
99
|
+
|
|
95
100
|
### SSH config support
|
|
96
101
|
|
|
97
102
|
All connections respect your `~/.ssh/config`. Host aliases, custom ports, usernames, identity files, and ProxyJump settings are used automatically. If you have `Host myserver` configured in your SSH config, just pass `host: "myserver"` — ssh-mcp resolves everything.
|
|
@@ -110,6 +115,29 @@ For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hos
|
|
|
110
115
|
|
|
111
116
|
The diagnostic tools (`ssh_test`, `ssh_diagnose`) use `StrictHostKeyChecking=no` for their probe commands. Those probes only run `echo SSH_OK` — no credentials or data pass through — so the relaxed setting is safe for connectivity testing. Real operations always go through the `hostVerifier`.
|
|
112
117
|
|
|
118
|
+
### Command policy
|
|
119
|
+
|
|
120
|
+
`ssh_exec` and `ssh_multi_exec` accept free-form shell commands from the agent. For security-conscious deployments, you can restrict which commands run via two env vars, each accepting a comma-separated list of regex patterns:
|
|
121
|
+
|
|
122
|
+
- `SSH_MCP_COMMAND_WHITELIST` — if set, the command **must** match at least one pattern, else it's blocked.
|
|
123
|
+
- `SSH_MCP_COMMAND_BLACKLIST` — if set, the command **must not** match any pattern, else it's blocked.
|
|
124
|
+
|
|
125
|
+
When both are set, the command must pass both checks (whitelist first, then blacklist). When neither is set (the default), all commands are allowed.
|
|
126
|
+
|
|
127
|
+
Patterns are JavaScript regexes. Use `^` and `$` for anchored matches; otherwise patterns are treated as substring matches. Commas are the delimiter, so a literal comma in a pattern needs to be expressed as `\x2c` or via a character class.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Read-only allowlist: only ls / df / cat / find / tail
|
|
131
|
+
SSH_MCP_COMMAND_WHITELIST="^ls( .*)?,^df( .*)?,^cat ,^find ,^tail "
|
|
132
|
+
|
|
133
|
+
# Block destructive ops even if your agent goes off-script
|
|
134
|
+
SSH_MCP_COMMAND_BLACKLIST="^rm ,^shutdown,^reboot,^mkfs,^dd if=,>\s*/dev/"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Blocked commands surface as a clear error mentioning which pattern (or which env var) rejected the call, so the agent can adapt rather than guess. Policy is enforced before the SSH connection opens — no remote process is started for a blocked command.
|
|
138
|
+
|
|
139
|
+
The structured higher-level tools (`ssh_find`, `ssh_tail`, `ssh_service_status`, SFTP ops) are exempt from policy. They build commands from typed parameters, so a tight `^ls` whitelist would otherwise force you to allow `^find `, `^tail `, `^systemctl ` just to keep those tools working — defeating the point of a tight whitelist.
|
|
140
|
+
|
|
113
141
|
### Windows support
|
|
114
142
|
|
|
115
143
|
On Windows, ssh-mcp detects the OpenSSH Authentication Agent service automatically (via the `\\.\pipe\openssh-ssh-agent` named pipe). No `SSH_AUTH_SOCK` needed — just make sure the OpenSSH agent service is running.
|
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() {
|
|
@@ -1241,6 +1312,36 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1241
1312
|
};
|
|
1242
1313
|
}
|
|
1243
1314
|
|
|
1315
|
+
// src/policy.ts
|
|
1316
|
+
function parsePatterns(raw) {
|
|
1317
|
+
if (!raw) return [];
|
|
1318
|
+
const patterns = [];
|
|
1319
|
+
for (const p of raw.split(",")) {
|
|
1320
|
+
const cleaned = p.replace(/^\s+/, "");
|
|
1321
|
+
if (!cleaned) continue;
|
|
1322
|
+
try {
|
|
1323
|
+
patterns.push(new RegExp(cleaned));
|
|
1324
|
+
} catch {
|
|
1325
|
+
console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return patterns;
|
|
1329
|
+
}
|
|
1330
|
+
function enforcePolicy(command) {
|
|
1331
|
+
const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
|
|
1332
|
+
if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
|
|
1333
|
+
throw new Error(
|
|
1334
|
+
`Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
|
|
1338
|
+
for (const pattern of blacklist) {
|
|
1339
|
+
if (pattern.test(command)) {
|
|
1340
|
+
throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1244
1345
|
// src/tools.ts
|
|
1245
1346
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
1246
1347
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
@@ -1261,15 +1362,24 @@ function registerTools(server, pool) {
|
|
|
1261
1362
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1262
1363
|
server.tool(
|
|
1263
1364
|
"ssh_exec",
|
|
1264
|
-
"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.",
|
|
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).",
|
|
1265
1366
|
{
|
|
1266
1367
|
...connectionParams,
|
|
1267
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
|
+
),
|
|
1268
1372
|
timeout: TimeoutSchema
|
|
1269
1373
|
},
|
|
1270
|
-
async ({ command, timeout, ...conn }) => {
|
|
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);
|
|
1271
1381
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1272
|
-
const result = await exec(client,
|
|
1382
|
+
const result = await exec(client, finalCommand, timeout || 3e4);
|
|
1273
1383
|
const parts = [];
|
|
1274
1384
|
if (result.stdout) parts.push(result.stdout);
|
|
1275
1385
|
if (result.stderr) parts.push(`[stderr]
|
|
@@ -1353,6 +1463,57 @@ ${result.stderr}`);
|
|
|
1353
1463
|
});
|
|
1354
1464
|
}
|
|
1355
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
|
+
);
|
|
1356
1517
|
server.tool(
|
|
1357
1518
|
"ssh_diagnose",
|
|
1358
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.",
|
|
@@ -1511,7 +1672,7 @@ ${result.stderr}`);
|
|
|
1511
1672
|
);
|
|
1512
1673
|
server.tool(
|
|
1513
1674
|
"ssh_multi_exec",
|
|
1514
|
-
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
|
|
1675
|
+
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once before fan-out).",
|
|
1515
1676
|
{
|
|
1516
1677
|
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1517
1678
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
@@ -1522,6 +1683,7 @@ ${result.stderr}`);
|
|
|
1522
1683
|
timeout: TimeoutSchema
|
|
1523
1684
|
},
|
|
1524
1685
|
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1686
|
+
enforcePolicy(command);
|
|
1525
1687
|
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1526
1688
|
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1527
1689
|
const lines = [];
|
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) */
|
|
@@ -185,8 +204,19 @@ interface ServiceStatus {
|
|
|
185
204
|
}
|
|
186
205
|
declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
|
|
187
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Check a command against the env-configured policy. Throws if blocked.
|
|
209
|
+
*
|
|
210
|
+
* Called from MCP tool handlers (ssh_exec, ssh_multi_exec) -- not from `exec()` itself,
|
|
211
|
+
* so library consumers using the programmatic API don't get policy enforcement (they're
|
|
212
|
+
* outside the MCP trust boundary and write their own gating).
|
|
213
|
+
*/
|
|
214
|
+
declare function enforcePolicy(command: string): void;
|
|
215
|
+
/** Returns true if any policy is currently configured. Used by tool descriptions to surface that. */
|
|
216
|
+
declare function isPolicyConfigured(): boolean;
|
|
217
|
+
|
|
188
218
|
declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
|
|
189
219
|
|
|
190
220
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
191
221
|
|
|
192
|
-
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 };
|
|
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) {
|
|
@@ -1052,6 +1123,39 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1052
1123
|
};
|
|
1053
1124
|
}
|
|
1054
1125
|
|
|
1126
|
+
// src/policy.ts
|
|
1127
|
+
function parsePatterns(raw) {
|
|
1128
|
+
if (!raw) return [];
|
|
1129
|
+
const patterns = [];
|
|
1130
|
+
for (const p of raw.split(",")) {
|
|
1131
|
+
const cleaned = p.replace(/^\s+/, "");
|
|
1132
|
+
if (!cleaned) continue;
|
|
1133
|
+
try {
|
|
1134
|
+
patterns.push(new RegExp(cleaned));
|
|
1135
|
+
} catch {
|
|
1136
|
+
console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return patterns;
|
|
1140
|
+
}
|
|
1141
|
+
function enforcePolicy(command) {
|
|
1142
|
+
const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
|
|
1143
|
+
if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
|
|
1144
|
+
throw new Error(
|
|
1145
|
+
`Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
|
|
1149
|
+
for (const pattern of blacklist) {
|
|
1150
|
+
if (pattern.test(command)) {
|
|
1151
|
+
throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function isPolicyConfigured() {
|
|
1156
|
+
return Boolean(process.env.SSH_MCP_COMMAND_WHITELIST?.trim() || process.env.SSH_MCP_COMMAND_BLACKLIST?.trim());
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1055
1159
|
// src/pool.ts
|
|
1056
1160
|
function defaultMaxPoolSize() {
|
|
1057
1161
|
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
@@ -1266,15 +1370,24 @@ function registerTools(server, pool) {
|
|
|
1266
1370
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1267
1371
|
server.tool(
|
|
1268
1372
|
"ssh_exec",
|
|
1269
|
-
"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.",
|
|
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).",
|
|
1270
1374
|
{
|
|
1271
1375
|
...connectionParams,
|
|
1272
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
|
+
),
|
|
1273
1380
|
timeout: TimeoutSchema
|
|
1274
1381
|
},
|
|
1275
|
-
async ({ command, timeout, ...conn }) => {
|
|
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);
|
|
1276
1389
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1277
|
-
const result = await exec(client,
|
|
1390
|
+
const result = await exec(client, finalCommand, timeout || 3e4);
|
|
1278
1391
|
const parts = [];
|
|
1279
1392
|
if (result.stdout) parts.push(result.stdout);
|
|
1280
1393
|
if (result.stderr) parts.push(`[stderr]
|
|
@@ -1358,6 +1471,57 @@ ${result.stderr}`);
|
|
|
1358
1471
|
});
|
|
1359
1472
|
}
|
|
1360
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
|
+
);
|
|
1361
1525
|
server.tool(
|
|
1362
1526
|
"ssh_diagnose",
|
|
1363
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.",
|
|
@@ -1516,7 +1680,7 @@ ${result.stderr}`);
|
|
|
1516
1680
|
);
|
|
1517
1681
|
server.tool(
|
|
1518
1682
|
"ssh_multi_exec",
|
|
1519
|
-
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side.",
|
|
1683
|
+
"Execute a command on multiple remote hosts in parallel. Returns results per host. Use this instead of calling ssh_exec multiple times \u2014 it's faster and shows results side by side. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked once before fan-out).",
|
|
1520
1684
|
{
|
|
1521
1685
|
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1522
1686
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
@@ -1527,6 +1691,7 @@ ${result.stderr}`);
|
|
|
1527
1691
|
timeout: TimeoutSchema
|
|
1528
1692
|
},
|
|
1529
1693
|
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1694
|
+
enforcePolicy(command);
|
|
1530
1695
|
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1531
1696
|
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1532
1697
|
const lines = [];
|
|
@@ -1646,22 +1811,27 @@ export {
|
|
|
1646
1811
|
connectRaw,
|
|
1647
1812
|
connectWithProxy,
|
|
1648
1813
|
createServer,
|
|
1814
|
+
deleteFile,
|
|
1649
1815
|
diagnose,
|
|
1650
1816
|
downloadFile,
|
|
1817
|
+
enforcePolicy,
|
|
1651
1818
|
ensureAgent,
|
|
1652
1819
|
exec,
|
|
1653
1820
|
find,
|
|
1654
1821
|
fixKnownHosts,
|
|
1655
1822
|
formatDiagnostics,
|
|
1823
|
+
isPolicyConfigured,
|
|
1656
1824
|
listDir,
|
|
1657
1825
|
listSshKeys,
|
|
1658
1826
|
loadKey,
|
|
1827
|
+
makeDir,
|
|
1659
1828
|
multiExec,
|
|
1660
1829
|
readFile,
|
|
1661
1830
|
readKnownHostsKeys,
|
|
1662
1831
|
registerTools,
|
|
1663
1832
|
resolveConfig,
|
|
1664
1833
|
serviceStatus,
|
|
1834
|
+
statFile,
|
|
1665
1835
|
tail,
|
|
1666
1836
|
testConnection,
|
|
1667
1837
|
uploadFile,
|