@yawlabs/ssh-mcp 0.9.2 → 0.10.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 +29 -4
- package/dist/index.js +47 -5
- package/dist/server.d.ts +19 -1
- package/dist/server.js +52 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ 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). 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. |
|
|
@@ -79,10 +79,10 @@ Tools that wrap common patterns agents build with ssh_exec — faster and less e
|
|
|
79
79
|
|
|
80
80
|
| Tool | Description |
|
|
81
81
|
|------|-------------|
|
|
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
|
|
82
|
+
| `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). |
|
|
83
|
+
| `ssh_find` | Search for files remotely with structured parameters (`name`, `type`, `size`, `depth`, `newer` — match files modified more recently than a reference path). |
|
|
84
84
|
| `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). |
|
|
85
|
+
| `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
86
|
|
|
87
87
|
### Auto-diagnostics
|
|
88
88
|
|
|
@@ -92,6 +92,8 @@ When any remote operation fails, ssh-mcp automatically runs diagnostics and incl
|
|
|
92
92
|
|
|
93
93
|
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
94
|
|
|
95
|
+
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`.
|
|
96
|
+
|
|
95
97
|
### SSH config support
|
|
96
98
|
|
|
97
99
|
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 +112,29 @@ For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hos
|
|
|
110
112
|
|
|
111
113
|
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
114
|
|
|
115
|
+
### Command policy
|
|
116
|
+
|
|
117
|
+
`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:
|
|
118
|
+
|
|
119
|
+
- `SSH_MCP_COMMAND_WHITELIST` — if set, the command **must** match at least one pattern, else it's blocked.
|
|
120
|
+
- `SSH_MCP_COMMAND_BLACKLIST` — if set, the command **must not** match any pattern, else it's blocked.
|
|
121
|
+
|
|
122
|
+
When both are set, the command must pass both checks (whitelist first, then blacklist). When neither is set (the default), all commands are allowed.
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Read-only allowlist: only ls / df / cat / find / tail
|
|
128
|
+
SSH_MCP_COMMAND_WHITELIST="^ls( .*)?,^df( .*)?,^cat ,^find ,^tail "
|
|
129
|
+
|
|
130
|
+
# Block destructive ops even if your agent goes off-script
|
|
131
|
+
SSH_MCP_COMMAND_BLACKLIST="^rm ,^shutdown,^reboot,^mkfs,^dd if=,>\s*/dev/"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
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.
|
|
137
|
+
|
|
113
138
|
### Windows support
|
|
114
139
|
|
|
115
140
|
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
|
@@ -343,6 +343,13 @@ function ensureAgent() {
|
|
|
343
343
|
if (process.platform === "win32") {
|
|
344
344
|
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
345
345
|
if (result) return result;
|
|
346
|
+
return {
|
|
347
|
+
running: false,
|
|
348
|
+
reachable: false,
|
|
349
|
+
keys: [],
|
|
350
|
+
started: false,
|
|
351
|
+
message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
352
|
+
};
|
|
346
353
|
}
|
|
347
354
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
348
355
|
if (ok) {
|
|
@@ -370,7 +377,7 @@ function ensureAgent() {
|
|
|
370
377
|
reachable: false,
|
|
371
378
|
keys: [],
|
|
372
379
|
started: false,
|
|
373
|
-
message:
|
|
380
|
+
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
374
381
|
};
|
|
375
382
|
}
|
|
376
383
|
function detectKeyType(filePath, fileName) {
|
|
@@ -1220,6 +1227,7 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1220
1227
|
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
1221
1228
|
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
1222
1229
|
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
1230
|
+
const unknown = !activeMatch && result.code !== 0;
|
|
1223
1231
|
const fallbackStatus = result.code === 0 ? "active" : "inactive";
|
|
1224
1232
|
return {
|
|
1225
1233
|
name: serviceName,
|
|
@@ -1228,10 +1236,41 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1228
1236
|
description: descMatch?.[1]?.trim(),
|
|
1229
1237
|
since: sinceMatch?.[1]?.trim(),
|
|
1230
1238
|
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
1231
|
-
raw
|
|
1239
|
+
raw,
|
|
1240
|
+
unknown
|
|
1232
1241
|
};
|
|
1233
1242
|
}
|
|
1234
1243
|
|
|
1244
|
+
// src/policy.ts
|
|
1245
|
+
function parsePatterns(raw) {
|
|
1246
|
+
if (!raw) return [];
|
|
1247
|
+
const patterns = [];
|
|
1248
|
+
for (const p of raw.split(",")) {
|
|
1249
|
+
const cleaned = p.replace(/^\s+/, "");
|
|
1250
|
+
if (!cleaned) continue;
|
|
1251
|
+
try {
|
|
1252
|
+
patterns.push(new RegExp(cleaned));
|
|
1253
|
+
} catch {
|
|
1254
|
+
console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return patterns;
|
|
1258
|
+
}
|
|
1259
|
+
function enforcePolicy(command) {
|
|
1260
|
+
const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
|
|
1261
|
+
if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
|
|
1262
|
+
throw new Error(
|
|
1263
|
+
`Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
|
|
1267
|
+
for (const pattern of blacklist) {
|
|
1268
|
+
if (pattern.test(command)) {
|
|
1269
|
+
throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1235
1274
|
// src/tools.ts
|
|
1236
1275
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
1237
1276
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
@@ -1252,19 +1291,21 @@ function registerTools(server, pool) {
|
|
|
1252
1291
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1253
1292
|
server.tool(
|
|
1254
1293
|
"ssh_exec",
|
|
1255
|
-
"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.",
|
|
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.",
|
|
1256
1295
|
{
|
|
1257
1296
|
...connectionParams,
|
|
1258
1297
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1259
1298
|
timeout: TimeoutSchema
|
|
1260
1299
|
},
|
|
1261
1300
|
async ({ command, timeout, ...conn }) => {
|
|
1301
|
+
enforcePolicy(command);
|
|
1262
1302
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1263
1303
|
const result = await exec(client, command, timeout || 3e4);
|
|
1264
1304
|
const parts = [];
|
|
1265
1305
|
if (result.stdout) parts.push(result.stdout);
|
|
1266
1306
|
if (result.stderr) parts.push(`[stderr]
|
|
1267
1307
|
${result.stderr}`);
|
|
1308
|
+
if (result.signal) parts.push(`[signal: ${result.signal}]`);
|
|
1268
1309
|
parts.push(`[exit code: ${result.code}]`);
|
|
1269
1310
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
1270
1311
|
});
|
|
@@ -1501,7 +1542,7 @@ ${result.stderr}`);
|
|
|
1501
1542
|
);
|
|
1502
1543
|
server.tool(
|
|
1503
1544
|
"ssh_multi_exec",
|
|
1504
|
-
"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.",
|
|
1545
|
+
"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).",
|
|
1505
1546
|
{
|
|
1506
1547
|
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1507
1548
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
@@ -1512,6 +1553,7 @@ ${result.stderr}`);
|
|
|
1512
1553
|
timeout: TimeoutSchema
|
|
1513
1554
|
},
|
|
1514
1555
|
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1556
|
+
enforcePolicy(command);
|
|
1515
1557
|
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1516
1558
|
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1517
1559
|
const lines = [];
|
|
@@ -1601,7 +1643,7 @@ ${files.join("\n")}` }] };
|
|
|
1601
1643
|
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1602
1644
|
lines.push("");
|
|
1603
1645
|
lines.push(status.raw);
|
|
1604
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1646
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: status.unknown };
|
|
1605
1647
|
});
|
|
1606
1648
|
}
|
|
1607
1649
|
);
|
package/dist/server.d.ts
CHANGED
|
@@ -175,11 +175,29 @@ interface ServiceStatus {
|
|
|
175
175
|
since?: string;
|
|
176
176
|
pid?: number;
|
|
177
177
|
raw: string;
|
|
178
|
+
/**
|
|
179
|
+
* True when systemctl could not report on the unit at all: no `Active:` line
|
|
180
|
+
* parseable AND non-zero exit. Typical causes: typo'd unit name, unit file
|
|
181
|
+
* doesn't exist, systemd unreachable. Distinct from "service exists but is
|
|
182
|
+
* stopped" (active=false but unknown=false).
|
|
183
|
+
*/
|
|
184
|
+
unknown: boolean;
|
|
178
185
|
}
|
|
179
186
|
declare function serviceStatus(client: Client, serviceName: string, timeoutMs?: number): Promise<ServiceStatus>;
|
|
180
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Check a command against the env-configured policy. Throws if blocked.
|
|
190
|
+
*
|
|
191
|
+
* Called from MCP tool handlers (ssh_exec, ssh_multi_exec) -- not from `exec()` itself,
|
|
192
|
+
* so library consumers using the programmatic API don't get policy enforcement (they're
|
|
193
|
+
* outside the MCP trust boundary and write their own gating).
|
|
194
|
+
*/
|
|
195
|
+
declare function enforcePolicy(command: string): void;
|
|
196
|
+
/** Returns true if any policy is currently configured. Used by tool descriptions to surface that. */
|
|
197
|
+
declare function isPolicyConfigured(): boolean;
|
|
198
|
+
|
|
181
199
|
declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
|
|
182
200
|
|
|
183
201
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
184
202
|
|
|
185
|
-
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 };
|
|
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 };
|
package/dist/server.js
CHANGED
|
@@ -339,6 +339,13 @@ function ensureAgent() {
|
|
|
339
339
|
if (process.platform === "win32") {
|
|
340
340
|
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
341
341
|
if (result) return result;
|
|
342
|
+
return {
|
|
343
|
+
running: false,
|
|
344
|
+
reachable: false,
|
|
345
|
+
keys: [],
|
|
346
|
+
started: false,
|
|
347
|
+
message: "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
348
|
+
};
|
|
342
349
|
}
|
|
343
350
|
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
344
351
|
if (ok) {
|
|
@@ -366,7 +373,7 @@ function ensureAgent() {
|
|
|
366
373
|
reachable: false,
|
|
367
374
|
keys: [],
|
|
368
375
|
started: false,
|
|
369
|
-
message:
|
|
376
|
+
message: 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
370
377
|
};
|
|
371
378
|
}
|
|
372
379
|
function detectKeyType(filePath, fileName) {
|
|
@@ -1031,6 +1038,7 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1031
1038
|
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
1032
1039
|
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
1033
1040
|
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
1041
|
+
const unknown = !activeMatch && result.code !== 0;
|
|
1034
1042
|
const fallbackStatus = result.code === 0 ? "active" : "inactive";
|
|
1035
1043
|
return {
|
|
1036
1044
|
name: serviceName,
|
|
@@ -1039,10 +1047,44 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1039
1047
|
description: descMatch?.[1]?.trim(),
|
|
1040
1048
|
since: sinceMatch?.[1]?.trim(),
|
|
1041
1049
|
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
1042
|
-
raw
|
|
1050
|
+
raw,
|
|
1051
|
+
unknown
|
|
1043
1052
|
};
|
|
1044
1053
|
}
|
|
1045
1054
|
|
|
1055
|
+
// src/policy.ts
|
|
1056
|
+
function parsePatterns(raw) {
|
|
1057
|
+
if (!raw) return [];
|
|
1058
|
+
const patterns = [];
|
|
1059
|
+
for (const p of raw.split(",")) {
|
|
1060
|
+
const cleaned = p.replace(/^\s+/, "");
|
|
1061
|
+
if (!cleaned) continue;
|
|
1062
|
+
try {
|
|
1063
|
+
patterns.push(new RegExp(cleaned));
|
|
1064
|
+
} catch {
|
|
1065
|
+
console.error(`ssh-mcp: ignoring malformed regex in command policy: "${cleaned}"`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return patterns;
|
|
1069
|
+
}
|
|
1070
|
+
function enforcePolicy(command) {
|
|
1071
|
+
const whitelist = parsePatterns(process.env.SSH_MCP_COMMAND_WHITELIST);
|
|
1072
|
+
if (whitelist.length > 0 && !whitelist.some((r) => r.test(command))) {
|
|
1073
|
+
throw new Error(
|
|
1074
|
+
`Command blocked: does not match any pattern in SSH_MCP_COMMAND_WHITELIST. Configured patterns: ${whitelist.map((r) => r.source).join(", ")}`
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
const blacklist = parsePatterns(process.env.SSH_MCP_COMMAND_BLACKLIST);
|
|
1078
|
+
for (const pattern of blacklist) {
|
|
1079
|
+
if (pattern.test(command)) {
|
|
1080
|
+
throw new Error(`Command blocked by SSH_MCP_COMMAND_BLACKLIST: pattern "${pattern.source}"`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
function isPolicyConfigured() {
|
|
1085
|
+
return Boolean(process.env.SSH_MCP_COMMAND_WHITELIST?.trim() || process.env.SSH_MCP_COMMAND_BLACKLIST?.trim());
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1046
1088
|
// src/pool.ts
|
|
1047
1089
|
function defaultMaxPoolSize() {
|
|
1048
1090
|
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
@@ -1257,19 +1299,21 @@ function registerTools(server, pool) {
|
|
|
1257
1299
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1258
1300
|
server.tool(
|
|
1259
1301
|
"ssh_exec",
|
|
1260
|
-
"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.",
|
|
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.",
|
|
1261
1303
|
{
|
|
1262
1304
|
...connectionParams,
|
|
1263
1305
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1264
1306
|
timeout: TimeoutSchema
|
|
1265
1307
|
},
|
|
1266
1308
|
async ({ command, timeout, ...conn }) => {
|
|
1309
|
+
enforcePolicy(command);
|
|
1267
1310
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1268
1311
|
const result = await exec(client, command, timeout || 3e4);
|
|
1269
1312
|
const parts = [];
|
|
1270
1313
|
if (result.stdout) parts.push(result.stdout);
|
|
1271
1314
|
if (result.stderr) parts.push(`[stderr]
|
|
1272
1315
|
${result.stderr}`);
|
|
1316
|
+
if (result.signal) parts.push(`[signal: ${result.signal}]`);
|
|
1273
1317
|
parts.push(`[exit code: ${result.code}]`);
|
|
1274
1318
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
1275
1319
|
});
|
|
@@ -1506,7 +1550,7 @@ ${result.stderr}`);
|
|
|
1506
1550
|
);
|
|
1507
1551
|
server.tool(
|
|
1508
1552
|
"ssh_multi_exec",
|
|
1509
|
-
"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.",
|
|
1553
|
+
"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).",
|
|
1510
1554
|
{
|
|
1511
1555
|
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1512
1556
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
@@ -1517,6 +1561,7 @@ ${result.stderr}`);
|
|
|
1517
1561
|
timeout: TimeoutSchema
|
|
1518
1562
|
},
|
|
1519
1563
|
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1564
|
+
enforcePolicy(command);
|
|
1520
1565
|
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1521
1566
|
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1522
1567
|
const lines = [];
|
|
@@ -1606,7 +1651,7 @@ ${files.join("\n")}` }] };
|
|
|
1606
1651
|
if (status.since) lines.push(`Since: ${status.since}`);
|
|
1607
1652
|
lines.push("");
|
|
1608
1653
|
lines.push(status.raw);
|
|
1609
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1654
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: status.unknown };
|
|
1610
1655
|
});
|
|
1611
1656
|
}
|
|
1612
1657
|
);
|
|
@@ -1638,11 +1683,13 @@ export {
|
|
|
1638
1683
|
createServer,
|
|
1639
1684
|
diagnose,
|
|
1640
1685
|
downloadFile,
|
|
1686
|
+
enforcePolicy,
|
|
1641
1687
|
ensureAgent,
|
|
1642
1688
|
exec,
|
|
1643
1689
|
find,
|
|
1644
1690
|
fixKnownHosts,
|
|
1645
1691
|
formatDiagnostics,
|
|
1692
|
+
isPolicyConfigured,
|
|
1646
1693
|
listDir,
|
|
1647
1694
|
listSshKeys,
|
|
1648
1695
|
loadKey,
|