@yawlabs/ssh-mcp 0.11.5 → 0.13.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/bin/ssh-mcp.mjs +169 -0
- package/dist/index.js +34 -17
- package/dist/server.d.ts +2 -1
- package/dist/server.js +31 -17
- package/package.json +68 -62
package/bin/ssh-mcp.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runtime launcher for @yawlabs/ssh-mcp.
|
|
4
|
+
*
|
|
5
|
+
* Prefers the oam runtime (https://oamjs.org) and falls back to the Node
|
|
6
|
+
* process already running this file.
|
|
7
|
+
*
|
|
8
|
+
*
|
|
9
|
+
* WHY THE FALLBACK COSTS NOTHING
|
|
10
|
+
* npm has already started Node to run this launcher, so falling back is a
|
|
11
|
+
* plain `import()` of the server into THIS process: no extra spawn, no extra
|
|
12
|
+
* startup, byte-identical to invoking dist/index.js directly. Discovery is
|
|
13
|
+
* stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THE OAM PATH COSTS
|
|
16
|
+
* Reaching oam through an npm `bin` means Node boots first and oam boots
|
|
17
|
+
* second, so the launcher is slower than either runtime alone. Measured on
|
|
18
|
+
* npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
|
|
19
|
+
* oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
|
|
20
|
+
* launcher is the slowest path -- it exists for `npx` convenience.
|
|
21
|
+
*
|
|
22
|
+
* For an MCP host config, point straight at oam and skip this file:
|
|
23
|
+
* { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
|
|
24
|
+
*
|
|
25
|
+
* SELECTION
|
|
26
|
+
* SSH_MCP_RUNTIME=oam require oam; fail loudly if it is missing
|
|
27
|
+
* SSH_MCP_RUNTIME=node never use oam
|
|
28
|
+
* SSH_MCP_RUNTIME=auto prefer oam, silently fall back (default)
|
|
29
|
+
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { spawn } from "node:child_process";
|
|
33
|
+
import { existsSync } from "node:fs";
|
|
34
|
+
import { constants, homedir } from "node:os";
|
|
35
|
+
import { delimiter, join } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
|
|
38
|
+
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
39
|
+
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
40
|
+
// in-process fallback must use the file:// URL. spawn() needs a real path.
|
|
41
|
+
const SERVER_URL = new URL("../dist/index.js", import.meta.url);
|
|
42
|
+
const SERVER_ENTRY = fileURLToPath(SERVER_URL);
|
|
43
|
+
const isWin = process.platform === "win32";
|
|
44
|
+
const exe = isWin ? "oam.exe" : "oam";
|
|
45
|
+
|
|
46
|
+
/** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
|
|
47
|
+
function findOam() {
|
|
48
|
+
// 1. Explicit override wins and is never second-guessed.
|
|
49
|
+
const override = process.env.OAM_BIN;
|
|
50
|
+
if (override) return existsSync(override) ? override : null;
|
|
51
|
+
|
|
52
|
+
// 2. Installed locations, BEFORE PATH. Someone who develops oam itself
|
|
53
|
+
// usually has oam/target/release on PATH, and a build directory is the
|
|
54
|
+
// wrong thing for a user-facing launcher to bind to: cargo replaces the
|
|
55
|
+
// binary underneath running processes, and the dev build is not the
|
|
56
|
+
// release the user installed. Preferring the installed copy makes the
|
|
57
|
+
// default path "what a normal user has", and OAM_BIN remains the way to
|
|
58
|
+
// point deliberately at a dev build.
|
|
59
|
+
//
|
|
60
|
+
// Both forms are checked on Windows: the installer defaults to
|
|
61
|
+
// %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
|
|
62
|
+
// OAM_INSTALL_DIR can pick either, so checking one silently misses a real
|
|
63
|
+
// install.
|
|
64
|
+
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
65
|
+
if (isWin) {
|
|
66
|
+
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
67
|
+
}
|
|
68
|
+
for (const candidate of installed) {
|
|
69
|
+
if (existsSync(candidate)) return candidate;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. PATH, resolved manually rather than by spawning `which`/`where`, which
|
|
73
|
+
// would cost a subprocess on every launch just to decide whether to spawn.
|
|
74
|
+
const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
|
|
75
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
76
|
+
if (!dir) continue;
|
|
77
|
+
for (const ext of isWin ? pathExt : [""]) {
|
|
78
|
+
const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
|
|
79
|
+
if (existsSync(candidate)) return candidate;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
87
|
+
async function runInProcess() {
|
|
88
|
+
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
89
|
+
// `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
|
|
90
|
+
// test file can import the module for unit tests without connecting a stdio
|
|
91
|
+
// transport. aws-mcp does exactly this. Importing the server here would leave
|
|
92
|
+
// argv[1] pointing at THIS launcher, the guard would read false, and the
|
|
93
|
+
// server would load but never serve: the MCP handshake just hangs.
|
|
94
|
+
//
|
|
95
|
+
// Point argv[1] at the server first, so the in-process path is
|
|
96
|
+
// indistinguishable from having executed the file directly. The spawn path
|
|
97
|
+
// needs no equivalent -- there argv[1] is already the server.
|
|
98
|
+
process.argv[1] = SERVER_ENTRY;
|
|
99
|
+
await import(SERVER_URL.href);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mode = (process.env.SSH_MCP_RUNTIME ?? "auto").toLowerCase();
|
|
103
|
+
|
|
104
|
+
if (mode === "node") {
|
|
105
|
+
await runInProcess();
|
|
106
|
+
} else {
|
|
107
|
+
const oam = findOam();
|
|
108
|
+
|
|
109
|
+
if (!oam) {
|
|
110
|
+
if (mode === "oam") {
|
|
111
|
+
// Explicitly demanded, so this is a real misconfiguration. writeSync
|
|
112
|
+
// because stderr is async for TTYs/pipes on Windows and process.exit
|
|
113
|
+
// truncates pending writes.
|
|
114
|
+
const { writeSync } = await import("node:fs");
|
|
115
|
+
writeSync(
|
|
116
|
+
2,
|
|
117
|
+
"ssh-mcp: SSH_MCP_RUNTIME=oam but no oam binary was found.\n" +
|
|
118
|
+
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use SSH_MCP_RUNTIME=node.\n",
|
|
119
|
+
);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
await runInProcess();
|
|
123
|
+
} else {
|
|
124
|
+
// `--` separates oam's own flags from the script's argv, so `ssh-mcp
|
|
125
|
+
// --version` and any host-supplied flags survive the hop unchanged.
|
|
126
|
+
const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
127
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
128
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
129
|
+
// server's shutdown path.
|
|
130
|
+
stdio: "inherit",
|
|
131
|
+
env: process.env,
|
|
132
|
+
windowsHide: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
136
|
+
// wrong arch, permission), fall back rather than failing the whole server.
|
|
137
|
+
// `spawned` prevents falling back AFTER the child started, which would
|
|
138
|
+
// double-start the server on the same stdio.
|
|
139
|
+
let spawned = false;
|
|
140
|
+
child.on("spawn", () => {
|
|
141
|
+
spawned = true;
|
|
142
|
+
});
|
|
143
|
+
child.on("error", (err) => {
|
|
144
|
+
if (spawned) return;
|
|
145
|
+
if (mode === "oam") {
|
|
146
|
+
process.stderr.write(`ssh-mcp: failed to launch oam (${err.message})\n`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
void runInProcess();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
153
|
+
// rather than the child being orphaned. No-op on Windows, harmless to add.
|
|
154
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
155
|
+
process.on(sig, () => {
|
|
156
|
+
if (!child.killed) child.kill(sig);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
child.on("exit", (code, signal) => {
|
|
161
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
162
|
+
// conventional shell exit status rather than a bare 0.
|
|
163
|
+
if (signal) {
|
|
164
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
165
|
+
}
|
|
166
|
+
process.exit(code ?? 0);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -214,8 +214,6 @@ function checkSshConfig(host) {
|
|
|
214
214
|
if (inHostBlock) hostConfig.push(trimmed);
|
|
215
215
|
} else if (inHostBlock && trimmed) {
|
|
216
216
|
hostConfig.push(trimmed);
|
|
217
|
-
} else if (inHostBlock && !trimmed) {
|
|
218
|
-
inHostBlock = false;
|
|
219
217
|
}
|
|
220
218
|
}
|
|
221
219
|
if (hostConfig.length === 0) {
|
|
@@ -615,7 +613,15 @@ import { readFileSync as readFileSync3 } from "fs";
|
|
|
615
613
|
import { homedir as homedir3 } from "os";
|
|
616
614
|
import { join as join3 } from "path";
|
|
617
615
|
import { Client } from "ssh2";
|
|
616
|
+
var sshConfigCache = /* @__PURE__ */ new Map();
|
|
618
617
|
function resolveFromSshConfig(host) {
|
|
618
|
+
const cached = sshConfigCache.get(host);
|
|
619
|
+
if (cached !== void 0) return cached;
|
|
620
|
+
const result = resolveFromSshConfigUncached(host);
|
|
621
|
+
sshConfigCache.set(host, result);
|
|
622
|
+
return result;
|
|
623
|
+
}
|
|
624
|
+
function resolveFromSshConfigUncached(host) {
|
|
619
625
|
try {
|
|
620
626
|
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
621
627
|
if (!ok) return null;
|
|
@@ -695,8 +701,10 @@ function resolveConfig(config) {
|
|
|
695
701
|
keepaliveCountMax: 3,
|
|
696
702
|
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
697
703
|
};
|
|
704
|
+
const home = homedir3();
|
|
698
705
|
if (config.privateKeyPath) {
|
|
699
|
-
|
|
706
|
+
const keyPath = config.privateKeyPath.startsWith("~") ? join3(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
|
|
707
|
+
connectConfig.privateKey = readFileSync3(keyPath);
|
|
700
708
|
} else if (config.password) {
|
|
701
709
|
connectConfig.password = config.password;
|
|
702
710
|
} else {
|
|
@@ -704,7 +712,6 @@ function resolveConfig(config) {
|
|
|
704
712
|
if (agentSock) {
|
|
705
713
|
connectConfig.agent = agentSock;
|
|
706
714
|
}
|
|
707
|
-
const home = homedir3();
|
|
708
715
|
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join3(home, p.slice(1)) : p) : [join3(home, ".ssh", "id_ed25519"), join3(home, ".ssh", "id_rsa"), join3(home, ".ssh", "id_ecdsa")];
|
|
709
716
|
for (const keyPath of keyPaths) {
|
|
710
717
|
try {
|
|
@@ -941,10 +948,11 @@ async function writeFile(client, remotePath, content) {
|
|
|
941
948
|
}
|
|
942
949
|
}
|
|
943
950
|
async function uploadFile(client, localPath, remotePath) {
|
|
951
|
+
const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
|
|
944
952
|
const sftp = await getSftp(client);
|
|
945
953
|
try {
|
|
946
954
|
await new Promise((resolve, reject) => {
|
|
947
|
-
sftp.fastPut(
|
|
955
|
+
sftp.fastPut(resolvedLocal, remotePath, (err) => {
|
|
948
956
|
if (err) return reject(err);
|
|
949
957
|
resolve();
|
|
950
958
|
});
|
|
@@ -954,10 +962,11 @@ async function uploadFile(client, localPath, remotePath) {
|
|
|
954
962
|
}
|
|
955
963
|
}
|
|
956
964
|
async function downloadFile(client, remotePath, localPath) {
|
|
965
|
+
const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
|
|
957
966
|
const sftp = await getSftp(client);
|
|
958
967
|
try {
|
|
959
968
|
await new Promise((resolve, reject) => {
|
|
960
|
-
sftp.fastGet(remotePath,
|
|
969
|
+
sftp.fastGet(remotePath, resolvedLocal, (err) => {
|
|
961
970
|
if (err) return reject(err);
|
|
962
971
|
resolve();
|
|
963
972
|
});
|
|
@@ -1296,16 +1305,16 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
1296
1305
|
};
|
|
1297
1306
|
});
|
|
1298
1307
|
}
|
|
1299
|
-
var VALID_FIND_SIZE = /^\d+[
|
|
1308
|
+
var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
|
|
1300
1309
|
async function find(client, options, timeoutMs = 3e4) {
|
|
1301
1310
|
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
1302
1311
|
throw new Error(
|
|
1303
|
-
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G
|
|
1312
|
+
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
|
|
1304
1313
|
);
|
|
1305
1314
|
}
|
|
1306
1315
|
if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
|
|
1307
1316
|
throw new Error(
|
|
1308
|
-
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G
|
|
1317
|
+
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
|
|
1309
1318
|
);
|
|
1310
1319
|
}
|
|
1311
1320
|
const args = ["--", shellQuote(options.path)];
|
|
@@ -1386,6 +1395,9 @@ function enforcePolicy(command) {
|
|
|
1386
1395
|
|
|
1387
1396
|
// src/tools.ts
|
|
1388
1397
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
1398
|
+
var AbsoluteRemotePathSchema = z.string().refine((p) => p.startsWith("/"), {
|
|
1399
|
+
message: "Path must be absolute (start with /). SFTP does not expand ~ or resolve relative paths through a shell."
|
|
1400
|
+
});
|
|
1389
1401
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
1390
1402
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
1391
1403
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
@@ -1453,7 +1465,7 @@ ${result.stderr}`);
|
|
|
1453
1465
|
"Write content to a file on a remote host via SFTP. Creates or overwrites the file.",
|
|
1454
1466
|
{
|
|
1455
1467
|
...connectionParams,
|
|
1456
|
-
path:
|
|
1468
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
1457
1469
|
content: z.string().describe("File content to write")
|
|
1458
1470
|
},
|
|
1459
1471
|
async ({ path, content, ...conn }) => {
|
|
@@ -1469,7 +1481,7 @@ ${result.stderr}`);
|
|
|
1469
1481
|
{
|
|
1470
1482
|
...connectionParams,
|
|
1471
1483
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
1472
|
-
remotePath:
|
|
1484
|
+
remotePath: AbsoluteRemotePathSchema.describe("Absolute path on the remote host. Must start with /.")
|
|
1473
1485
|
},
|
|
1474
1486
|
async ({ localPath, remotePath, ...conn }) => {
|
|
1475
1487
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1483,7 +1495,7 @@ ${result.stderr}`);
|
|
|
1483
1495
|
"Download a file from a remote host to local filesystem via SFTP.",
|
|
1484
1496
|
{
|
|
1485
1497
|
...connectionParams,
|
|
1486
|
-
remotePath:
|
|
1498
|
+
remotePath: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
1487
1499
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
1488
1500
|
},
|
|
1489
1501
|
async ({ remotePath, localPath, ...conn }) => {
|
|
@@ -1498,7 +1510,7 @@ ${result.stderr}`);
|
|
|
1498
1510
|
"List files in a directory on a remote host via SFTP.",
|
|
1499
1511
|
{
|
|
1500
1512
|
...connectionParams,
|
|
1501
|
-
path:
|
|
1513
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
|
|
1502
1514
|
},
|
|
1503
1515
|
async ({ path, ...conn }) => {
|
|
1504
1516
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1512,7 +1524,7 @@ ${result.stderr}`);
|
|
|
1512
1524
|
"Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and type flags (isFile, isDirectory, isSymbolicLink). Use this instead of parsing `ls -la` output.",
|
|
1513
1525
|
{
|
|
1514
1526
|
...connectionParams,
|
|
1515
|
-
path:
|
|
1527
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
|
|
1516
1528
|
},
|
|
1517
1529
|
async ({ path, ...conn }) => {
|
|
1518
1530
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1549,7 +1561,9 @@ ${result.stderr}`);
|
|
|
1549
1561
|
"Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with `rm -rf` explicitly so the destructive intent is visible in the tool trace.",
|
|
1550
1562
|
{
|
|
1551
1563
|
...connectionParams,
|
|
1552
|
-
path:
|
|
1564
|
+
path: AbsoluteRemotePathSchema.describe(
|
|
1565
|
+
"Absolute path of the file or empty directory to delete. Must start with /."
|
|
1566
|
+
)
|
|
1553
1567
|
},
|
|
1554
1568
|
async ({ path, ...conn }) => {
|
|
1555
1569
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1824,8 +1838,7 @@ ${files.join("\n")}` }] };
|
|
|
1824
1838
|
}
|
|
1825
1839
|
|
|
1826
1840
|
// src/server.ts
|
|
1827
|
-
var
|
|
1828
|
-
var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1841
|
+
var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
1829
1842
|
function createServer(pool) {
|
|
1830
1843
|
const server = new McpServer({
|
|
1831
1844
|
name: "ssh-mcp",
|
|
@@ -1836,6 +1849,10 @@ function createServer(pool) {
|
|
|
1836
1849
|
}
|
|
1837
1850
|
|
|
1838
1851
|
// src/index.ts
|
|
1852
|
+
if (process.argv[2] === "--version" || process.argv[2] === "version") {
|
|
1853
|
+
console.log(version);
|
|
1854
|
+
process.exit(0);
|
|
1855
|
+
}
|
|
1839
1856
|
async function main() {
|
|
1840
1857
|
const pool = new ConnectionPool();
|
|
1841
1858
|
const server = createServer(pool);
|
package/dist/server.d.ts
CHANGED
|
@@ -217,6 +217,7 @@ declare function isPolicyConfigured(): boolean;
|
|
|
217
217
|
|
|
218
218
|
declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
|
|
219
219
|
|
|
220
|
+
declare const version: string;
|
|
220
221
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
221
222
|
|
|
222
|
-
export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FileStats, 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, deleteFile, diagnose, downloadFile, enforcePolicy, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, isPolicyConfigured, listDir, listSshKeys, loadKey, makeDir, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, statFile, tail, testConnection, uploadFile, writeFile };
|
|
223
|
+
export { type AgentResult, type ConfigLookupResult, ConnectionPool, type DiagnosticReport, type DiagnosticResult, type ExecResult, type FileStats, 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, deleteFile, diagnose, downloadFile, enforcePolicy, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, isPolicyConfigured, listDir, listSshKeys, loadKey, makeDir, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, statFile, tail, testConnection, uploadFile, version, writeFile };
|
package/dist/server.js
CHANGED
|
@@ -212,8 +212,6 @@ function checkSshConfig(host) {
|
|
|
212
212
|
if (inHostBlock) hostConfig.push(trimmed);
|
|
213
213
|
} else if (inHostBlock && trimmed) {
|
|
214
214
|
hostConfig.push(trimmed);
|
|
215
|
-
} else if (inHostBlock && !trimmed) {
|
|
216
|
-
inHostBlock = false;
|
|
217
215
|
}
|
|
218
216
|
}
|
|
219
217
|
if (hostConfig.length === 0) {
|
|
@@ -608,7 +606,15 @@ import { readFileSync as readFileSync3 } from "fs";
|
|
|
608
606
|
import { homedir as homedir3 } from "os";
|
|
609
607
|
import { join as join3 } from "path";
|
|
610
608
|
import { Client } from "ssh2";
|
|
609
|
+
var sshConfigCache = /* @__PURE__ */ new Map();
|
|
611
610
|
function resolveFromSshConfig(host) {
|
|
611
|
+
const cached = sshConfigCache.get(host);
|
|
612
|
+
if (cached !== void 0) return cached;
|
|
613
|
+
const result = resolveFromSshConfigUncached(host);
|
|
614
|
+
sshConfigCache.set(host, result);
|
|
615
|
+
return result;
|
|
616
|
+
}
|
|
617
|
+
function resolveFromSshConfigUncached(host) {
|
|
612
618
|
try {
|
|
613
619
|
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
614
620
|
if (!ok) return null;
|
|
@@ -688,8 +694,10 @@ function resolveConfig(config) {
|
|
|
688
694
|
keepaliveCountMax: 3,
|
|
689
695
|
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
690
696
|
};
|
|
697
|
+
const home = homedir3();
|
|
691
698
|
if (config.privateKeyPath) {
|
|
692
|
-
|
|
699
|
+
const keyPath = config.privateKeyPath.startsWith("~") ? join3(home, config.privateKeyPath.slice(1)) : config.privateKeyPath;
|
|
700
|
+
connectConfig.privateKey = readFileSync3(keyPath);
|
|
693
701
|
} else if (config.password) {
|
|
694
702
|
connectConfig.password = config.password;
|
|
695
703
|
} else {
|
|
@@ -697,7 +705,6 @@ function resolveConfig(config) {
|
|
|
697
705
|
if (agentSock) {
|
|
698
706
|
connectConfig.agent = agentSock;
|
|
699
707
|
}
|
|
700
|
-
const home = homedir3();
|
|
701
708
|
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join3(home, p.slice(1)) : p) : [join3(home, ".ssh", "id_ed25519"), join3(home, ".ssh", "id_rsa"), join3(home, ".ssh", "id_ecdsa")];
|
|
702
709
|
for (const keyPath of keyPaths) {
|
|
703
710
|
try {
|
|
@@ -952,10 +959,11 @@ async function writeFile(client, remotePath, content) {
|
|
|
952
959
|
}
|
|
953
960
|
}
|
|
954
961
|
async function uploadFile(client, localPath, remotePath) {
|
|
962
|
+
const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
|
|
955
963
|
const sftp = await getSftp(client);
|
|
956
964
|
try {
|
|
957
965
|
await new Promise((resolve, reject) => {
|
|
958
|
-
sftp.fastPut(
|
|
966
|
+
sftp.fastPut(resolvedLocal, remotePath, (err) => {
|
|
959
967
|
if (err) return reject(err);
|
|
960
968
|
resolve();
|
|
961
969
|
});
|
|
@@ -965,10 +973,11 @@ async function uploadFile(client, localPath, remotePath) {
|
|
|
965
973
|
}
|
|
966
974
|
}
|
|
967
975
|
async function downloadFile(client, remotePath, localPath) {
|
|
976
|
+
const resolvedLocal = localPath.startsWith("~") ? join3(homedir3(), localPath.slice(1)) : localPath;
|
|
968
977
|
const sftp = await getSftp(client);
|
|
969
978
|
try {
|
|
970
979
|
await new Promise((resolve, reject) => {
|
|
971
|
-
sftp.fastGet(remotePath,
|
|
980
|
+
sftp.fastGet(remotePath, resolvedLocal, (err) => {
|
|
972
981
|
if (err) return reject(err);
|
|
973
982
|
resolve();
|
|
974
983
|
});
|
|
@@ -1088,16 +1097,16 @@ async function multiExec(pool, hosts, command, timeoutMs = 3e4) {
|
|
|
1088
1097
|
};
|
|
1089
1098
|
});
|
|
1090
1099
|
}
|
|
1091
|
-
var VALID_FIND_SIZE = /^\d+[
|
|
1100
|
+
var VALID_FIND_SIZE = /^\d+[cwbkMG]?$/;
|
|
1092
1101
|
async function find(client, options, timeoutMs = 3e4) {
|
|
1093
1102
|
if (options.minsize && !VALID_FIND_SIZE.test(options.minsize)) {
|
|
1094
1103
|
throw new Error(
|
|
1095
|
-
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional k/M/G
|
|
1104
|
+
`Invalid minsize format: "${options.minsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "1M", "100k")`
|
|
1096
1105
|
);
|
|
1097
1106
|
}
|
|
1098
1107
|
if (options.maxsize && !VALID_FIND_SIZE.test(options.maxsize)) {
|
|
1099
1108
|
throw new Error(
|
|
1100
|
-
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G
|
|
1109
|
+
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional c/w/b/k/M/G (e.g. "10M", "500k")`
|
|
1101
1110
|
);
|
|
1102
1111
|
}
|
|
1103
1112
|
const args = ["--", shellQuote(options.path)];
|
|
@@ -1392,6 +1401,9 @@ ${diag}`);
|
|
|
1392
1401
|
|
|
1393
1402
|
// src/tools.ts
|
|
1394
1403
|
var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
1404
|
+
var AbsoluteRemotePathSchema = z.string().refine((p) => p.startsWith("/"), {
|
|
1405
|
+
message: "Path must be absolute (start with /). SFTP does not expand ~ or resolve relative paths through a shell."
|
|
1406
|
+
});
|
|
1395
1407
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
1396
1408
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
1397
1409
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
@@ -1459,7 +1471,7 @@ ${result.stderr}`);
|
|
|
1459
1471
|
"Write content to a file on a remote host via SFTP. Creates or overwrites the file.",
|
|
1460
1472
|
{
|
|
1461
1473
|
...connectionParams,
|
|
1462
|
-
path:
|
|
1474
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
1463
1475
|
content: z.string().describe("File content to write")
|
|
1464
1476
|
},
|
|
1465
1477
|
async ({ path, content, ...conn }) => {
|
|
@@ -1475,7 +1487,7 @@ ${result.stderr}`);
|
|
|
1475
1487
|
{
|
|
1476
1488
|
...connectionParams,
|
|
1477
1489
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
1478
|
-
remotePath:
|
|
1490
|
+
remotePath: AbsoluteRemotePathSchema.describe("Absolute path on the remote host. Must start with /.")
|
|
1479
1491
|
},
|
|
1480
1492
|
async ({ localPath, remotePath, ...conn }) => {
|
|
1481
1493
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1489,7 +1501,7 @@ ${result.stderr}`);
|
|
|
1489
1501
|
"Download a file from a remote host to local filesystem via SFTP.",
|
|
1490
1502
|
{
|
|
1491
1503
|
...connectionParams,
|
|
1492
|
-
remotePath:
|
|
1504
|
+
remotePath: AbsoluteRemotePathSchema.describe("Absolute path to the remote file. Must start with /."),
|
|
1493
1505
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
1494
1506
|
},
|
|
1495
1507
|
async ({ remotePath, localPath, ...conn }) => {
|
|
@@ -1504,7 +1516,7 @@ ${result.stderr}`);
|
|
|
1504
1516
|
"List files in a directory on a remote host via SFTP.",
|
|
1505
1517
|
{
|
|
1506
1518
|
...connectionParams,
|
|
1507
|
-
path:
|
|
1519
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote directory. Must start with /.")
|
|
1508
1520
|
},
|
|
1509
1521
|
async ({ path, ...conn }) => {
|
|
1510
1522
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1518,7 +1530,7 @@ ${result.stderr}`);
|
|
|
1518
1530
|
"Get metadata for a file or directory on a remote host via SFTP. Returns size, permissions (octal), uid/gid, mtime/atime, and type flags (isFile, isDirectory, isSymbolicLink). Use this instead of parsing `ls -la` output.",
|
|
1519
1531
|
{
|
|
1520
1532
|
...connectionParams,
|
|
1521
|
-
path:
|
|
1533
|
+
path: AbsoluteRemotePathSchema.describe("Absolute path to the remote file or directory. Must start with /.")
|
|
1522
1534
|
},
|
|
1523
1535
|
async ({ path, ...conn }) => {
|
|
1524
1536
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1555,7 +1567,9 @@ ${result.stderr}`);
|
|
|
1555
1567
|
"Delete a file or empty directory on a remote host via SFTP. Auto-detects the path type and calls the right SFTP op (unlink for files/symlinks, rmdir for empty dirs). Recursive directory delete is intentionally NOT supported -- for that, use ssh_exec with `rm -rf` explicitly so the destructive intent is visible in the tool trace.",
|
|
1556
1568
|
{
|
|
1557
1569
|
...connectionParams,
|
|
1558
|
-
path:
|
|
1570
|
+
path: AbsoluteRemotePathSchema.describe(
|
|
1571
|
+
"Absolute path of the file or empty directory to delete. Must start with /."
|
|
1572
|
+
)
|
|
1559
1573
|
},
|
|
1560
1574
|
async ({ path, ...conn }) => {
|
|
1561
1575
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1830,8 +1844,7 @@ ${files.join("\n")}` }] };
|
|
|
1830
1844
|
}
|
|
1831
1845
|
|
|
1832
1846
|
// src/server.ts
|
|
1833
|
-
var
|
|
1834
|
-
var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1847
|
+
var version = typeof __VERSION__ !== "undefined" ? __VERSION__ : JSON.parse(readFileSync4(join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
1835
1848
|
function createServer(pool) {
|
|
1836
1849
|
const server = new McpServer({
|
|
1837
1850
|
name: "ssh-mcp",
|
|
@@ -1877,5 +1890,6 @@ export {
|
|
|
1877
1890
|
tail,
|
|
1878
1891
|
testConnection,
|
|
1879
1892
|
uploadFile,
|
|
1893
|
+
version,
|
|
1880
1894
|
writeFile
|
|
1881
1895
|
};
|
package/package.json
CHANGED
|
@@ -1,62 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@yawlabs/ssh-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"mcpName": "io.github.YawLabs/ssh-mcp",
|
|
5
|
-
"description": "MCP server for SSH operations with built-in diagnostics",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"ssh-mcp": "
|
|
9
|
-
},
|
|
10
|
-
"exports": {
|
|
11
|
-
".": {
|
|
12
|
-
"import": "./dist/server.js",
|
|
13
|
-
"types": "./dist/server.d.ts"
|
|
14
|
-
}
|
|
15
|
-
},
|
|
16
|
-
"files": [
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"lint
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"test
|
|
29
|
-
"test:
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"@
|
|
57
|
-
"@types/
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@yawlabs/ssh-mcp",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"mcpName": "io.github.YawLabs/ssh-mcp",
|
|
5
|
+
"description": "MCP server for SSH operations with built-in diagnostics",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ssh-mcp": "bin/ssh-mcp.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/server.js",
|
|
13
|
+
"types": "./dist/server.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/ssh-mcp.mjs",
|
|
18
|
+
"dist",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsup",
|
|
24
|
+
"dev": "tsup --watch",
|
|
25
|
+
"lint": "biome check src/",
|
|
26
|
+
"lint:fix": "biome check --write src/",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:integration": "docker compose -f test/docker/docker-compose.yml up -d --build --wait && SSH_MCP_INTEGRATION=1 vitest run src/tests/integration.test.ts; docker compose -f test/docker/docker-compose.yml down",
|
|
30
|
+
"test:ci": "npm run build && npm test",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mcp",
|
|
35
|
+
"ssh",
|
|
36
|
+
"remote",
|
|
37
|
+
"model-context-protocol",
|
|
38
|
+
"ai",
|
|
39
|
+
"devops"
|
|
40
|
+
],
|
|
41
|
+
"author": "Yaw Labs <contact@yaw.sh>",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/YawLabs/ssh-mcp.git"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
|
+
"ssh2": "^1.17.0",
|
|
53
|
+
"zod": "^4.4.3"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@biomejs/biome": "^2.4.15",
|
|
57
|
+
"@types/node": "^26.1.1",
|
|
58
|
+
"@types/ssh2": "^1.15.5",
|
|
59
|
+
"esbuild": "^0.28.1",
|
|
60
|
+
"postject": "^1.0.0-alpha.6",
|
|
61
|
+
"tsup": "^8.5.1",
|
|
62
|
+
"typescript": "^7.0.2",
|
|
63
|
+
"vitest": "^4.1.6"
|
|
64
|
+
},
|
|
65
|
+
"overrides": {
|
|
66
|
+
"esbuild": "^0.28.1"
|
|
67
|
+
}
|
|
68
|
+
}
|