@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 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: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
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.6.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: config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22),
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.6.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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/ssh-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "MCP server for SSH operations with built-in diagnostics",
5
5
  "type": "module",
6
6
  "bin": {