@yawlabs/ssh-mcp 0.10.0 → 0.11.1
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 +31 -1
- package/dist/index.js +134 -4
- package/dist/server.d.ts +20 -1
- package/dist/server.js +137 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
Built and maintained by [Yaw Labs](https://yaw.sh).
|
|
9
9
|
|
|
10
|
+
[](https://mcp.hosting/install?name=SSH&command=npx&args=-y%2C%40yawlabs%2Fssh-mcp&description=Run%20commands%20on%20remote%20hosts%2C%20transfer%20files%2C%20manage%20SSH%20tunnels%20and%20keys&source=https%3A%2F%2Fgithub.com%2FYawLabs%2Fssh-mcp)
|
|
11
|
+
|
|
12
|
+
One click adds this to your [mcp.hosting](https://mcp.hosting) account so it syncs to every MCP client you use. Or install manually below.
|
|
13
|
+
|
|
10
14
|
## The problem
|
|
11
15
|
|
|
12
16
|
AI CLI tools run in subprocesses where SSH is constantly broken. The agent tries to `git pull` and gets `Permission denied (publickey)`. It tries to SSH into a server and the agent socket is stale. It tries to deploy and the host key changed because the instance was recreated. Every time, the AI has no idea what's wrong and spirals.
|
|
@@ -66,12 +70,15 @@ Tools that fix your local SSH setup so everything else — git, deploys, tunnels
|
|
|
66
70
|
|
|
67
71
|
| Tool | Description |
|
|
68
72
|
|------|-------------|
|
|
69
|
-
| `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code (or `[signal: NAME]` and `code: -1` when the channel closed signal-only). Subject to [command policy](#command-policy) if configured. |
|
|
73
|
+
| `ssh_exec` | Execute a command on a remote host. Returns stdout, stderr, and exit code (or `[signal: NAME]` and `code: -1` when the channel closed signal-only). Optional `env` param sets per-call environment variables (POSIX-safe prefix, works regardless of sshd's `AcceptEnv`). Subject to [command policy](#command-policy) if configured. |
|
|
70
74
|
| `ssh_read_file` | Read a file from a remote host via SFTP. |
|
|
71
75
|
| `ssh_write_file` | Write content to a file on a remote host via SFTP. |
|
|
72
76
|
| `ssh_upload` | Upload a local file to a remote host via SFTP. |
|
|
73
77
|
| `ssh_download` | Download a file from a remote host to local filesystem. |
|
|
74
78
|
| `ssh_ls` | List files in a directory on a remote host. |
|
|
79
|
+
| `ssh_stat` | Get metadata for a file or directory (size, mode in octal, uid/gid, mtime/atime, isFile/isDirectory/isSymbolicLink). Use instead of parsing `ls -la`. |
|
|
80
|
+
| `ssh_mkdir` | Create a directory via SFTP. Set `recursive: true` for `mkdir -p` behavior. |
|
|
81
|
+
| `ssh_delete` | Delete a file or empty directory via SFTP. Auto-dispatches unlink vs rmdir based on the path's type. Recursive directory delete is intentionally NOT supported -- use `ssh_exec rm -rf` if you need it. |
|
|
75
82
|
|
|
76
83
|
### Higher-level operations
|
|
77
84
|
|
|
@@ -135,6 +142,29 @@ Blocked commands surface as a clear error mentioning which pattern (or which env
|
|
|
135
142
|
|
|
136
143
|
The structured higher-level tools (`ssh_find`, `ssh_tail`, `ssh_service_status`, SFTP ops) are exempt from policy. They build commands from typed parameters, so a tight `^ls` whitelist would otherwise force you to allow `^find `, `^tail `, `^systemctl ` just to keep those tools working — defeating the point of a tight whitelist.
|
|
137
144
|
|
|
145
|
+
#### Policy interaction with `ssh_exec`'s `env` parameter
|
|
146
|
+
|
|
147
|
+
When `ssh_exec` is called with `env: { KEY: "value" }`, the values are injected as a `KEY='value' ...` shell prefix before the command (see the `ssh_exec` description). **Policy is checked against the full prefixed command**, not the bare `command` argument. That's the safer ordering at the protocol layer — but it means whitelist patterns need to anticipate the prefix and must be **anchored**, not substring matches:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# WRONG -- blocks any ssh_exec call that uses `env`, because the final command
|
|
151
|
+
# starts with `KEY='value' ` and never matches `^ls`.
|
|
152
|
+
SSH_MCP_COMMAND_WHITELIST="^ls "
|
|
153
|
+
|
|
154
|
+
# RIGHT -- allow zero or more `KEY='value' ` prefixes before the real command.
|
|
155
|
+
SSH_MCP_COMMAND_WHITELIST="^([A-Za-z_][A-Za-z0-9_]*='[^']*' )*ls( |$)"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Avoid substring-match patterns** like ` ls ` if you're worried about a hostile agent. An agent could pass `env: { ATTACK: " ls " }` to make the final command `ATTACK=' ls ' rm -rf /`, which matches a substring ` ls ` and bypasses the whitelist. Anchored patterns of the form above don't have this weakness because they require the real command name to follow the env-prefix block, not appear inside a quoted env value.
|
|
159
|
+
|
|
160
|
+
Blacklists need the same care. `^rm ` blocks a bare `rm` call, but doesn't block `FOO='bar' rm`. Use the same env-prefix-tolerant anchor:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
SSH_MCP_COMMAND_BLACKLIST="^([A-Za-z_][A-Za-z0-9_]*='[^']*' )*rm( |$)"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
If you don't trust the agent's `env` values at all, the simplest mitigation is to leave `env` unused in your client config and pass everything through the `command` string yourself.
|
|
167
|
+
|
|
138
168
|
### Windows support
|
|
139
169
|
|
|
140
170
|
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
|
@@ -953,6 +953,77 @@ async function listDir(client, remotePath) {
|
|
|
953
953
|
sftp.end();
|
|
954
954
|
}
|
|
955
955
|
}
|
|
956
|
+
async function statFile(client, remotePath) {
|
|
957
|
+
const sftp = await getSftp(client);
|
|
958
|
+
try {
|
|
959
|
+
return await new Promise((resolve, reject) => {
|
|
960
|
+
sftp.stat(remotePath, (err, stats) => {
|
|
961
|
+
if (err) return reject(err);
|
|
962
|
+
resolve({
|
|
963
|
+
size: stats.size,
|
|
964
|
+
mode: stats.mode,
|
|
965
|
+
modeOctal: (stats.mode & 4095).toString(8).padStart(4, "0"),
|
|
966
|
+
uid: stats.uid,
|
|
967
|
+
gid: stats.gid,
|
|
968
|
+
mtime: stats.mtime,
|
|
969
|
+
atime: stats.atime,
|
|
970
|
+
isFile: stats.isFile(),
|
|
971
|
+
isDirectory: stats.isDirectory(),
|
|
972
|
+
isSymbolicLink: stats.isSymbolicLink()
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
} finally {
|
|
977
|
+
sftp.end();
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
async function deleteFile(client, remotePath) {
|
|
981
|
+
const sftp = await getSftp(client);
|
|
982
|
+
try {
|
|
983
|
+
const stats = await new Promise((resolve, reject) => {
|
|
984
|
+
sftp.stat(remotePath, (err, stats2) => {
|
|
985
|
+
if (err) return reject(err);
|
|
986
|
+
resolve(stats2);
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
await new Promise((resolve, reject) => {
|
|
990
|
+
const done = (err) => err ? reject(err) : resolve();
|
|
991
|
+
if (stats.isDirectory()) {
|
|
992
|
+
sftp.rmdir(remotePath, done);
|
|
993
|
+
} else {
|
|
994
|
+
sftp.unlink(remotePath, done);
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
} finally {
|
|
998
|
+
sftp.end();
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
async function makeDir(client, remotePath, recursive = false) {
|
|
1002
|
+
const sftp = await getSftp(client);
|
|
1003
|
+
try {
|
|
1004
|
+
const mkOne = (path) => new Promise((resolve, reject) => {
|
|
1005
|
+
sftp.mkdir(path, (err) => err ? reject(err) : resolve());
|
|
1006
|
+
});
|
|
1007
|
+
if (!recursive) {
|
|
1008
|
+
await mkOne(remotePath);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const isAbsolute = remotePath.startsWith("/");
|
|
1012
|
+
const parts = remotePath.split("/").filter(Boolean);
|
|
1013
|
+
let cur = isAbsolute ? "" : ".";
|
|
1014
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1015
|
+
cur = isAbsolute ? `${cur}/${parts[i]}` : `${cur}/${parts[i]}`;
|
|
1016
|
+
const isLeaf = i === parts.length - 1;
|
|
1017
|
+
try {
|
|
1018
|
+
await mkOne(cur);
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
if (isLeaf) throw e;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
} finally {
|
|
1024
|
+
sftp.end();
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
956
1027
|
|
|
957
1028
|
// src/pool.ts
|
|
958
1029
|
function defaultMaxPoolSize() {
|
|
@@ -1291,16 +1362,24 @@ function registerTools(server, pool) {
|
|
|
1291
1362
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1292
1363
|
server.tool(
|
|
1293
1364
|
"ssh_exec",
|
|
1294
|
-
"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. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured.",
|
|
1365
|
+
"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. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
|
|
1295
1366
|
{
|
|
1296
1367
|
...connectionParams,
|
|
1297
1368
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1369
|
+
env: z.record(z.string(), z.string()).optional().describe(
|
|
1370
|
+
"Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. Values are POSIX-single-quoted, so any byte is safe."
|
|
1371
|
+
),
|
|
1298
1372
|
timeout: TimeoutSchema
|
|
1299
1373
|
},
|
|
1300
|
-
async ({ command, timeout, ...conn }) => {
|
|
1301
|
-
|
|
1374
|
+
async ({ command, env, timeout, ...conn }) => {
|
|
1375
|
+
let finalCommand = command;
|
|
1376
|
+
if (env && Object.keys(env).length > 0) {
|
|
1377
|
+
const prefix = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ");
|
|
1378
|
+
finalCommand = `${prefix} ${command}`;
|
|
1379
|
+
}
|
|
1380
|
+
enforcePolicy(finalCommand);
|
|
1302
1381
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1303
|
-
const result = await exec(client,
|
|
1382
|
+
const result = await exec(client, finalCommand, timeout || 3e4);
|
|
1304
1383
|
const parts = [];
|
|
1305
1384
|
if (result.stdout) parts.push(result.stdout);
|
|
1306
1385
|
if (result.stderr) parts.push(`[stderr]
|
|
@@ -1384,6 +1463,57 @@ ${result.stderr}`);
|
|
|
1384
1463
|
});
|
|
1385
1464
|
}
|
|
1386
1465
|
);
|
|
1466
|
+
server.tool(
|
|
1467
|
+
"ssh_stat",
|
|
1468
|
+
"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.",
|
|
1469
|
+
{
|
|
1470
|
+
...connectionParams,
|
|
1471
|
+
path: z.string().describe("Absolute path to the remote file or directory")
|
|
1472
|
+
},
|
|
1473
|
+
async ({ path, ...conn }) => {
|
|
1474
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1475
|
+
const stats = await statFile(client, path);
|
|
1476
|
+
const lines = [];
|
|
1477
|
+
const kind = stats.isDirectory ? "directory" : stats.isSymbolicLink ? "symlink" : stats.isFile ? "file" : "other";
|
|
1478
|
+
lines.push(`${path}: ${kind}`);
|
|
1479
|
+
lines.push(` Size: ${stats.size} bytes`);
|
|
1480
|
+
lines.push(` Mode: ${stats.modeOctal}`);
|
|
1481
|
+
lines.push(` Owner: uid=${stats.uid} gid=${stats.gid}`);
|
|
1482
|
+
lines.push(` Modified: ${new Date(stats.mtime * 1e3).toISOString()}`);
|
|
1483
|
+
lines.push(` Accessed: ${new Date(stats.atime * 1e3).toISOString()}`);
|
|
1484
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
);
|
|
1488
|
+
server.tool(
|
|
1489
|
+
"ssh_mkdir",
|
|
1490
|
+
"Create a directory on a remote host via SFTP. Set `recursive: true` to create parent directories as needed (like `mkdir -p`). Existing intermediate dirs are tolerated; an existing leaf path is still an error.",
|
|
1491
|
+
{
|
|
1492
|
+
...connectionParams,
|
|
1493
|
+
path: z.string().describe("Absolute path of the directory to create"),
|
|
1494
|
+
recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
|
|
1495
|
+
},
|
|
1496
|
+
async ({ path, recursive, ...conn }) => {
|
|
1497
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1498
|
+
await makeDir(client, path, recursive ?? false);
|
|
1499
|
+
return { content: [{ type: "text", text: `Created directory ${path}` }] };
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
);
|
|
1503
|
+
server.tool(
|
|
1504
|
+
"ssh_delete",
|
|
1505
|
+
"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.",
|
|
1506
|
+
{
|
|
1507
|
+
...connectionParams,
|
|
1508
|
+
path: z.string().describe("Absolute path of the file or empty directory to delete")
|
|
1509
|
+
},
|
|
1510
|
+
async ({ path, ...conn }) => {
|
|
1511
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1512
|
+
await deleteFile(client, path);
|
|
1513
|
+
return { content: [{ type: "text", text: `Deleted ${path}` }] };
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
);
|
|
1387
1517
|
server.tool(
|
|
1388
1518
|
"ssh_diagnose",
|
|
1389
1519
|
"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.",
|
package/dist/server.d.ts
CHANGED
|
@@ -36,6 +36,25 @@ declare function writeFile(client: Client, remotePath: string, content: string):
|
|
|
36
36
|
declare function uploadFile(client: Client, localPath: string, remotePath: string): Promise<void>;
|
|
37
37
|
declare function downloadFile(client: Client, remotePath: string, localPath: string): Promise<void>;
|
|
38
38
|
declare function listDir(client: Client, remotePath: string): Promise<string[]>;
|
|
39
|
+
interface FileStats {
|
|
40
|
+
size: number;
|
|
41
|
+
/** POSIX mode as a decimal number. Use modeOctal for the human-readable form. */
|
|
42
|
+
mode: number;
|
|
43
|
+
/** POSIX mode formatted as a 4-digit octal string (e.g. "0755"). */
|
|
44
|
+
modeOctal: string;
|
|
45
|
+
uid: number;
|
|
46
|
+
gid: number;
|
|
47
|
+
/** Unix timestamp (seconds since epoch) of last modification. */
|
|
48
|
+
mtime: number;
|
|
49
|
+
/** Unix timestamp (seconds since epoch) of last access. */
|
|
50
|
+
atime: number;
|
|
51
|
+
isFile: boolean;
|
|
52
|
+
isDirectory: boolean;
|
|
53
|
+
isSymbolicLink: boolean;
|
|
54
|
+
}
|
|
55
|
+
declare function statFile(client: Client, remotePath: string): Promise<FileStats>;
|
|
56
|
+
declare function deleteFile(client: Client, remotePath: string): Promise<void>;
|
|
57
|
+
declare function makeDir(client: Client, remotePath: string, recursive?: boolean): Promise<void>;
|
|
39
58
|
|
|
40
59
|
interface PoolOptions {
|
|
41
60
|
/** Milliseconds before an idle connection is closed. Default: 60000 (60s) */
|
|
@@ -200,4 +219,4 @@ declare function registerTools(server: McpServer, pool?: ConnectionPool): void;
|
|
|
200
219
|
|
|
201
220
|
declare function createServer(pool?: ConnectionPool): McpServer;
|
|
202
221
|
|
|
203
|
-
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, enforcePolicy, ensureAgent, exec, find, fixKnownHosts, formatDiagnostics, isPolicyConfigured, listDir, listSshKeys, loadKey, multiExec, readFile, readKnownHostsKeys, registerTools, resolveConfig, serviceStatus, tail, testConnection, uploadFile, writeFile };
|
|
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 };
|
package/dist/server.js
CHANGED
|
@@ -967,6 +967,77 @@ async function listDir(client, remotePath) {
|
|
|
967
967
|
sftp.end();
|
|
968
968
|
}
|
|
969
969
|
}
|
|
970
|
+
async function statFile(client, remotePath) {
|
|
971
|
+
const sftp = await getSftp(client);
|
|
972
|
+
try {
|
|
973
|
+
return await new Promise((resolve, reject) => {
|
|
974
|
+
sftp.stat(remotePath, (err, stats) => {
|
|
975
|
+
if (err) return reject(err);
|
|
976
|
+
resolve({
|
|
977
|
+
size: stats.size,
|
|
978
|
+
mode: stats.mode,
|
|
979
|
+
modeOctal: (stats.mode & 4095).toString(8).padStart(4, "0"),
|
|
980
|
+
uid: stats.uid,
|
|
981
|
+
gid: stats.gid,
|
|
982
|
+
mtime: stats.mtime,
|
|
983
|
+
atime: stats.atime,
|
|
984
|
+
isFile: stats.isFile(),
|
|
985
|
+
isDirectory: stats.isDirectory(),
|
|
986
|
+
isSymbolicLink: stats.isSymbolicLink()
|
|
987
|
+
});
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
} finally {
|
|
991
|
+
sftp.end();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async function deleteFile(client, remotePath) {
|
|
995
|
+
const sftp = await getSftp(client);
|
|
996
|
+
try {
|
|
997
|
+
const stats = await new Promise((resolve, reject) => {
|
|
998
|
+
sftp.stat(remotePath, (err, stats2) => {
|
|
999
|
+
if (err) return reject(err);
|
|
1000
|
+
resolve(stats2);
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
1003
|
+
await new Promise((resolve, reject) => {
|
|
1004
|
+
const done = (err) => err ? reject(err) : resolve();
|
|
1005
|
+
if (stats.isDirectory()) {
|
|
1006
|
+
sftp.rmdir(remotePath, done);
|
|
1007
|
+
} else {
|
|
1008
|
+
sftp.unlink(remotePath, done);
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
} finally {
|
|
1012
|
+
sftp.end();
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
async function makeDir(client, remotePath, recursive = false) {
|
|
1016
|
+
const sftp = await getSftp(client);
|
|
1017
|
+
try {
|
|
1018
|
+
const mkOne = (path) => new Promise((resolve, reject) => {
|
|
1019
|
+
sftp.mkdir(path, (err) => err ? reject(err) : resolve());
|
|
1020
|
+
});
|
|
1021
|
+
if (!recursive) {
|
|
1022
|
+
await mkOne(remotePath);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const isAbsolute = remotePath.startsWith("/");
|
|
1026
|
+
const parts = remotePath.split("/").filter(Boolean);
|
|
1027
|
+
let cur = isAbsolute ? "" : ".";
|
|
1028
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1029
|
+
cur = isAbsolute ? `${cur}/${parts[i]}` : `${cur}/${parts[i]}`;
|
|
1030
|
+
const isLeaf = i === parts.length - 1;
|
|
1031
|
+
try {
|
|
1032
|
+
await mkOne(cur);
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
if (isLeaf) throw e;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
} finally {
|
|
1038
|
+
sftp.end();
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
970
1041
|
|
|
971
1042
|
// src/ops.ts
|
|
972
1043
|
function shellQuote(s) {
|
|
@@ -1299,16 +1370,24 @@ function registerTools(server, pool) {
|
|
|
1299
1370
|
const connectionPool = pool ?? new ConnectionPool();
|
|
1300
1371
|
server.tool(
|
|
1301
1372
|
"ssh_exec",
|
|
1302
|
-
"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. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured.",
|
|
1373
|
+
"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. Use `env` to set environment variables for this call without modifying the command string. Subject to SSH_MCP_COMMAND_WHITELIST / SSH_MCP_COMMAND_BLACKLIST if configured (policy is checked against the env-prefixed command).",
|
|
1303
1374
|
{
|
|
1304
1375
|
...connectionParams,
|
|
1305
1376
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1377
|
+
env: z.record(z.string(), z.string()).optional().describe(
|
|
1378
|
+
"Environment variables to set for this command. Injected as a `KEY='value' ...` prefix; works on any sshd regardless of AcceptEnv config. Values are POSIX-single-quoted, so any byte is safe."
|
|
1379
|
+
),
|
|
1306
1380
|
timeout: TimeoutSchema
|
|
1307
1381
|
},
|
|
1308
|
-
async ({ command, timeout, ...conn }) => {
|
|
1309
|
-
|
|
1382
|
+
async ({ command, env, timeout, ...conn }) => {
|
|
1383
|
+
let finalCommand = command;
|
|
1384
|
+
if (env && Object.keys(env).length > 0) {
|
|
1385
|
+
const prefix = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ");
|
|
1386
|
+
finalCommand = `${prefix} ${command}`;
|
|
1387
|
+
}
|
|
1388
|
+
enforcePolicy(finalCommand);
|
|
1310
1389
|
return connectionPool.withConnection(conn, async (client) => {
|
|
1311
|
-
const result = await exec(client,
|
|
1390
|
+
const result = await exec(client, finalCommand, timeout || 3e4);
|
|
1312
1391
|
const parts = [];
|
|
1313
1392
|
if (result.stdout) parts.push(result.stdout);
|
|
1314
1393
|
if (result.stderr) parts.push(`[stderr]
|
|
@@ -1392,6 +1471,57 @@ ${result.stderr}`);
|
|
|
1392
1471
|
});
|
|
1393
1472
|
}
|
|
1394
1473
|
);
|
|
1474
|
+
server.tool(
|
|
1475
|
+
"ssh_stat",
|
|
1476
|
+
"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.",
|
|
1477
|
+
{
|
|
1478
|
+
...connectionParams,
|
|
1479
|
+
path: z.string().describe("Absolute path to the remote file or directory")
|
|
1480
|
+
},
|
|
1481
|
+
async ({ path, ...conn }) => {
|
|
1482
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1483
|
+
const stats = await statFile(client, path);
|
|
1484
|
+
const lines = [];
|
|
1485
|
+
const kind = stats.isDirectory ? "directory" : stats.isSymbolicLink ? "symlink" : stats.isFile ? "file" : "other";
|
|
1486
|
+
lines.push(`${path}: ${kind}`);
|
|
1487
|
+
lines.push(` Size: ${stats.size} bytes`);
|
|
1488
|
+
lines.push(` Mode: ${stats.modeOctal}`);
|
|
1489
|
+
lines.push(` Owner: uid=${stats.uid} gid=${stats.gid}`);
|
|
1490
|
+
lines.push(` Modified: ${new Date(stats.mtime * 1e3).toISOString()}`);
|
|
1491
|
+
lines.push(` Accessed: ${new Date(stats.atime * 1e3).toISOString()}`);
|
|
1492
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
);
|
|
1496
|
+
server.tool(
|
|
1497
|
+
"ssh_mkdir",
|
|
1498
|
+
"Create a directory on a remote host via SFTP. Set `recursive: true` to create parent directories as needed (like `mkdir -p`). Existing intermediate dirs are tolerated; an existing leaf path is still an error.",
|
|
1499
|
+
{
|
|
1500
|
+
...connectionParams,
|
|
1501
|
+
path: z.string().describe("Absolute path of the directory to create"),
|
|
1502
|
+
recursive: z.boolean().optional().describe("Create parent directories as needed (default: false). Like `mkdir -p`.")
|
|
1503
|
+
},
|
|
1504
|
+
async ({ path, recursive, ...conn }) => {
|
|
1505
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1506
|
+
await makeDir(client, path, recursive ?? false);
|
|
1507
|
+
return { content: [{ type: "text", text: `Created directory ${path}` }] };
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
);
|
|
1511
|
+
server.tool(
|
|
1512
|
+
"ssh_delete",
|
|
1513
|
+
"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.",
|
|
1514
|
+
{
|
|
1515
|
+
...connectionParams,
|
|
1516
|
+
path: z.string().describe("Absolute path of the file or empty directory to delete")
|
|
1517
|
+
},
|
|
1518
|
+
async ({ path, ...conn }) => {
|
|
1519
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1520
|
+
await deleteFile(client, path);
|
|
1521
|
+
return { content: [{ type: "text", text: `Deleted ${path}` }] };
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
);
|
|
1395
1525
|
server.tool(
|
|
1396
1526
|
"ssh_diagnose",
|
|
1397
1527
|
"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.",
|
|
@@ -1681,6 +1811,7 @@ export {
|
|
|
1681
1811
|
connectRaw,
|
|
1682
1812
|
connectWithProxy,
|
|
1683
1813
|
createServer,
|
|
1814
|
+
deleteFile,
|
|
1684
1815
|
diagnose,
|
|
1685
1816
|
downloadFile,
|
|
1686
1817
|
enforcePolicy,
|
|
@@ -1693,12 +1824,14 @@ export {
|
|
|
1693
1824
|
listDir,
|
|
1694
1825
|
listSshKeys,
|
|
1695
1826
|
loadKey,
|
|
1827
|
+
makeDir,
|
|
1696
1828
|
multiExec,
|
|
1697
1829
|
readFile,
|
|
1698
1830
|
readKnownHostsKeys,
|
|
1699
1831
|
registerTools,
|
|
1700
1832
|
resolveConfig,
|
|
1701
1833
|
serviceStatus,
|
|
1834
|
+
statFile,
|
|
1702
1835
|
tail,
|
|
1703
1836
|
testConnection,
|
|
1704
1837
|
uploadFile,
|