@yawlabs/ssh-mcp 0.5.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 +107 -24
- package/dist/server.d.ts +6 -2
- package/dist/server.js +108 -24
- 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
|
@@ -15,16 +15,21 @@ import { existsSync, readFileSync, readdirSync } from "fs";
|
|
|
15
15
|
import { homedir } from "os";
|
|
16
16
|
import { join } from "path";
|
|
17
17
|
function isValidHostname(host) {
|
|
18
|
-
|
|
18
|
+
if (host.length === 0 || host.length > 253) return false;
|
|
19
|
+
if (host.startsWith("[")) {
|
|
20
|
+
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
21
|
+
}
|
|
22
|
+
return /^[a-zA-Z0-9._\-]+$/.test(host);
|
|
19
23
|
}
|
|
20
24
|
function runArgs(cmd, args) {
|
|
21
25
|
try {
|
|
22
26
|
const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
|
|
23
27
|
return { stdout: stdout.trim(), ok: true };
|
|
24
28
|
} catch (e) {
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
29
|
+
const err = e;
|
|
30
|
+
const stdout = err.stdout?.toString().trim() || "";
|
|
31
|
+
const stderr = err.stderr?.toString().trim() || "";
|
|
32
|
+
const output = [stdout, stderr].filter(Boolean).join("\n") || err.message || "";
|
|
28
33
|
return { stdout: output, ok: false };
|
|
29
34
|
}
|
|
30
35
|
}
|
|
@@ -42,12 +47,10 @@ ${stdout2}` };
|
|
|
42
47
|
message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
|
|
43
48
|
};
|
|
44
49
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
};
|
|
50
|
-
}
|
|
50
|
+
return {
|
|
51
|
+
status: "error",
|
|
52
|
+
message: "Windows OpenSSH Authentication Agent is not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
53
|
+
};
|
|
51
54
|
}
|
|
52
55
|
if (!sock) {
|
|
53
56
|
return {
|
|
@@ -111,6 +114,9 @@ function checkSshKeys() {
|
|
|
111
114
|
return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
|
|
112
115
|
}
|
|
113
116
|
function checkKnownHosts(host) {
|
|
117
|
+
if (!isValidHostname(host)) {
|
|
118
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
119
|
+
}
|
|
114
120
|
const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
|
|
115
121
|
if (!existsSync(knownHostsPath)) {
|
|
116
122
|
return {
|
|
@@ -118,9 +124,6 @@ function checkKnownHosts(host) {
|
|
|
118
124
|
message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
|
-
if (!isValidHostname(host)) {
|
|
122
|
-
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
123
|
-
}
|
|
124
127
|
const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
|
|
125
128
|
if (!ok || !stdout.trim()) {
|
|
126
129
|
return {
|
|
@@ -287,14 +290,50 @@ function resolveFromSshConfig(host) {
|
|
|
287
290
|
return null;
|
|
288
291
|
}
|
|
289
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
|
+
}
|
|
290
323
|
function resolveConfig(config) {
|
|
291
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
|
+
}
|
|
292
330
|
const connectConfig = {
|
|
293
331
|
host: sshConfig?.hostname || config.host,
|
|
294
|
-
port
|
|
332
|
+
port,
|
|
295
333
|
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
296
334
|
keepaliveInterval: 15e3,
|
|
297
|
-
keepaliveCountMax: 3
|
|
335
|
+
keepaliveCountMax: 3,
|
|
336
|
+
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
298
337
|
};
|
|
299
338
|
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
300
339
|
if (agentSock) {
|
|
@@ -423,9 +462,21 @@ function getSftp(client) {
|
|
|
423
462
|
});
|
|
424
463
|
});
|
|
425
464
|
}
|
|
426
|
-
|
|
465
|
+
var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
466
|
+
async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
427
467
|
const sftp = await getSftp(client);
|
|
428
468
|
try {
|
|
469
|
+
const stats = await new Promise((resolve, reject) => {
|
|
470
|
+
sftp.stat(remotePath, (err, stats2) => {
|
|
471
|
+
if (err) return reject(err);
|
|
472
|
+
resolve(stats2);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
if (stats.size > maxBytes) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
|
|
478
|
+
);
|
|
479
|
+
}
|
|
429
480
|
return await new Promise((resolve, reject) => {
|
|
430
481
|
sftp.readFile(remotePath, (err, data) => {
|
|
431
482
|
if (err) return reject(err);
|
|
@@ -493,8 +544,10 @@ async function listDir(client, remotePath) {
|
|
|
493
544
|
var ConnectionPool = class {
|
|
494
545
|
entries = /* @__PURE__ */ new Map();
|
|
495
546
|
idleTtlMs;
|
|
547
|
+
maxPoolSize;
|
|
496
548
|
constructor(options) {
|
|
497
549
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
550
|
+
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
498
551
|
}
|
|
499
552
|
async acquire(config) {
|
|
500
553
|
const resolved = resolveConfig(config);
|
|
@@ -512,6 +565,24 @@ var ConnectionPool = class {
|
|
|
512
565
|
if (existing?.dead) {
|
|
513
566
|
this.entries.delete(key);
|
|
514
567
|
}
|
|
568
|
+
if (this.entries.size >= this.maxPoolSize) {
|
|
569
|
+
let evicted = false;
|
|
570
|
+
for (const [k, e] of this.entries) {
|
|
571
|
+
if (e.refCount === 0) {
|
|
572
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
573
|
+
try {
|
|
574
|
+
e.client.end();
|
|
575
|
+
} catch {
|
|
576
|
+
}
|
|
577
|
+
this.entries.delete(k);
|
|
578
|
+
evicted = true;
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (!evicted) {
|
|
583
|
+
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
515
586
|
try {
|
|
516
587
|
const client = await connectWithProxy(resolved);
|
|
517
588
|
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
@@ -924,7 +995,18 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
924
995
|
};
|
|
925
996
|
});
|
|
926
997
|
}
|
|
998
|
+
var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
|
|
927
999
|
async function find(client, options, timeoutMs = 3e4) {
|
|
1000
|
+
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
1001
|
+
throw new Error(
|
|
1002
|
+
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
|
|
1006
|
+
throw new Error(
|
|
1007
|
+
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
928
1010
|
const args = [shellQuote(options.path)];
|
|
929
1011
|
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
930
1012
|
if (options.type) args.push("-type", options.type);
|
|
@@ -967,11 +1049,11 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
967
1049
|
|
|
968
1050
|
// src/tools.ts
|
|
969
1051
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
970
|
-
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
1052
|
+
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
971
1053
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
972
1054
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
973
1055
|
var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
|
|
974
|
-
var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1056
|
+
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
975
1057
|
var connectionParams = {
|
|
976
1058
|
host: HostSchema,
|
|
977
1059
|
port: PortSchema,
|
|
@@ -983,10 +1065,10 @@ function registerTools(server, pool) {
|
|
|
983
1065
|
const connectionPool = pool ?? new ConnectionPool();
|
|
984
1066
|
server.tool(
|
|
985
1067
|
"ssh_exec",
|
|
986
|
-
"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.",
|
|
987
1069
|
{
|
|
988
1070
|
...connectionParams,
|
|
989
|
-
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)"),
|
|
990
1072
|
timeout: TimeoutSchema
|
|
991
1073
|
},
|
|
992
1074
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
@@ -1099,7 +1181,7 @@ ${result.stderr}`);
|
|
|
1099
1181
|
lines.push(` - ${s}`);
|
|
1100
1182
|
}
|
|
1101
1183
|
}
|
|
1102
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1184
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
|
|
1103
1185
|
}
|
|
1104
1186
|
);
|
|
1105
1187
|
server.tool(
|
|
@@ -1236,13 +1318,14 @@ ${result.stderr}`);
|
|
|
1236
1318
|
{
|
|
1237
1319
|
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1238
1320
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
1321
|
+
port: PortSchema,
|
|
1239
1322
|
username: UsernameSchema,
|
|
1240
1323
|
privateKeyPath: KeyPathSchema,
|
|
1241
1324
|
password: PasswordSchema,
|
|
1242
1325
|
timeout: TimeoutSchema
|
|
1243
1326
|
},
|
|
1244
|
-
async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
|
|
1245
|
-
const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
|
|
1327
|
+
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1328
|
+
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1246
1329
|
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1247
1330
|
const lines = [];
|
|
1248
1331
|
for (const r of results) {
|
|
@@ -1353,7 +1436,7 @@ ${files.join("\n")}` }] };
|
|
|
1353
1436
|
function createServer(pool) {
|
|
1354
1437
|
const server = new McpServer({
|
|
1355
1438
|
name: "ssh-mcp",
|
|
1356
|
-
version: "0.
|
|
1439
|
+
version: "0.7.0"
|
|
1357
1440
|
});
|
|
1358
1441
|
registerTools(server, pool);
|
|
1359
1442
|
return server;
|
package/dist/server.d.ts
CHANGED
|
@@ -18,13 +18,14 @@ 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>;
|
|
24
25
|
declare function connectWithProxy(resolved: ResolvedConfig): Promise<Client>;
|
|
25
26
|
declare function connect(config: SSHConfig): Promise<Client>;
|
|
26
27
|
declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
|
|
27
|
-
declare function readFile(client: Client, remotePath: string): Promise<string>;
|
|
28
|
+
declare function readFile(client: Client, remotePath: string, maxBytes?: number): Promise<string>;
|
|
28
29
|
declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
|
|
29
30
|
declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
|
|
30
31
|
declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
|
|
@@ -33,10 +34,13 @@ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
|
|
|
33
34
|
interface PoolOptions {
|
|
34
35
|
/** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
|
|
35
36
|
idleTtlMs?: number;
|
|
37
|
+
/** Maximum number of connections in the pool. Default: 100 */
|
|
38
|
+
maxPoolSize?: number;
|
|
36
39
|
}
|
|
37
40
|
declare class ConnectionPool {
|
|
38
41
|
private entries;
|
|
39
42
|
private idleTtlMs;
|
|
43
|
+
private maxPoolSize;
|
|
40
44
|
constructor(options?: PoolOptions);
|
|
41
45
|
acquire(config: SSHConfig): Promise<Client>;
|
|
42
46
|
release(client: Client): void;
|
|
@@ -161,4 +165,4 @@ declare function testConnection(host: string, port?: number): {
|
|
|
161
165
|
|
|
162
166
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
163
167
|
|
|
164
|
-
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
|
@@ -10,16 +10,21 @@ import { existsSync, readFileSync, readdirSync } from "fs";
|
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
function isValidHostname(host) {
|
|
13
|
-
|
|
13
|
+
if (host.length === 0 || host.length > 253) return false;
|
|
14
|
+
if (host.startsWith("[")) {
|
|
15
|
+
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
16
|
+
}
|
|
17
|
+
return /^[a-zA-Z0-9._\-]+$/.test(host);
|
|
14
18
|
}
|
|
15
19
|
function runArgs(cmd, args) {
|
|
16
20
|
try {
|
|
17
21
|
const stdout = execFileSync(cmd, args, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
|
|
18
22
|
return { stdout: stdout.trim(), ok: true };
|
|
19
23
|
} catch (e) {
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
24
|
+
const err = e;
|
|
25
|
+
const stdout = err.stdout?.toString().trim() || "";
|
|
26
|
+
const stderr = err.stderr?.toString().trim() || "";
|
|
27
|
+
const output = [stdout, stderr].filter(Boolean).join("\n") || err.message || "";
|
|
23
28
|
return { stdout: output, ok: false };
|
|
24
29
|
}
|
|
25
30
|
}
|
|
@@ -37,12 +42,10 @@ ${stdout2}` };
|
|
|
37
42
|
message: "Windows OpenSSH agent is running but has no keys loaded. Run: ssh-add <key-path>"
|
|
38
43
|
};
|
|
39
44
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
};
|
|
45
|
-
}
|
|
45
|
+
return {
|
|
46
|
+
status: "error",
|
|
47
|
+
message: "Windows OpenSSH Authentication Agent is not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent"
|
|
48
|
+
};
|
|
46
49
|
}
|
|
47
50
|
if (!sock) {
|
|
48
51
|
return {
|
|
@@ -106,6 +109,9 @@ function checkSshKeys() {
|
|
|
106
109
|
return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
|
|
107
110
|
}
|
|
108
111
|
function checkKnownHosts(host) {
|
|
112
|
+
if (!isValidHostname(host)) {
|
|
113
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
114
|
+
}
|
|
109
115
|
const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
|
|
110
116
|
if (!existsSync(knownHostsPath)) {
|
|
111
117
|
return {
|
|
@@ -113,9 +119,6 @@ function checkKnownHosts(host) {
|
|
|
113
119
|
message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
|
-
if (!isValidHostname(host)) {
|
|
117
|
-
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
118
|
-
}
|
|
119
122
|
const { stdout, ok } = runArgs("ssh-keygen", ["-F", host]);
|
|
120
123
|
if (!ok || !stdout.trim()) {
|
|
121
124
|
return {
|
|
@@ -578,14 +581,50 @@ function resolveFromSshConfig(host) {
|
|
|
578
581
|
return null;
|
|
579
582
|
}
|
|
580
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
|
+
}
|
|
581
614
|
function resolveConfig(config) {
|
|
582
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
|
+
}
|
|
583
621
|
const connectConfig = {
|
|
584
622
|
host: sshConfig?.hostname || config.host,
|
|
585
|
-
port
|
|
623
|
+
port,
|
|
586
624
|
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
587
625
|
keepaliveInterval: 15e3,
|
|
588
|
-
keepaliveCountMax: 3
|
|
626
|
+
keepaliveCountMax: 3,
|
|
627
|
+
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
589
628
|
};
|
|
590
629
|
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
591
630
|
if (agentSock) {
|
|
@@ -732,9 +771,21 @@ function getSftp(client) {
|
|
|
732
771
|
});
|
|
733
772
|
});
|
|
734
773
|
}
|
|
735
|
-
|
|
774
|
+
var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
775
|
+
async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
736
776
|
const sftp = await getSftp(client);
|
|
737
777
|
try {
|
|
778
|
+
const stats = await new Promise((resolve, reject) => {
|
|
779
|
+
sftp.stat(remotePath, (err, stats2) => {
|
|
780
|
+
if (err) return reject(err);
|
|
781
|
+
resolve(stats2);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
if (stats.size > maxBytes) {
|
|
785
|
+
throw new Error(
|
|
786
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
|
|
787
|
+
);
|
|
788
|
+
}
|
|
738
789
|
return await new Promise((resolve, reject) => {
|
|
739
790
|
sftp.readFile(remotePath, (err, data) => {
|
|
740
791
|
if (err) return reject(err);
|
|
@@ -824,7 +875,18 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
824
875
|
};
|
|
825
876
|
});
|
|
826
877
|
}
|
|
878
|
+
var VALID_FIND_SIZE = /^\d+[kMGTP]?$/;
|
|
827
879
|
async function find(client, options, timeoutMs = 3e4) {
|
|
880
|
+
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
881
|
+
throw new Error(
|
|
882
|
+
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "1M", "100k")`
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
|
|
888
|
+
);
|
|
889
|
+
}
|
|
828
890
|
const args = [shellQuote(options.path)];
|
|
829
891
|
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
830
892
|
if (options.type) args.push("-type", options.type);
|
|
@@ -869,8 +931,10 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
869
931
|
var ConnectionPool = class {
|
|
870
932
|
entries = /* @__PURE__ */ new Map();
|
|
871
933
|
idleTtlMs;
|
|
934
|
+
maxPoolSize;
|
|
872
935
|
constructor(options) {
|
|
873
936
|
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
937
|
+
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
874
938
|
}
|
|
875
939
|
async acquire(config) {
|
|
876
940
|
const resolved = resolveConfig(config);
|
|
@@ -888,6 +952,24 @@ var ConnectionPool = class {
|
|
|
888
952
|
if (existing?.dead) {
|
|
889
953
|
this.entries.delete(key);
|
|
890
954
|
}
|
|
955
|
+
if (this.entries.size >= this.maxPoolSize) {
|
|
956
|
+
let evicted = false;
|
|
957
|
+
for (const [k, e] of this.entries) {
|
|
958
|
+
if (e.refCount === 0) {
|
|
959
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
960
|
+
try {
|
|
961
|
+
e.client.end();
|
|
962
|
+
} catch {
|
|
963
|
+
}
|
|
964
|
+
this.entries.delete(k);
|
|
965
|
+
evicted = true;
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (!evicted) {
|
|
970
|
+
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
891
973
|
try {
|
|
892
974
|
const client = await connectWithProxy(resolved);
|
|
893
975
|
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
@@ -978,11 +1060,11 @@ ${diag}`);
|
|
|
978
1060
|
|
|
979
1061
|
// src/tools.ts
|
|
980
1062
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
981
|
-
var PortSchema = z.number().optional().describe("SSH port (default: 22)");
|
|
1063
|
+
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
982
1064
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
983
1065
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
984
1066
|
var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
|
|
985
|
-
var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1067
|
+
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
986
1068
|
var connectionParams = {
|
|
987
1069
|
host: HostSchema,
|
|
988
1070
|
port: PortSchema,
|
|
@@ -994,10 +1076,10 @@ function registerTools(server, pool) {
|
|
|
994
1076
|
const connectionPool = pool ?? new ConnectionPool();
|
|
995
1077
|
server.tool(
|
|
996
1078
|
"ssh_exec",
|
|
997
|
-
"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.",
|
|
998
1080
|
{
|
|
999
1081
|
...connectionParams,
|
|
1000
|
-
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)"),
|
|
1001
1083
|
timeout: TimeoutSchema
|
|
1002
1084
|
},
|
|
1003
1085
|
async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
|
|
@@ -1110,7 +1192,7 @@ ${result.stderr}`);
|
|
|
1110
1192
|
lines.push(` - ${s}`);
|
|
1111
1193
|
}
|
|
1112
1194
|
}
|
|
1113
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1195
|
+
return { content: [{ type: "text", text: lines.join("\n") }], isError: report.overall === "error" };
|
|
1114
1196
|
}
|
|
1115
1197
|
);
|
|
1116
1198
|
server.tool(
|
|
@@ -1247,13 +1329,14 @@ ${result.stderr}`);
|
|
|
1247
1329
|
{
|
|
1248
1330
|
hosts: z.array(z.string()).describe("List of SSH hostnames or IPs"),
|
|
1249
1331
|
command: z.string().describe("Shell command to execute on all hosts"),
|
|
1332
|
+
port: PortSchema,
|
|
1250
1333
|
username: UsernameSchema,
|
|
1251
1334
|
privateKeyPath: KeyPathSchema,
|
|
1252
1335
|
password: PasswordSchema,
|
|
1253
1336
|
timeout: TimeoutSchema
|
|
1254
1337
|
},
|
|
1255
|
-
async ({ hosts, command, username, privateKeyPath, password, timeout }) => {
|
|
1256
|
-
const hostConfigs = hosts.map((host) => ({ host, username, privateKeyPath, password }));
|
|
1338
|
+
async ({ hosts, command, port, username, privateKeyPath, password, timeout }) => {
|
|
1339
|
+
const hostConfigs = hosts.map((host) => ({ host, port, username, privateKeyPath, password }));
|
|
1257
1340
|
const results = await multiExec(connectionPool, hostConfigs, command, timeout || 3e4);
|
|
1258
1341
|
const lines = [];
|
|
1259
1342
|
for (const r of results) {
|
|
@@ -1364,7 +1447,7 @@ ${files.join("\n")}` }] };
|
|
|
1364
1447
|
function createServer(pool) {
|
|
1365
1448
|
const server = new McpServer({
|
|
1366
1449
|
name: "ssh-mcp",
|
|
1367
|
-
version: "0.
|
|
1450
|
+
version: "0.7.0"
|
|
1368
1451
|
});
|
|
1369
1452
|
registerTools(server, pool);
|
|
1370
1453
|
return server;
|
|
@@ -1394,6 +1477,7 @@ export {
|
|
|
1394
1477
|
loadKey,
|
|
1395
1478
|
multiExec,
|
|
1396
1479
|
readFile,
|
|
1480
|
+
readKnownHostsKeys,
|
|
1397
1481
|
registerTools,
|
|
1398
1482
|
resolveConfig,
|
|
1399
1483
|
serviceStatus,
|