@yawlabs/ssh-mcp 0.6.0 → 0.7.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 +12 -0
- package/dist/index.js +41 -5
- package/dist/server.d.ts +2 -1
- package/dist/server.js +42 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,6 +98,18 @@ All connections respect your `~/.ssh/config`. Host aliases, custom ports, userna
|
|
|
98
98
|
|
|
99
99
|
**ProxyJump / bastion hosts** are supported automatically. If your SSH config has `ProxyJump bastion` for a host, ssh-mcp connects through the bastion transparently. Chained proxies work too.
|
|
100
100
|
|
|
101
|
+
### Host key verification
|
|
102
|
+
|
|
103
|
+
All remote operations verify the server's host key against `~/.ssh/known_hosts`:
|
|
104
|
+
|
|
105
|
+
- **Known host, key matches** — accept.
|
|
106
|
+
- **Known host, key changed** — reject (MITM protection).
|
|
107
|
+
- **Unknown host** — accept on first connection (TOFU). Use `ssh_known_hosts_fix` to pin the key for future mismatch detection.
|
|
108
|
+
|
|
109
|
+
For stricter environments, set `SSH_MCP_STRICT_HOST_KEY=1` to reject unknown hosts. Add them explicitly with `ssh_known_hosts_fix` first.
|
|
110
|
+
|
|
111
|
+
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
|
+
|
|
101
113
|
### Windows support
|
|
102
114
|
|
|
103
115
|
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
|
@@ -290,14 +290,50 @@ function resolveFromSshConfig(host) {
|
|
|
290
290
|
return null;
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
|
+
function readKnownHostsKeys(host, port) {
|
|
294
|
+
if (!isValidHostname(host)) return [];
|
|
295
|
+
const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
|
|
296
|
+
const keys = [];
|
|
297
|
+
for (const target of targets) {
|
|
298
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
|
|
299
|
+
if (!ok || !stdout.trim()) continue;
|
|
300
|
+
for (const line of stdout.split("\n")) {
|
|
301
|
+
const trimmed = line.trim();
|
|
302
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
303
|
+
const parts = trimmed.split(/\s+/);
|
|
304
|
+
if (parts.length < 3) continue;
|
|
305
|
+
try {
|
|
306
|
+
keys.push(Buffer.from(parts[2], "base64"));
|
|
307
|
+
} catch {
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return keys;
|
|
312
|
+
}
|
|
313
|
+
function buildHostVerifier(hosts, port) {
|
|
314
|
+
const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
|
|
315
|
+
return (key) => {
|
|
316
|
+
const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
|
|
317
|
+
if (known.length === 0) {
|
|
318
|
+
return !strict;
|
|
319
|
+
}
|
|
320
|
+
return known.some((k) => k.equals(key));
|
|
321
|
+
};
|
|
322
|
+
}
|
|
293
323
|
function resolveConfig(config) {
|
|
294
324
|
const sshConfig = resolveFromSshConfig(config.host);
|
|
325
|
+
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
326
|
+
const verifierHosts = [config.host];
|
|
327
|
+
if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
|
|
328
|
+
verifierHosts.push(sshConfig.hostname);
|
|
329
|
+
}
|
|
295
330
|
const connectConfig = {
|
|
296
331
|
host: sshConfig?.hostname || config.host,
|
|
297
|
-
port
|
|
332
|
+
port,
|
|
298
333
|
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
299
334
|
keepaliveInterval: 15e3,
|
|
300
|
-
keepaliveCountMax: 3
|
|
335
|
+
keepaliveCountMax: 3,
|
|
336
|
+
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
301
337
|
};
|
|
302
338
|
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
303
339
|
if (agentSock) {
|
|
@@ -1029,10 +1065,10 @@ function registerTools(server, pool) {
|
|
|
1029
1065
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1030
1066
|
server.tool(
|
|
1031
1067
|
"ssh_exec",
|
|
1032
|
-
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
1068
|
+
"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.",
|
|
1033
1069
|
{
|
|
1034
1070
|
...connectionParams,
|
|
1035
|
-
command: z.string().describe("Shell command to execute on the remote host"),
|
|
1071
|
+
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1036
1072
|
timeout: TimeoutSchema
|
|
1037
1073
|
},
|
|
1038
1074
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
@@ -1400,7 +1436,7 @@ ${files.join("\n")}` }] };
|
|
|
1400
1436
|
function createServer(pool) {
|
|
1401
1437
|
const server = new McpServer({
|
|
1402
1438
|
name: "ssh-mcp",
|
|
1403
|
-
version: "0.
|
|
1439
|
+
version: "0.7.0"
|
|
1404
1440
|
});
|
|
1405
1441
|
registerTools(server, pool);
|
|
1406
1442
|
return server;
|
package/dist/server.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ interface ResolvedConfig {
|
|
|
18
18
|
connectConfig: ConnectConfig;
|
|
19
19
|
proxyJump?: string;
|
|
20
20
|
}
|
|
21
|
+
declare function readKnownHostsKeys(host: string, port?: number): Buffer[];
|
|
21
22
|
declare function resolveConfig(config: SSHConfig): ResolvedConfig;
|
|
22
23
|
declare function formatDiagnostics(host: string): string;
|
|
23
24
|
declare function connectRaw(connectConfig: ConnectConfig): Promise<Client>;
|
|
@@ -164,4 +165,4 @@ declare function testConnection(host: string, port?: number): {
|
|
|
164
165
|
|
|
165
166
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
166
167
|
|
|
167
|
-
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, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
|
|
168
|
+
export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FindOptions, type KeyInfo, type MultiExecHost, type MultiExecResult, type PoolOptions, type ResolvedConfig, type SSHConfig, type ServiceStatus, checkConnectivity, checkGitSsh, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, configLookup, connect, connectRaw, connectWithProxy, createServer, diagnose, downloadFile, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
|
package/dist/server.js
CHANGED
|
@@ -581,14 +581,50 @@ function resolveFromSshConfig(host) {
|
|
|
581
581
|
return null;
|
|
582
582
|
}
|
|
583
583
|
}
|
|
584
|
+
function readKnownHostsKeys(host, port) {
|
|
585
|
+
if (!isValidHostname(host)) return [];
|
|
586
|
+
const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
|
|
587
|
+
const keys = [];
|
|
588
|
+
for (const target of targets) {
|
|
589
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
|
|
590
|
+
if (!ok || !stdout.trim()) continue;
|
|
591
|
+
for (const line of stdout.split("\n")) {
|
|
592
|
+
const trimmed = line.trim();
|
|
593
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
594
|
+
const parts = trimmed.split(/\s+/);
|
|
595
|
+
if (parts.length < 3) continue;
|
|
596
|
+
try {
|
|
597
|
+
keys.push(Buffer.from(parts[2], "base64"));
|
|
598
|
+
} catch {
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return keys;
|
|
603
|
+
}
|
|
604
|
+
function buildHostVerifier(hosts, port) {
|
|
605
|
+
const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
|
|
606
|
+
return (key) => {
|
|
607
|
+
const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
|
|
608
|
+
if (known.length === 0) {
|
|
609
|
+
return !strict;
|
|
610
|
+
}
|
|
611
|
+
return known.some((k) => k.equals(key));
|
|
612
|
+
};
|
|
613
|
+
}
|
|
584
614
|
function resolveConfig(config) {
|
|
585
615
|
const sshConfig = resolveFromSshConfig(config.host);
|
|
616
|
+
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
617
|
+
const verifierHosts = [config.host];
|
|
618
|
+
if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
|
|
619
|
+
verifierHosts.push(sshConfig.hostname);
|
|
620
|
+
}
|
|
586
621
|
const connectConfig = {
|
|
587
622
|
host: sshConfig?.hostname || config.host,
|
|
588
|
-
port
|
|
623
|
+
port,
|
|
589
624
|
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
590
625
|
keepaliveInterval: 15e3,
|
|
591
|
-
keepaliveCountMax: 3
|
|
626
|
+
keepaliveCountMax: 3,
|
|
627
|
+
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
592
628
|
};
|
|
593
629
|
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
594
630
|
if (agentSock) {
|
|
@@ -1040,10 +1076,10 @@ function registerTools(server, pool) {
|
|
|
1040
1076
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1041
1077
|
server.tool(
|
|
1042
1078
|
"ssh_exec",
|
|
1043
|
-
"Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
|
|
1079
|
+
"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.",
|
|
1044
1080
|
{
|
|
1045
1081
|
...connectionParams,
|
|
1046
|
-
command: z.string().describe("Shell command to execute on the remote host"),
|
|
1082
|
+
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1047
1083
|
timeout: TimeoutSchema
|
|
1048
1084
|
},
|
|
1049
1085
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
@@ -1411,7 +1447,7 @@ ${files.join("\n")}` }] };
|
|
|
1411
1447
|
function createServer(pool) {
|
|
1412
1448
|
const server = new McpServer({
|
|
1413
1449
|
name: "ssh-mcp",
|
|
1414
|
-
version: "0.
|
|
1450
|
+
version: "0.7.0"
|
|
1415
1451
|
});
|
|
1416
1452
|
registerTools(server, pool);
|
|
1417
1453
|
return server;
|
|
@@ -1441,6 +1477,7 @@ export {
|
|
|
1441
1477
|
loadKey,
|
|
1442
1478
|
multiExec,
|
|
1443
1479
|
readFile,
|
|
1480
|
+
readKnownHostsKeys,
|
|
1444
1481
|
registerTools,
|
|
1445
1482
|
resolveConfig,
|
|
1446
1483
|
serviceStatus,
|