@yawlabs/ssh-mcp 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yaw Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.js ADDED
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/server.ts
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+
9
+ // src/tools.ts
10
+ import { z } from "zod";
11
+
12
+ // src/diagnose.ts
13
+ import { execSync } from "child_process";
14
+ import { existsSync, readFileSync } from "fs";
15
+ import { homedir } from "os";
16
+ import { join } from "path";
17
+ function run(cmd) {
18
+ try {
19
+ const stdout = execSync(cmd, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
20
+ return { stdout: stdout.trim(), ok: true };
21
+ } catch (e) {
22
+ return { stdout: e.stdout?.toString().trim() || e.message || "", ok: false };
23
+ }
24
+ }
25
+ function checkSshAgent() {
26
+ const sock = process.env.SSH_AUTH_SOCK;
27
+ if (!sock) {
28
+ return {
29
+ status: "error",
30
+ message: "SSH_AUTH_SOCK is not set. ssh-agent is not running or not exported to this shell."
31
+ };
32
+ }
33
+ const { stdout, ok } = run("ssh-add -l");
34
+ if (!ok && stdout.includes("Could not open a connection")) {
35
+ return {
36
+ status: "error",
37
+ message: `SSH_AUTH_SOCK is set to "${sock}" but the agent is not reachable. The agent process may have died. Run: eval "$(ssh-agent -s)"`
38
+ };
39
+ }
40
+ if (stdout.includes("The agent has no identities")) {
41
+ return {
42
+ status: "warning",
43
+ message: "ssh-agent is running but has no keys loaded. Run: ssh-add <key-path>"
44
+ };
45
+ }
46
+ return { status: "ok", message: `ssh-agent running with keys:
47
+ ${stdout}` };
48
+ }
49
+ function checkSshKeys() {
50
+ const home = homedir();
51
+ const sshDir = join(home, ".ssh");
52
+ if (!existsSync(sshDir)) {
53
+ return { status: "error", message: "~/.ssh directory does not exist. Run: mkdir -p ~/.ssh && chmod 700 ~/.ssh" };
54
+ }
55
+ const keyTypes = ["id_ed25519", "id_rsa", "id_ecdsa"];
56
+ const found = [];
57
+ for (const key of keyTypes) {
58
+ const keyPath = join(sshDir, key);
59
+ if (existsSync(keyPath)) {
60
+ found.push(key);
61
+ }
62
+ }
63
+ try {
64
+ const { stdout } = run(`ls "${sshDir}"`);
65
+ const allFiles = stdout.split("\n").filter((f) => !f.endsWith(".pub") && !["known_hosts", "config", "authorized_keys"].includes(f));
66
+ for (const f of allFiles) {
67
+ if (!keyTypes.includes(f) && existsSync(join(sshDir, f))) {
68
+ try {
69
+ const content = readFileSync(join(sshDir, f), "utf8");
70
+ if (content.includes("PRIVATE KEY")) {
71
+ found.push(f);
72
+ }
73
+ } catch {
74
+ }
75
+ }
76
+ }
77
+ } catch {
78
+ }
79
+ if (found.length === 0) {
80
+ return {
81
+ status: "error",
82
+ message: 'No SSH private keys found in ~/.ssh/. Generate one: ssh-keygen -t ed25519 -C "your@email.com"'
83
+ };
84
+ }
85
+ return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
86
+ }
87
+ function checkKnownHosts(host) {
88
+ const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
89
+ if (!existsSync(knownHostsPath)) {
90
+ return {
91
+ status: "warning",
92
+ message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
93
+ };
94
+ }
95
+ const { stdout, ok } = run(`ssh-keygen -F "${host}"`);
96
+ if (!ok || !stdout.trim()) {
97
+ return {
98
+ status: "warning",
99
+ message: `Host "${host}" is not in known_hosts. First connection will prompt for host key verification. To add it: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`
100
+ };
101
+ }
102
+ return { status: "ok", message: `Host "${host}" found in known_hosts` };
103
+ }
104
+ function checkConnectivity(host, port = 22) {
105
+ const { ok, stdout } = run(
106
+ `ssh -o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=no -p ${port} ${host} echo SSH_OK 2>&1`
107
+ );
108
+ if (ok && stdout.includes("SSH_OK")) {
109
+ return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
110
+ }
111
+ if (stdout.includes("Permission denied")) {
112
+ return {
113
+ status: "error",
114
+ message: `Permission denied connecting to ${host}:${port}. Your key is not authorized on this host. Check: 1) correct key is loaded (ssh-add -l), 2) key is in remote authorized_keys, 3) correct username.`
115
+ };
116
+ }
117
+ if (stdout.includes("Connection refused")) {
118
+ return {
119
+ status: "error",
120
+ message: `Connection refused at ${host}:${port}. SSH server is not running on this port or host is blocking connections.`
121
+ };
122
+ }
123
+ if (stdout.includes("Connection timed out") || stdout.includes("timed out")) {
124
+ return {
125
+ status: "error",
126
+ message: `Connection timed out to ${host}:${port}. Host may be down, port may be blocked by firewall, or DNS resolution failed.`
127
+ };
128
+ }
129
+ if (stdout.includes("Host key verification failed")) {
130
+ return {
131
+ status: "error",
132
+ message: `Host key verification failed for ${host}. The host key changed (instance recreated?). Fix: ssh-keygen -R ${host} && ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`
133
+ };
134
+ }
135
+ if (stdout.includes("Could not resolve hostname")) {
136
+ return {
137
+ status: "error",
138
+ message: `Could not resolve hostname "${host}". Check DNS, /etc/hosts, or SSH config aliases.`
139
+ };
140
+ }
141
+ return { status: "error", message: `SSH connection failed: ${stdout}` };
142
+ }
143
+ function checkSshConfig(host) {
144
+ const configPath = join(homedir(), ".ssh", "config");
145
+ if (!existsSync(configPath)) {
146
+ return { status: "ok", message: "No ~/.ssh/config file (using defaults)" };
147
+ }
148
+ try {
149
+ const content = readFileSync(configPath, "utf8");
150
+ const lines = content.split("\n");
151
+ let inHostBlock = false;
152
+ const hostConfig = [];
153
+ for (const line of lines) {
154
+ const trimmed = line.trim();
155
+ if (/^Host\s+/i.test(trimmed)) {
156
+ const pattern = trimmed.replace(/^Host\s+/i, "").trim();
157
+ inHostBlock = pattern === host || pattern === "*";
158
+ if (inHostBlock) hostConfig.push(trimmed);
159
+ } else if (inHostBlock && trimmed) {
160
+ hostConfig.push(trimmed);
161
+ } else if (inHostBlock && !trimmed) {
162
+ inHostBlock = false;
163
+ }
164
+ }
165
+ if (hostConfig.length === 0) {
166
+ return { status: "ok", message: `No SSH config entry for "${host}" (using defaults)` };
167
+ }
168
+ return { status: "ok", message: `SSH config for "${host}":
169
+ ${hostConfig.join("\n")}` };
170
+ } catch {
171
+ return { status: "warning", message: "Could not read ~/.ssh/config" };
172
+ }
173
+ }
174
+ function diagnose(host, port = 22) {
175
+ const checks = [];
176
+ const suggestions = [];
177
+ const agent = checkSshAgent();
178
+ checks.push({ name: "SSH Agent", ...agent });
179
+ if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
180
+ if (agent.status === "warning") suggestions.push("Load your key: ssh-add ~/.ssh/id_ed25519");
181
+ const keys = checkSshKeys();
182
+ checks.push({ name: "SSH Keys", ...keys });
183
+ if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
184
+ const config = checkSshConfig(host);
185
+ checks.push({ name: "SSH Config", ...config });
186
+ const known = checkKnownHosts(host);
187
+ checks.push({ name: "Known Hosts", ...known });
188
+ if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`);
189
+ const conn = checkConnectivity(host, port);
190
+ checks.push({ name: "Connectivity", ...conn });
191
+ if (conn.status === "error" && conn.message.includes("Host key verification")) {
192
+ suggestions.push(`Remove stale host key: ssh-keygen -R ${host}`);
193
+ suggestions.push(`Re-add host key: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`);
194
+ }
195
+ if (conn.status === "error" && conn.message.includes("Permission denied")) {
196
+ suggestions.push("Check loaded keys: ssh-add -l");
197
+ suggestions.push("Verify correct username for this host");
198
+ }
199
+ const overall = checks.some((c) => c.status === "error") ? "error" : checks.some((c) => c.status === "warning") ? "warning" : "ok";
200
+ return { overall, checks, suggestions };
201
+ }
202
+
203
+ // src/ssh.ts
204
+ import { readFileSync as readFileSync2 } from "fs";
205
+ import { homedir as homedir2 } from "os";
206
+ import { join as join2 } from "path";
207
+ import { Client } from "ssh2";
208
+ function resolveConfig(config) {
209
+ const connectConfig = {
210
+ host: config.host,
211
+ port: config.port || 22,
212
+ username: config.username || process.env.USER || process.env.USERNAME || "root"
213
+ };
214
+ if (config.privateKeyPath) {
215
+ connectConfig.privateKey = readFileSync2(config.privateKeyPath);
216
+ } else if (config.password) {
217
+ connectConfig.password = config.password;
218
+ } else if (config.agent || process.env.SSH_AUTH_SOCK) {
219
+ connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
220
+ } else {
221
+ const home = homedir2();
222
+ const defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
223
+ for (const keyName of defaultKeys) {
224
+ const keyPath = join2(home, ".ssh", keyName);
225
+ try {
226
+ connectConfig.privateKey = readFileSync2(keyPath);
227
+ break;
228
+ } catch {
229
+ }
230
+ }
231
+ }
232
+ return connectConfig;
233
+ }
234
+ function connect(config) {
235
+ return new Promise((resolve, reject) => {
236
+ const client = new Client();
237
+ const connectConfig = resolveConfig(config);
238
+ client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
239
+ });
240
+ }
241
+ function exec(client, command, timeoutMs = 3e4) {
242
+ return new Promise((resolve, reject) => {
243
+ const timer = setTimeout(() => {
244
+ reject(new Error(`Command timed out after ${timeoutMs}ms`));
245
+ }, timeoutMs);
246
+ client.exec(command, (err, stream) => {
247
+ if (err) {
248
+ clearTimeout(timer);
249
+ return reject(err);
250
+ }
251
+ let stdout = "";
252
+ let stderr = "";
253
+ stream.on("close", (code) => {
254
+ clearTimeout(timer);
255
+ resolve({ stdout, stderr, code: code ?? 0 });
256
+ }).on("data", (data) => {
257
+ stdout += data.toString();
258
+ }).stderr.on("data", (data) => {
259
+ stderr += data.toString();
260
+ });
261
+ });
262
+ });
263
+ }
264
+ function readFile(client, remotePath) {
265
+ return new Promise((resolve, reject) => {
266
+ client.sftp((err, sftp) => {
267
+ if (err) return reject(err);
268
+ sftp.readFile(remotePath, (err2, data) => {
269
+ if (err2) return reject(err2);
270
+ resolve(data.toString("utf8"));
271
+ });
272
+ });
273
+ });
274
+ }
275
+ function writeFile(client, remotePath, content) {
276
+ return new Promise((resolve, reject) => {
277
+ client.sftp((err, sftp) => {
278
+ if (err) return reject(err);
279
+ sftp.writeFile(remotePath, content, (err2) => {
280
+ if (err2) return reject(err2);
281
+ resolve();
282
+ });
283
+ });
284
+ });
285
+ }
286
+ function uploadFile(client, localPath, remotePath) {
287
+ return new Promise((resolve, reject) => {
288
+ client.sftp((err, sftp) => {
289
+ if (err) return reject(err);
290
+ sftp.fastPut(localPath, remotePath, (err2) => {
291
+ if (err2) return reject(err2);
292
+ resolve();
293
+ });
294
+ });
295
+ });
296
+ }
297
+ function downloadFile(client, remotePath, localPath) {
298
+ return new Promise((resolve, reject) => {
299
+ client.sftp((err, sftp) => {
300
+ if (err) return reject(err);
301
+ sftp.fastGet(remotePath, localPath, (err2) => {
302
+ if (err2) return reject(err2);
303
+ resolve();
304
+ });
305
+ });
306
+ });
307
+ }
308
+ function listDir(client, remotePath) {
309
+ return new Promise((resolve, reject) => {
310
+ client.sftp((err, sftp) => {
311
+ if (err) return reject(err);
312
+ sftp.readdir(remotePath, (err2, list) => {
313
+ if (err2) return reject(err2);
314
+ resolve(list.map((item) => item.filename));
315
+ });
316
+ });
317
+ });
318
+ }
319
+
320
+ // src/tools.ts
321
+ var HostSchema = z.string().describe("SSH hostname or IP address");
322
+ var PortSchema = z.number().optional().describe("SSH port (default: 22)");
323
+ var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
324
+ var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
325
+ var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
326
+ var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
327
+ var connectionParams = {
328
+ host: HostSchema,
329
+ port: PortSchema,
330
+ username: UsernameSchema,
331
+ privateKeyPath: KeyPathSchema,
332
+ password: PasswordSchema
333
+ };
334
+ function registerTools(server) {
335
+ server.tool(
336
+ "ssh_exec",
337
+ "Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
338
+ {
339
+ ...connectionParams,
340
+ command: z.string().describe("Shell command to execute on the remote host"),
341
+ timeout: TimeoutSchema
342
+ },
343
+ async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
344
+ const client = await connect({ host, port, username, privateKeyPath, password });
345
+ try {
346
+ const result = await exec(client, command, timeout || 3e4);
347
+ const parts = [];
348
+ if (result.stdout) parts.push(result.stdout);
349
+ if (result.stderr) parts.push(`[stderr]
350
+ ${result.stderr}`);
351
+ parts.push(`[exit code: ${result.code}]`);
352
+ return { content: [{ type: "text", text: parts.join("\n") }] };
353
+ } finally {
354
+ client.end();
355
+ }
356
+ }
357
+ );
358
+ server.tool(
359
+ "ssh_read_file",
360
+ "Read a file from a remote host via SFTP.",
361
+ {
362
+ ...connectionParams,
363
+ path: z.string().describe("Absolute path to the remote file")
364
+ },
365
+ async ({ host, port, username, privateKeyPath, password, path }) => {
366
+ const client = await connect({ host, port, username, privateKeyPath, password });
367
+ try {
368
+ const content = await readFile(client, path);
369
+ return { content: [{ type: "text", text: content }] };
370
+ } finally {
371
+ client.end();
372
+ }
373
+ }
374
+ );
375
+ server.tool(
376
+ "ssh_write_file",
377
+ "Write content to a file on a remote host via SFTP. Creates or overwrites the file.",
378
+ {
379
+ ...connectionParams,
380
+ path: z.string().describe("Absolute path to the remote file"),
381
+ content: z.string().describe("File content to write")
382
+ },
383
+ async ({ host, port, username, privateKeyPath, password, path, content }) => {
384
+ const client = await connect({ host, port, username, privateKeyPath, password });
385
+ try {
386
+ await writeFile(client, path, content);
387
+ return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
388
+ } finally {
389
+ client.end();
390
+ }
391
+ }
392
+ );
393
+ server.tool(
394
+ "ssh_upload",
395
+ "Upload a local file to a remote host via SFTP.",
396
+ {
397
+ ...connectionParams,
398
+ localPath: z.string().describe("Path to the local file to upload"),
399
+ remotePath: z.string().describe("Absolute path on the remote host")
400
+ },
401
+ async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
402
+ const client = await connect({ host, port, username, privateKeyPath, password });
403
+ try {
404
+ await uploadFile(client, localPath, remotePath);
405
+ return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
406
+ } finally {
407
+ client.end();
408
+ }
409
+ }
410
+ );
411
+ server.tool(
412
+ "ssh_download",
413
+ "Download a file from a remote host to local filesystem via SFTP.",
414
+ {
415
+ ...connectionParams,
416
+ remotePath: z.string().describe("Absolute path to the remote file"),
417
+ localPath: z.string().describe("Local path to save the downloaded file")
418
+ },
419
+ async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
420
+ const client = await connect({ host, port, username, privateKeyPath, password });
421
+ try {
422
+ await downloadFile(client, remotePath, localPath);
423
+ return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
424
+ } finally {
425
+ client.end();
426
+ }
427
+ }
428
+ );
429
+ server.tool(
430
+ "ssh_ls",
431
+ "List files in a directory on a remote host via SFTP.",
432
+ {
433
+ ...connectionParams,
434
+ path: z.string().describe("Absolute path to the remote directory")
435
+ },
436
+ async ({ host, port, username, privateKeyPath, password, path }) => {
437
+ const client = await connect({ host, port, username, privateKeyPath, password });
438
+ try {
439
+ const files = await listDir(client, path);
440
+ return { content: [{ type: "text", text: files.join("\n") }] };
441
+ } finally {
442
+ client.end();
443
+ }
444
+ }
445
+ );
446
+ server.tool(
447
+ "ssh_diagnose",
448
+ "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.",
449
+ {
450
+ host: HostSchema,
451
+ port: PortSchema
452
+ },
453
+ async ({ host, port }) => {
454
+ const report = diagnose(host, port || 22);
455
+ const lines = [];
456
+ lines.push(`SSH Diagnostic Report for ${host}:${port || 22}`);
457
+ lines.push(`Overall: ${report.overall.toUpperCase()}`);
458
+ lines.push("");
459
+ for (const check of report.checks) {
460
+ const icon = check.status === "ok" ? "PASS" : check.status === "warning" ? "WARN" : "FAIL";
461
+ lines.push(`[${icon}] ${check.name}`);
462
+ lines.push(` ${check.message}`);
463
+ lines.push("");
464
+ }
465
+ if (report.suggestions.length > 0) {
466
+ lines.push("Suggested fixes:");
467
+ for (const s of report.suggestions) {
468
+ lines.push(` - ${s}`);
469
+ }
470
+ }
471
+ return { content: [{ type: "text", text: lines.join("\n") }] };
472
+ }
473
+ );
474
+ }
475
+
476
+ // src/server.ts
477
+ function createServer() {
478
+ const server = new McpServer({
479
+ name: "ssh-mcp",
480
+ version: "0.1.0"
481
+ });
482
+ registerTools(server);
483
+ return server;
484
+ }
485
+
486
+ // src/index.ts
487
+ async function main() {
488
+ const server = createServer();
489
+ const transport = new StdioServerTransport();
490
+ await server.connect(transport);
491
+ }
492
+ main().catch((err) => {
493
+ console.error("ssh-mcp failed to start:", err);
494
+ process.exit(1);
495
+ });
@@ -0,0 +1,47 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Client } from 'ssh2';
3
+
4
+ declare function registerTools(server: McpServer): void;
5
+
6
+ interface DiagnosticResult {
7
+ status: "ok" | "warning" | "error";
8
+ message: string;
9
+ }
10
+ interface DiagnosticReport {
11
+ overall: "ok" | "warning" | "error";
12
+ checks: Array<{
13
+ name: string;
14
+ } & DiagnosticResult>;
15
+ suggestions: string[];
16
+ }
17
+ declare function checkSshAgent(): DiagnosticResult;
18
+ declare function checkSshKeys(): DiagnosticResult;
19
+ declare function checkKnownHosts(host: string): DiagnosticResult;
20
+ declare function checkConnectivity(host: string, port?: number): DiagnosticResult;
21
+ declare function checkSshConfig(host: string): DiagnosticResult;
22
+ declare function diagnose(host: string, port?: number): DiagnosticReport;
23
+
24
+ interface SSHConfig {
25
+ host: string;
26
+ port?: number;
27
+ username?: string;
28
+ privateKeyPath?: string;
29
+ password?: string;
30
+ agent?: string;
31
+ }
32
+ interface ExecResult {
33
+ stdout: string;
34
+ stderr: string;
35
+ code: number;
36
+ }
37
+ declare function connect(config: SSHConfig): Promise<Client>;
38
+ declare function exec(client: Client, command: string, timeoutMs?: number): Promise<ExecResult>;
39
+ declare function readFile(client: Client, remotePath: string): Promise<string>;
40
+ declare function writeFile(client: Client, remotePath: string, content: string): Promise<void>;
41
+ declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
42
+ declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
43
+ declare function listDir(client: Client, remotePath: string): Promise<string[]>;
44
+
45
+ declare function createServer(): McpServer;
46
+
47
+ export { checkConnectivity, checkKnownHosts, checkSshAgent, checkSshConfig, checkSshKeys, connect, createServer, diagnose, downloadFile, exec, listDir, readFile, registerTools, uploadFile, writeFile };
package/dist/server.js ADDED
@@ -0,0 +1,496 @@
1
+ // src/server.ts
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+
4
+ // src/tools.ts
5
+ import { z } from "zod";
6
+
7
+ // src/diagnose.ts
8
+ import { execSync } from "child_process";
9
+ import { existsSync, readFileSync } from "fs";
10
+ import { homedir } from "os";
11
+ import { join } from "path";
12
+ function run(cmd) {
13
+ try {
14
+ const stdout = execSync(cmd, { encoding: "utf8", timeout: 1e4, stdio: ["pipe", "pipe", "pipe"] });
15
+ return { stdout: stdout.trim(), ok: true };
16
+ } catch (e) {
17
+ return { stdout: e.stdout?.toString().trim() || e.message || "", ok: false };
18
+ }
19
+ }
20
+ function checkSshAgent() {
21
+ const sock = process.env.SSH_AUTH_SOCK;
22
+ if (!sock) {
23
+ return {
24
+ status: "error",
25
+ message: "SSH_AUTH_SOCK is not set. ssh-agent is not running or not exported to this shell."
26
+ };
27
+ }
28
+ const { stdout, ok } = run("ssh-add -l");
29
+ if (!ok && stdout.includes("Could not open a connection")) {
30
+ return {
31
+ status: "error",
32
+ message: `SSH_AUTH_SOCK is set to "${sock}" but the agent is not reachable. The agent process may have died. Run: eval "$(ssh-agent -s)"`
33
+ };
34
+ }
35
+ if (stdout.includes("The agent has no identities")) {
36
+ return {
37
+ status: "warning",
38
+ message: "ssh-agent is running but has no keys loaded. Run: ssh-add <key-path>"
39
+ };
40
+ }
41
+ return { status: "ok", message: `ssh-agent running with keys:
42
+ ${stdout}` };
43
+ }
44
+ function checkSshKeys() {
45
+ const home = homedir();
46
+ const sshDir = join(home, ".ssh");
47
+ if (!existsSync(sshDir)) {
48
+ return { status: "error", message: "~/.ssh directory does not exist. Run: mkdir -p ~/.ssh && chmod 700 ~/.ssh" };
49
+ }
50
+ const keyTypes = ["id_ed25519", "id_rsa", "id_ecdsa"];
51
+ const found = [];
52
+ for (const key of keyTypes) {
53
+ const keyPath = join(sshDir, key);
54
+ if (existsSync(keyPath)) {
55
+ found.push(key);
56
+ }
57
+ }
58
+ try {
59
+ const { stdout } = run(`ls "${sshDir}"`);
60
+ const allFiles = stdout.split("\n").filter((f) => !f.endsWith(".pub") && !["known_hosts", "config", "authorized_keys"].includes(f));
61
+ for (const f of allFiles) {
62
+ if (!keyTypes.includes(f) && existsSync(join(sshDir, f))) {
63
+ try {
64
+ const content = readFileSync(join(sshDir, f), "utf8");
65
+ if (content.includes("PRIVATE KEY")) {
66
+ found.push(f);
67
+ }
68
+ } catch {
69
+ }
70
+ }
71
+ }
72
+ } catch {
73
+ }
74
+ if (found.length === 0) {
75
+ return {
76
+ status: "error",
77
+ message: 'No SSH private keys found in ~/.ssh/. Generate one: ssh-keygen -t ed25519 -C "your@email.com"'
78
+ };
79
+ }
80
+ return { status: "ok", message: `Found SSH keys: ${found.join(", ")}` };
81
+ }
82
+ function checkKnownHosts(host) {
83
+ const knownHostsPath = join(homedir(), ".ssh", "known_hosts");
84
+ if (!existsSync(knownHostsPath)) {
85
+ return {
86
+ status: "warning",
87
+ message: "~/.ssh/known_hosts does not exist. First connection to any host will prompt for verification."
88
+ };
89
+ }
90
+ const { stdout, ok } = run(`ssh-keygen -F "${host}"`);
91
+ if (!ok || !stdout.trim()) {
92
+ return {
93
+ status: "warning",
94
+ message: `Host "${host}" is not in known_hosts. First connection will prompt for host key verification. To add it: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`
95
+ };
96
+ }
97
+ return { status: "ok", message: `Host "${host}" found in known_hosts` };
98
+ }
99
+ function checkConnectivity(host, port = 22) {
100
+ const { ok, stdout } = run(
101
+ `ssh -o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=no -p ${port} ${host} echo SSH_OK 2>&1`
102
+ );
103
+ if (ok && stdout.includes("SSH_OK")) {
104
+ return { status: "ok", message: `SSH connection to ${host}:${port} succeeded` };
105
+ }
106
+ if (stdout.includes("Permission denied")) {
107
+ return {
108
+ status: "error",
109
+ message: `Permission denied connecting to ${host}:${port}. Your key is not authorized on this host. Check: 1) correct key is loaded (ssh-add -l), 2) key is in remote authorized_keys, 3) correct username.`
110
+ };
111
+ }
112
+ if (stdout.includes("Connection refused")) {
113
+ return {
114
+ status: "error",
115
+ message: `Connection refused at ${host}:${port}. SSH server is not running on this port or host is blocking connections.`
116
+ };
117
+ }
118
+ if (stdout.includes("Connection timed out") || stdout.includes("timed out")) {
119
+ return {
120
+ status: "error",
121
+ message: `Connection timed out to ${host}:${port}. Host may be down, port may be blocked by firewall, or DNS resolution failed.`
122
+ };
123
+ }
124
+ if (stdout.includes("Host key verification failed")) {
125
+ return {
126
+ status: "error",
127
+ message: `Host key verification failed for ${host}. The host key changed (instance recreated?). Fix: ssh-keygen -R ${host} && ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`
128
+ };
129
+ }
130
+ if (stdout.includes("Could not resolve hostname")) {
131
+ return {
132
+ status: "error",
133
+ message: `Could not resolve hostname "${host}". Check DNS, /etc/hosts, or SSH config aliases.`
134
+ };
135
+ }
136
+ return { status: "error", message: `SSH connection failed: ${stdout}` };
137
+ }
138
+ function checkSshConfig(host) {
139
+ const configPath = join(homedir(), ".ssh", "config");
140
+ if (!existsSync(configPath)) {
141
+ return { status: "ok", message: "No ~/.ssh/config file (using defaults)" };
142
+ }
143
+ try {
144
+ const content = readFileSync(configPath, "utf8");
145
+ const lines = content.split("\n");
146
+ let inHostBlock = false;
147
+ const hostConfig = [];
148
+ for (const line of lines) {
149
+ const trimmed = line.trim();
150
+ if (/^Host\s+/i.test(trimmed)) {
151
+ const pattern = trimmed.replace(/^Host\s+/i, "").trim();
152
+ inHostBlock = pattern === host || pattern === "*";
153
+ if (inHostBlock) hostConfig.push(trimmed);
154
+ } else if (inHostBlock && trimmed) {
155
+ hostConfig.push(trimmed);
156
+ } else if (inHostBlock && !trimmed) {
157
+ inHostBlock = false;
158
+ }
159
+ }
160
+ if (hostConfig.length === 0) {
161
+ return { status: "ok", message: `No SSH config entry for "${host}" (using defaults)` };
162
+ }
163
+ return { status: "ok", message: `SSH config for "${host}":
164
+ ${hostConfig.join("\n")}` };
165
+ } catch {
166
+ return { status: "warning", message: "Could not read ~/.ssh/config" };
167
+ }
168
+ }
169
+ function diagnose(host, port = 22) {
170
+ const checks = [];
171
+ const suggestions = [];
172
+ const agent = checkSshAgent();
173
+ checks.push({ name: "SSH Agent", ...agent });
174
+ if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
175
+ if (agent.status === "warning") suggestions.push("Load your key: ssh-add ~/.ssh/id_ed25519");
176
+ const keys = checkSshKeys();
177
+ checks.push({ name: "SSH Keys", ...keys });
178
+ if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
179
+ const config = checkSshConfig(host);
180
+ checks.push({ name: "SSH Config", ...config });
181
+ const known = checkKnownHosts(host);
182
+ checks.push({ name: "Known Hosts", ...known });
183
+ if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`);
184
+ const conn = checkConnectivity(host, port);
185
+ checks.push({ name: "Connectivity", ...conn });
186
+ if (conn.status === "error" && conn.message.includes("Host key verification")) {
187
+ suggestions.push(`Remove stale host key: ssh-keygen -R ${host}`);
188
+ suggestions.push(`Re-add host key: ssh-keyscan -H ${host} >> ~/.ssh/known_hosts`);
189
+ }
190
+ if (conn.status === "error" && conn.message.includes("Permission denied")) {
191
+ suggestions.push("Check loaded keys: ssh-add -l");
192
+ suggestions.push("Verify correct username for this host");
193
+ }
194
+ const overall = checks.some((c) => c.status === "error") ? "error" : checks.some((c) => c.status === "warning") ? "warning" : "ok";
195
+ return { overall, checks, suggestions };
196
+ }
197
+
198
+ // src/ssh.ts
199
+ import { readFileSync as readFileSync2 } from "fs";
200
+ import { homedir as homedir2 } from "os";
201
+ import { join as join2 } from "path";
202
+ import { Client } from "ssh2";
203
+ function resolveConfig(config) {
204
+ const connectConfig = {
205
+ host: config.host,
206
+ port: config.port || 22,
207
+ username: config.username || process.env.USER || process.env.USERNAME || "root"
208
+ };
209
+ if (config.privateKeyPath) {
210
+ connectConfig.privateKey = readFileSync2(config.privateKeyPath);
211
+ } else if (config.password) {
212
+ connectConfig.password = config.password;
213
+ } else if (config.agent || process.env.SSH_AUTH_SOCK) {
214
+ connectConfig.agent = config.agent || process.env.SSH_AUTH_SOCK;
215
+ } else {
216
+ const home = homedir2();
217
+ const defaultKeys = ["id_ed25519", "id_rsa", "id_ecdsa"];
218
+ for (const keyName of defaultKeys) {
219
+ const keyPath = join2(home, ".ssh", keyName);
220
+ try {
221
+ connectConfig.privateKey = readFileSync2(keyPath);
222
+ break;
223
+ } catch {
224
+ }
225
+ }
226
+ }
227
+ return connectConfig;
228
+ }
229
+ function connect(config) {
230
+ return new Promise((resolve, reject) => {
231
+ const client = new Client();
232
+ const connectConfig = resolveConfig(config);
233
+ client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
234
+ });
235
+ }
236
+ function exec(client, command, timeoutMs = 3e4) {
237
+ return new Promise((resolve, reject) => {
238
+ const timer = setTimeout(() => {
239
+ reject(new Error(`Command timed out after ${timeoutMs}ms`));
240
+ }, timeoutMs);
241
+ client.exec(command, (err, stream) => {
242
+ if (err) {
243
+ clearTimeout(timer);
244
+ return reject(err);
245
+ }
246
+ let stdout = "";
247
+ let stderr = "";
248
+ stream.on("close", (code) => {
249
+ clearTimeout(timer);
250
+ resolve({ stdout, stderr, code: code ?? 0 });
251
+ }).on("data", (data) => {
252
+ stdout += data.toString();
253
+ }).stderr.on("data", (data) => {
254
+ stderr += data.toString();
255
+ });
256
+ });
257
+ });
258
+ }
259
+ function readFile(client, remotePath) {
260
+ return new Promise((resolve, reject) => {
261
+ client.sftp((err, sftp) => {
262
+ if (err) return reject(err);
263
+ sftp.readFile(remotePath, (err2, data) => {
264
+ if (err2) return reject(err2);
265
+ resolve(data.toString("utf8"));
266
+ });
267
+ });
268
+ });
269
+ }
270
+ function writeFile(client, remotePath, content) {
271
+ return new Promise((resolve, reject) => {
272
+ client.sftp((err, sftp) => {
273
+ if (err) return reject(err);
274
+ sftp.writeFile(remotePath, content, (err2) => {
275
+ if (err2) return reject(err2);
276
+ resolve();
277
+ });
278
+ });
279
+ });
280
+ }
281
+ function uploadFile(client, localPath, remotePath) {
282
+ return new Promise((resolve, reject) => {
283
+ client.sftp((err, sftp) => {
284
+ if (err) return reject(err);
285
+ sftp.fastPut(localPath, remotePath, (err2) => {
286
+ if (err2) return reject(err2);
287
+ resolve();
288
+ });
289
+ });
290
+ });
291
+ }
292
+ function downloadFile(client, remotePath, localPath) {
293
+ return new Promise((resolve, reject) => {
294
+ client.sftp((err, sftp) => {
295
+ if (err) return reject(err);
296
+ sftp.fastGet(remotePath, localPath, (err2) => {
297
+ if (err2) return reject(err2);
298
+ resolve();
299
+ });
300
+ });
301
+ });
302
+ }
303
+ function listDir(client, remotePath) {
304
+ return new Promise((resolve, reject) => {
305
+ client.sftp((err, sftp) => {
306
+ if (err) return reject(err);
307
+ sftp.readdir(remotePath, (err2, list) => {
308
+ if (err2) return reject(err2);
309
+ resolve(list.map((item) => item.filename));
310
+ });
311
+ });
312
+ });
313
+ }
314
+
315
+ // src/tools.ts
316
+ var HostSchema = z.string().describe("SSH hostname or IP address");
317
+ var PortSchema = z.number().optional().describe("SSH port (default: 22)");
318
+ var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
319
+ var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
320
+ var PasswordSchema = z.string().optional().describe("SSH password (prefer keys)");
321
+ var TimeoutSchema = z.number().optional().describe("Command timeout in milliseconds (default: 30000)");
322
+ var connectionParams = {
323
+ host: HostSchema,
324
+ port: PortSchema,
325
+ username: UsernameSchema,
326
+ privateKeyPath: KeyPathSchema,
327
+ password: PasswordSchema
328
+ };
329
+ function registerTools(server) {
330
+ server.tool(
331
+ "ssh_exec",
332
+ "Execute a command on a remote host via SSH. Returns stdout, stderr, and exit code.",
333
+ {
334
+ ...connectionParams,
335
+ command: z.string().describe("Shell command to execute on the remote host"),
336
+ timeout: TimeoutSchema
337
+ },
338
+ async ({ host, port, username, privateKeyPath, password, command, timeout }) => {
339
+ const client = await connect({ host, port, username, privateKeyPath, password });
340
+ try {
341
+ const result = await exec(client, command, timeout || 3e4);
342
+ const parts = [];
343
+ if (result.stdout) parts.push(result.stdout);
344
+ if (result.stderr) parts.push(`[stderr]
345
+ ${result.stderr}`);
346
+ parts.push(`[exit code: ${result.code}]`);
347
+ return { content: [{ type: "text", text: parts.join("\n") }] };
348
+ } finally {
349
+ client.end();
350
+ }
351
+ }
352
+ );
353
+ server.tool(
354
+ "ssh_read_file",
355
+ "Read a file from a remote host via SFTP.",
356
+ {
357
+ ...connectionParams,
358
+ path: z.string().describe("Absolute path to the remote file")
359
+ },
360
+ async ({ host, port, username, privateKeyPath, password, path }) => {
361
+ const client = await connect({ host, port, username, privateKeyPath, password });
362
+ try {
363
+ const content = await readFile(client, path);
364
+ return { content: [{ type: "text", text: content }] };
365
+ } finally {
366
+ client.end();
367
+ }
368
+ }
369
+ );
370
+ server.tool(
371
+ "ssh_write_file",
372
+ "Write content to a file on a remote host via SFTP. Creates or overwrites the file.",
373
+ {
374
+ ...connectionParams,
375
+ path: z.string().describe("Absolute path to the remote file"),
376
+ content: z.string().describe("File content to write")
377
+ },
378
+ async ({ host, port, username, privateKeyPath, password, path, content }) => {
379
+ const client = await connect({ host, port, username, privateKeyPath, password });
380
+ try {
381
+ await writeFile(client, path, content);
382
+ return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
383
+ } finally {
384
+ client.end();
385
+ }
386
+ }
387
+ );
388
+ server.tool(
389
+ "ssh_upload",
390
+ "Upload a local file to a remote host via SFTP.",
391
+ {
392
+ ...connectionParams,
393
+ localPath: z.string().describe("Path to the local file to upload"),
394
+ remotePath: z.string().describe("Absolute path on the remote host")
395
+ },
396
+ async ({ host, port, username, privateKeyPath, password, localPath, remotePath }) => {
397
+ const client = await connect({ host, port, username, privateKeyPath, password });
398
+ try {
399
+ await uploadFile(client, localPath, remotePath);
400
+ return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
401
+ } finally {
402
+ client.end();
403
+ }
404
+ }
405
+ );
406
+ server.tool(
407
+ "ssh_download",
408
+ "Download a file from a remote host to local filesystem via SFTP.",
409
+ {
410
+ ...connectionParams,
411
+ remotePath: z.string().describe("Absolute path to the remote file"),
412
+ localPath: z.string().describe("Local path to save the downloaded file")
413
+ },
414
+ async ({ host, port, username, privateKeyPath, password, remotePath, localPath }) => {
415
+ const client = await connect({ host, port, username, privateKeyPath, password });
416
+ try {
417
+ await downloadFile(client, remotePath, localPath);
418
+ return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
419
+ } finally {
420
+ client.end();
421
+ }
422
+ }
423
+ );
424
+ server.tool(
425
+ "ssh_ls",
426
+ "List files in a directory on a remote host via SFTP.",
427
+ {
428
+ ...connectionParams,
429
+ path: z.string().describe("Absolute path to the remote directory")
430
+ },
431
+ async ({ host, port, username, privateKeyPath, password, path }) => {
432
+ const client = await connect({ host, port, username, privateKeyPath, password });
433
+ try {
434
+ const files = await listDir(client, path);
435
+ return { content: [{ type: "text", text: files.join("\n") }] };
436
+ } finally {
437
+ client.end();
438
+ }
439
+ }
440
+ );
441
+ server.tool(
442
+ "ssh_diagnose",
443
+ "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.",
444
+ {
445
+ host: HostSchema,
446
+ port: PortSchema
447
+ },
448
+ async ({ host, port }) => {
449
+ const report = diagnose(host, port || 22);
450
+ const lines = [];
451
+ lines.push(`SSH Diagnostic Report for ${host}:${port || 22}`);
452
+ lines.push(`Overall: ${report.overall.toUpperCase()}`);
453
+ lines.push("");
454
+ for (const check of report.checks) {
455
+ const icon = check.status === "ok" ? "PASS" : check.status === "warning" ? "WARN" : "FAIL";
456
+ lines.push(`[${icon}] ${check.name}`);
457
+ lines.push(` ${check.message}`);
458
+ lines.push("");
459
+ }
460
+ if (report.suggestions.length > 0) {
461
+ lines.push("Suggested fixes:");
462
+ for (const s of report.suggestions) {
463
+ lines.push(` - ${s}`);
464
+ }
465
+ }
466
+ return { content: [{ type: "text", text: lines.join("\n") }] };
467
+ }
468
+ );
469
+ }
470
+
471
+ // src/server.ts
472
+ function createServer() {
473
+ const server = new McpServer({
474
+ name: "ssh-mcp",
475
+ version: "0.1.0"
476
+ });
477
+ registerTools(server);
478
+ return server;
479
+ }
480
+ export {
481
+ checkConnectivity,
482
+ checkKnownHosts,
483
+ checkSshAgent,
484
+ checkSshConfig,
485
+ checkSshKeys,
486
+ connect,
487
+ createServer,
488
+ diagnose,
489
+ downloadFile,
490
+ exec,
491
+ listDir,
492
+ readFile,
493
+ registerTools,
494
+ uploadFile,
495
+ writeFile
496
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@yawlabs/ssh-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for SSH operations with built-in diagnostics",
5
+ "type": "module",
6
+ "bin": {
7
+ "ssh-mcp": "dist/index.js"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/server.js",
12
+ "types": "./dist/server.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "LICENSE",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsup",
22
+ "dev": "tsup --watch",
23
+ "lint": "biome check src/",
24
+ "lint:fix": "biome check --write src/",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run",
27
+ "test:ci": "npm run build && npm test",
28
+ "prepublishOnly": "npm run build"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "ssh",
33
+ "remote",
34
+ "model-context-protocol",
35
+ "ai",
36
+ "devops"
37
+ ],
38
+ "author": "Yaw Labs <contact@yaw.sh>",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/YawLabs/ssh-mcp.git"
43
+ },
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.29.0",
49
+ "ssh2": "^1.16.0",
50
+ "zod": "^3.24.4"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^1.9.4",
54
+ "@types/node": "^22.15.2",
55
+ "@types/ssh2": "^1.15.4",
56
+ "tsup": "^8.5.1",
57
+ "typescript": "^5.8.3",
58
+ "vitest": "^3.2.4"
59
+ }
60
+ }