@aaarc/handfree-ssh-mcp 1.0.3 → 1.0.5
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 +8 -4
- package/build/config/config-loader.js +12 -0
- package/build/config/server.js +1 -1
- package/build/services/ssh-connection-manager.js +25 -30
- package/build/tools/download.js +2 -2
- package/build/tools/show-whitelist.js +27 -17
- package/build/tools/transfer.js +3 -3
- package/build/tools/upload.js +2 -2
- package/package.json +61 -61
package/README.md
CHANGED
|
@@ -216,7 +216,8 @@ servers:
|
|
|
216
216
|
|
|
217
217
|
# SFTP path policy — applies ONLY to upload / download / transfer.
|
|
218
218
|
# execute-command is NOT affected by these lists.
|
|
219
|
-
#
|
|
219
|
+
# Default is OPEN: with `allowedRemoteDirectories` unset/empty, any absolute
|
|
220
|
+
# POSIX remote path is allowed. Configure it to opt into an allowlist instead.
|
|
220
221
|
allowedRemoteDirectories:
|
|
221
222
|
- /home/user
|
|
222
223
|
- /tmp
|
|
@@ -224,20 +225,23 @@ servers:
|
|
|
224
225
|
# The MCP working directory is always permitted implicitly.
|
|
225
226
|
allowedLocalDirectories:
|
|
226
227
|
- /path/to/extra/local/dir
|
|
228
|
+
# Bypasses both the remote allowlist and the local directory check entirely.
|
|
229
|
+
# disableSftpPathPolicy: true
|
|
227
230
|
```
|
|
228
231
|
|
|
229
232
|
### Security note: command policy
|
|
230
233
|
|
|
231
|
-
`execute-command` defaults to `commandMode: blacklist`. In that mode commands are allowed unless they match built-in destructive guards, the built-in dangerous-command blacklist, or a server's configured `blacklist:` patterns. Built-in blocked operations include hidden/chained destructive file operations, risky absolute-path output redirection, system power commands (`reboot`, `shutdown`, `halt`, `poweroff`), recursive force delete (`rm -rf`), destructive disk writes (`dd ... of=`), and filesystem formatting commands. Set `commandMode: whitelist` to require every command to match `whitelist:` after blacklist checks. For compatibility, a YAML server that contains `whitelist:` without `commandMode:` is treated as whitelist mode.
|
|
234
|
+
`execute-command` defaults to `commandMode: blacklist`. In that mode commands are allowed unless they match built-in destructive guards, the built-in dangerous-command blacklist, or a server's configured `blacklist:` patterns. Built-in blocked operations include hidden/chained destructive file operations, risky absolute-path output redirection, system power commands (`reboot`, `shutdown`, `halt`, `poweroff`), recursive force delete (`rm -rf`), destructive disk writes (`dd ... of=`), and filesystem formatting commands. Set `commandMode: whitelist` to require every command to match `whitelist:` after blacklist checks. For compatibility, a YAML server that contains `whitelist:` without `commandMode:` is treated as whitelist mode. Set `disableBuiltinGuards: true` and/or `disableBuiltinBlacklist: true` to turn off the built-in guards/blacklist for a server (your own `blacklist:`/`whitelist:` still apply).
|
|
232
235
|
|
|
233
236
|
### SFTP path policy
|
|
234
237
|
|
|
235
238
|
| Field | Scope | Default behavior |
|
|
236
239
|
|---|---|---|
|
|
237
|
-
| `allowedRemoteDirectories` | `upload` / `download` / `transfer` only | **Unset =
|
|
240
|
+
| `allowedRemoteDirectories` | `upload` / `download` / `transfer` only | **Unset/empty = open.** Any absolute POSIX path is allowed. Configuring this list opts into restricting SFTP to those directories. |
|
|
238
241
|
| `allowedLocalDirectories` | `upload` / `download` only | Unset = only the MCP working directory is allowed. |
|
|
242
|
+
| `disableSftpPathPolicy` | all SFTP tools | When `true`, bypasses both checks above entirely — any remote path, any local path. |
|
|
239
243
|
|
|
240
|
-
Path matching is exact-equal or `dir + separator` prefix. `..` segments and null bytes are rejected. Use `show-whitelist` to inspect a server's current SFTP policy.
|
|
244
|
+
Path matching (when an allowlist is configured) is exact-equal or `dir + separator` prefix. `..` segments and null bytes are always rejected regardless of policy. Use `show-whitelist` to inspect a server's current SFTP policy.
|
|
241
245
|
|
|
242
246
|
### Jump host (ProxyJump-style)
|
|
243
247
|
|
|
@@ -148,6 +148,12 @@ function buildYamlServerConfig(name, serverConfig, options = {}) {
|
|
|
148
148
|
if (serverConfig.blacklist !== undefined) {
|
|
149
149
|
merged.commandBlacklist = serverConfig.blacklist ?? undefined;
|
|
150
150
|
}
|
|
151
|
+
if (serverConfig.disableBuiltinGuards !== undefined) {
|
|
152
|
+
merged.disableBuiltinGuards = serverConfig.disableBuiltinGuards === true;
|
|
153
|
+
}
|
|
154
|
+
if (serverConfig.disableBuiltinBlacklist !== undefined) {
|
|
155
|
+
merged.disableBuiltinBlacklist = serverConfig.disableBuiltinBlacklist === true;
|
|
156
|
+
}
|
|
151
157
|
if (serverConfig.safeDirectory !== undefined) {
|
|
152
158
|
merged.safeDirectory = serverConfig.safeDirectory ?? undefined;
|
|
153
159
|
}
|
|
@@ -157,6 +163,9 @@ function buildYamlServerConfig(name, serverConfig, options = {}) {
|
|
|
157
163
|
if (serverConfig.allowedLocalDirectories !== undefined) {
|
|
158
164
|
merged.allowedLocalDirectories = normalizeAllowedLocalDirectories(name, serverConfig.allowedLocalDirectories);
|
|
159
165
|
}
|
|
166
|
+
if (serverConfig.disableSftpPathPolicy !== undefined) {
|
|
167
|
+
merged.disableSftpPathPolicy = serverConfig.disableSftpPathPolicy === true;
|
|
168
|
+
}
|
|
160
169
|
if (!allowPartial) {
|
|
161
170
|
Logger.log(`Loaded server config: ${name} -> ${merged.username}@${merged.host}:${merged.port}`, "info");
|
|
162
171
|
}
|
|
@@ -175,6 +184,9 @@ function mergeConfigMaps(sshConfigs, yamlConfigs) {
|
|
|
175
184
|
commandMode: yamlConfig.commandMode ?? merged[name]?.commandMode,
|
|
176
185
|
commandWhitelist: yamlConfig.commandWhitelist ?? merged[name]?.commandWhitelist,
|
|
177
186
|
commandBlacklist: yamlConfig.commandBlacklist ?? merged[name]?.commandBlacklist,
|
|
187
|
+
disableBuiltinGuards: yamlConfig.disableBuiltinGuards ?? merged[name]?.disableBuiltinGuards,
|
|
188
|
+
disableBuiltinBlacklist: yamlConfig.disableBuiltinBlacklist ?? merged[name]?.disableBuiltinBlacklist,
|
|
189
|
+
disableSftpPathPolicy: yamlConfig.disableSftpPathPolicy ?? merged[name]?.disableSftpPathPolicy,
|
|
178
190
|
safeDirectory: yamlConfig.safeDirectory ?? merged[name]?.safeDirectory,
|
|
179
191
|
};
|
|
180
192
|
}
|
package/build/config/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const SERVER_CONFIG = {
|
|
2
2
|
name: "ssh-mcp-server",
|
|
3
|
-
version: "1.0.
|
|
3
|
+
version: "1.0.5",
|
|
4
4
|
};
|
|
5
5
|
export const SERVER_INSTRUCTIONS = `This server provides SSH access to servers loaded from OpenSSH config (~/.ssh/config by default) and optional YAML config/policy overlays.
|
|
6
6
|
|
|
@@ -29,23 +29,10 @@ export const BUILT_IN_COMMAND_BLACKLIST = [
|
|
|
29
29
|
{ regex: /\bRemove-Item\b(?=.*\s-Recurse(?:\s|$))(?=.*\s-Force(?:\s|$))/i, reason: "recursive force Remove-Item" },
|
|
30
30
|
{ regex: /^\s*(?:del|erase|rd)\b(?=.*(?:\/s\b|\s-Recurse(?:\s|$)))(?=.*(?:\/q\b|\s-Force(?:\s|$)))/i, reason: "recursive quiet Windows delete" },
|
|
31
31
|
{ regex: /^\s*(?:sudo\s+)?rmdir\s+(?:\/|\*|~|\$HOME|%USERPROFILE%|[A-Za-z]:\\)(?:\s|$)/i, reason: "dangerous rmdir target" },
|
|
32
|
-
{ regex: /^\s*(?:sudo\s+)?dd\b.*\bof=/i, reason: "dd with of= (can overwrite devices/files)" },
|
|
33
|
-
{ regex: /^\s*(?:sudo\s+)?mkfs(?:\.\S+)?\b/i, reason: "filesystem formatting command" },
|
|
34
|
-
{ regex: /^\s*(?:sudo\s+)?(?:diskpart|format)(?:\s|$)/i, reason: "disk formatting command" },
|
|
35
|
-
{ regex: /\b(?:Format-Volume|Clear-Disk|Remove-Partition)\b/i, reason: "Windows disk destructive command" },
|
|
36
32
|
{ regex: /^\s*(?:sudo\s+)?chmod\s+-R\s+777\b/i, reason: "recursive world-writable chmod" },
|
|
37
33
|
{ regex: /^\s*(?:sudo\s+)?chown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: "recursive chown on root" },
|
|
38
34
|
];
|
|
39
35
|
export const BUILT_IN_DESTRUCTIVE_GUARDS = [
|
|
40
|
-
{ regex: /\brm\b/, reason: "rm in command chain" },
|
|
41
|
-
{ regex: /\brmdir\b/, reason: "rmdir in command chain" },
|
|
42
|
-
{ regex: /\bunlink\b/, reason: "unlink in command chain" },
|
|
43
|
-
{ regex: /\bshred\b/, reason: "shred in command chain" },
|
|
44
|
-
{ regex: /\btruncate\b/, reason: "truncate in command chain" },
|
|
45
|
-
{ regex: /-delete\b/, reason: "find -delete detected" },
|
|
46
|
-
{ regex: /-exec\s+rm\b/, reason: "find -exec rm detected" },
|
|
47
|
-
{ regex: /\bmv\b/, reason: "mv detected (can overwrite)" },
|
|
48
|
-
{ regex: /\bcp\b.*-f/, reason: "cp -f detected (force overwrite)" },
|
|
49
36
|
{ regex: /(?<![0-9])>\s*\/(?!dev\/null)/, reason: "output redirection to absolute path" },
|
|
50
37
|
{ regex: />\s*~/, reason: "output redirection to home path" },
|
|
51
38
|
];
|
|
@@ -824,7 +811,9 @@ export class SSHConnectionManager {
|
|
|
824
811
|
|| (config.username
|
|
825
812
|
? (config.username === 'root' ? '/root' : `/home/${config.username}`)
|
|
826
813
|
: SSHConnectionManager.DEFAULT_SAFE_DIRECTORY);
|
|
827
|
-
const destructiveReason =
|
|
814
|
+
const destructiveReason = config.disableBuiltinGuards
|
|
815
|
+
? null
|
|
816
|
+
: this.getDestructiveMatch(command);
|
|
828
817
|
if (destructiveReason) {
|
|
829
818
|
// Command contains rm/rmdir - check if it's a simple rm command or hidden in a chain
|
|
830
819
|
const trimmed = command.trim();
|
|
@@ -852,13 +841,15 @@ export class SSHConnectionManager {
|
|
|
852
841
|
// ========================================
|
|
853
842
|
// LAYER 2: Built-in blacklist for high-risk operations
|
|
854
843
|
// ========================================
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
844
|
+
if (!config.disableBuiltinBlacklist) {
|
|
845
|
+
for (const { regex, reason } of BUILT_IN_COMMAND_BLACKLIST) {
|
|
846
|
+
if (regex.test(command)) {
|
|
847
|
+
Logger.log(`Command blocked by built-in blacklist (${reason}): ${command}`, "info");
|
|
848
|
+
return {
|
|
849
|
+
isAllowed: false,
|
|
850
|
+
reason: `Command blocked by built-in blacklist: ${reason}`,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
862
853
|
}
|
|
863
854
|
}
|
|
864
855
|
// ========================================
|
|
@@ -1213,14 +1204,16 @@ export class SSHConnectionManager {
|
|
|
1213
1204
|
*/
|
|
1214
1205
|
validateLocalPath(localPath, name) {
|
|
1215
1206
|
const resolvedPath = path.resolve(localPath);
|
|
1207
|
+
const config = name ? this.getServerConfig(name) : undefined;
|
|
1208
|
+
// disableSftpPathPolicy fully opens the local side too (any path allowed).
|
|
1209
|
+
if (config?.disableSftpPathPolicy) {
|
|
1210
|
+
return resolvedPath;
|
|
1211
|
+
}
|
|
1216
1212
|
const allowedRoots = new Set([process.cwd()]);
|
|
1217
1213
|
// Add per-server allowedLocalDirectories if a server is targeted
|
|
1218
|
-
if (
|
|
1219
|
-
const
|
|
1220
|
-
|
|
1221
|
-
for (const dir of config.allowedLocalDirectories) {
|
|
1222
|
-
allowedRoots.add(dir);
|
|
1223
|
-
}
|
|
1214
|
+
if (config?.allowedLocalDirectories) {
|
|
1215
|
+
for (const dir of config.allowedLocalDirectories) {
|
|
1216
|
+
allowedRoots.add(dir);
|
|
1224
1217
|
}
|
|
1225
1218
|
}
|
|
1226
1219
|
const isAllowed = Array.from(allowedRoots).some((root) => resolvedPath === root || resolvedPath.startsWith(root + path.sep));
|
|
@@ -1258,9 +1251,11 @@ export class SSHConnectionManager {
|
|
|
1258
1251
|
const normalized = path.posix.normalize(remotePath);
|
|
1259
1252
|
const config = this.getConfig(name);
|
|
1260
1253
|
const allowedRoots = config.allowedRemoteDirectories ?? [];
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1254
|
+
// Default is OPEN: with no 'allowedRemoteDirectories' configured (or
|
|
1255
|
+
// disableSftpPathPolicy set), any absolute POSIX path is allowed. Configure
|
|
1256
|
+
// 'allowedRemoteDirectories' to opt into an allowlist for this server.
|
|
1257
|
+
if (config.disableSftpPathPolicy || allowedRoots.length === 0) {
|
|
1258
|
+
return normalized;
|
|
1264
1259
|
}
|
|
1265
1260
|
const isAllowed = allowedRoots.some((root) => normalized === root || normalized.startsWith(root === "/" ? "/" : root + "/"));
|
|
1266
1261
|
if (!isAllowed) {
|
package/build/tools/download.js
CHANGED
|
@@ -7,8 +7,8 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export function registerDownloadTool(server) {
|
|
9
9
|
const sshManager = SSHConnectionManager.getInstance();
|
|
10
|
-
server.tool("download", "Download a file from the remote server to the MCP host over SFTP. Use this when you need to inspect or save a remote file locally.
|
|
11
|
-
remotePath: z.string().describe("Path to the file on the remote server. Must be an absolute POSIX path
|
|
10
|
+
server.tool("download", "Download a file from the remote server to the MCP host over SFTP. Use this when you need to inspect or save a remote file locally. By default any absolute remote path is allowed; if the server configures allowedRemoteDirectories, the source must live inside one of those entries — call show-whitelist to check.", {
|
|
11
|
+
remotePath: z.string().describe("Path to the file on the remote server. Must be an absolute POSIX path (e.g. /var/log/app.log); restricted to allowedRemoteDirectories only if the server configures that list."),
|
|
12
12
|
localPath: z.string().describe("Destination path on the MCP host. Must be inside the MCP working directory or one of the server's allowedLocalDirectories."),
|
|
13
13
|
connectionName: z.string().optional().describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
|
|
14
14
|
}, async ({ remotePath, localPath, connectionName }) => {
|
|
@@ -100,15 +100,18 @@ export function registerShowWhitelistTool(server) {
|
|
|
100
100
|
const allowedLocalDirs = config.allowedLocalDirectories ?? [];
|
|
101
101
|
const mcpCwd = process.cwd();
|
|
102
102
|
output += `## 📂 SFTP Path Policy (upload / download / transfer)\n\n`;
|
|
103
|
+
if (config.disableSftpPathPolicy) {
|
|
104
|
+
output += `**\`disableSftpPathPolicy\` is set — all path containment checks below are bypassed.** ` +
|
|
105
|
+
`Any absolute remote path and any local path is allowed for upload/download/transfer.\n\n`;
|
|
106
|
+
}
|
|
103
107
|
// ----- Remote directories -----
|
|
104
108
|
output += `### Allowed remote directories\n\n`;
|
|
105
|
-
if (allowedRemoteDirs.length === 0) {
|
|
106
|
-
output +=
|
|
107
|
-
`
|
|
108
|
-
`To enable file transfer, add absolute POSIX directories under \`allowedRemoteDirectories\` in the server's YAML config.\n\n`;
|
|
109
|
+
if (config.disableSftpPathPolicy || allowedRemoteDirs.length === 0) {
|
|
110
|
+
output += `\`allowedRemoteDirectories\` is not configured, so **any absolute POSIX path is allowed** (default is open). ` +
|
|
111
|
+
`To restrict SFTP to specific directories, add them under \`allowedRemoteDirectories\` in the server's YAML config.\n\n`;
|
|
109
112
|
}
|
|
110
113
|
else {
|
|
111
|
-
output += `${allowedRemoteDirs.length} entr${allowedRemoteDirs.length === 1 ? "y" : "ies"}:\n\n`;
|
|
114
|
+
output += `${allowedRemoteDirs.length} entr${allowedRemoteDirs.length === 1 ? "y" : "ies"} (restricting SFTP to these paths only):\n\n`;
|
|
112
115
|
for (const dir of allowedRemoteDirs) {
|
|
113
116
|
output += `- \`${dir}\`\n`;
|
|
114
117
|
}
|
|
@@ -116,24 +119,31 @@ export function registerShowWhitelistTool(server) {
|
|
|
116
119
|
}
|
|
117
120
|
// ----- Local directories -----
|
|
118
121
|
output += `### Allowed local directories\n\n`;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
output += `⚠️ **\`allowedLocalDirectories\` is NOT configured.** ` +
|
|
122
|
-
`Only paths inside the MCP working directory above can be used as the local side of upload/download — any other path will be rejected with \`LOCAL_PATH_NOT_ALLOWED\`. ` +
|
|
123
|
-
`To allow more locations, add absolute host paths under \`allowedLocalDirectories\` in the server's YAML config.\n\n`;
|
|
122
|
+
if (config.disableSftpPathPolicy) {
|
|
123
|
+
output += `\`disableSftpPathPolicy\` is set, so **any local path is allowed**.\n\n`;
|
|
124
124
|
}
|
|
125
125
|
else {
|
|
126
|
-
output += `
|
|
127
|
-
|
|
128
|
-
output +=
|
|
126
|
+
output += `Always implicitly allowed: \`${mcpCwd}\` _(the MCP working directory)_\n\n`;
|
|
127
|
+
if (allowedLocalDirs.length === 0) {
|
|
128
|
+
output += `\`allowedLocalDirectories\` is not configured. ` +
|
|
129
|
+
`Only paths inside the MCP working directory above can be used as the local side of upload/download — any other path will be rejected with \`LOCAL_PATH_NOT_ALLOWED\`. ` +
|
|
130
|
+
`To allow more locations, add absolute host paths under \`allowedLocalDirectories\`, or set \`disableSftpPathPolicy: true\` to allow any local path.\n\n`;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
output += `Additional entries (${allowedLocalDirs.length}):\n\n`;
|
|
134
|
+
for (const dir of allowedLocalDirs) {
|
|
135
|
+
output += `- \`${dir}\`\n`;
|
|
136
|
+
}
|
|
137
|
+
output += `\n`;
|
|
129
138
|
}
|
|
130
|
-
output += `\n`;
|
|
131
139
|
}
|
|
132
140
|
// ----- Matching rules -----
|
|
133
141
|
output += `### Matching rules\n\n`;
|
|
134
|
-
output += `-
|
|
135
|
-
output += `-
|
|
136
|
-
output += `-
|
|
142
|
+
output += `- Default is open: with no \`allowedRemoteDirectories\` configured, any absolute POSIX remote path is allowed.\n`;
|
|
143
|
+
output += `- Configuring \`allowedRemoteDirectories\` opts into an allowlist — a path is then allowed iff it equals an entry, or starts with \`<entry> + separator\`.\n`;
|
|
144
|
+
output += `- \`disableSftpPathPolicy: true\` bypasses both the remote allowlist and the local directory check entirely.\n`;
|
|
145
|
+
output += `- Remote paths must be absolute POSIX (\`/...\`); \`..\` segments and null bytes are rejected up-front regardless of policy.\n`;
|
|
146
|
+
output += `- Local paths are resolved on the MCP host first, then matched (unless \`disableSftpPathPolicy\` is set).\n`;
|
|
137
147
|
output += `- These path lists do **not** affect \`execute-command\`. They only restrict SFTP file transfers.\n\n`;
|
|
138
148
|
// ----- Output log policy (execute-command full-output persistence) -----
|
|
139
149
|
const outputLogRoot = sshManager.getOutputLogRoot();
|
package/build/tools/transfer.js
CHANGED
|
@@ -26,12 +26,12 @@ Set recursive=true when transferring a directory (upload/download only).
|
|
|
26
26
|
For relay mode, specify sourceServer, sourceRemotePath, destServer, destRemotePath.`, {
|
|
27
27
|
mode: z.enum(["upload", "download", "relay"]).describe("'upload' pushes local → remote. 'download' pulls remote → local. 'relay' copies remote-A → remote-B through the MCP host."),
|
|
28
28
|
localPath: z.string().optional().describe("(upload/download only) Path on the MCP host. Must be inside the MCP working directory or one of the server's allowedLocalDirectories."),
|
|
29
|
-
remotePath: z.string().optional().describe("(upload/download only) Absolute POSIX path on the remote server.
|
|
29
|
+
remotePath: z.string().optional().describe("(upload/download only) Absolute POSIX path on the remote server. Any path is allowed by default; restricted to allowedRemoteDirectories only if the server configures that list — call show-whitelist to check."),
|
|
30
30
|
connectionName: z.string().optional().describe("(upload/download only) Target server name from list-servers. Required when multiple servers are enabled."),
|
|
31
31
|
sourceServer: z.string().optional().describe("(relay only) Server name to download the file from."),
|
|
32
|
-
sourceRemotePath: z.string().optional().describe("(relay only) Absolute POSIX file path on the source server.
|
|
32
|
+
sourceRemotePath: z.string().optional().describe("(relay only) Absolute POSIX file path on the source server. Any path is allowed by default unless the source server configures allowedRemoteDirectories."),
|
|
33
33
|
destServer: z.string().optional().describe("(relay only) Server name to upload the file to."),
|
|
34
|
-
destRemotePath: z.string().optional().describe("(relay only) Absolute POSIX destination path on the target server.
|
|
34
|
+
destRemotePath: z.string().optional().describe("(relay only) Absolute POSIX destination path on the target server. Any path is allowed by default unless the destination server configures allowedRemoteDirectories."),
|
|
35
35
|
recursive: z.boolean().optional().describe("(upload/download only) When true, transfers an entire directory tree recursively. Default false."),
|
|
36
36
|
skipIfIdentical: z.boolean().optional().describe("When true (default), skip the transfer if the destination already matches the source. " +
|
|
37
37
|
"Upload: byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts (.sh / .bash / .zsh) ignore CRLF\u2194LF differences. " +
|
package/build/tools/upload.js
CHANGED
|
@@ -7,11 +7,11 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export function registerUploadTool(server) {
|
|
9
9
|
const sshManager = SSHConnectionManager.getInstance();
|
|
10
|
-
server.tool("upload", "Upload a local file from the MCP host to the remote server over SFTP. Use this when the file already exists locally and must be copied to the selected SSH server.
|
|
10
|
+
server.tool("upload", "Upload a local file from the MCP host to the remote server over SFTP. Use this when the file already exists locally and must be copied to the selected SSH server. By default any absolute remote path is allowed; if the server configures allowedRemoteDirectories, the destination must live inside one of those entries — call show-whitelist to check. " +
|
|
11
11
|
"Shell scripts (.sh / .bash / .zsh) with CRLF line endings are auto-converted to LF before upload (the response notes when this happens). " +
|
|
12
12
|
"By default the upload is skipped if the remote file is already identical to the local one (byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts are compared in a line-ending-agnostic way so CRLF\u2194LF differences alone do not trigger a re-upload) \u2014 pass skipIfIdentical=false to force a re-upload.", {
|
|
13
13
|
localPath: z.string().describe("Path to a local file on the MCP host. Must be inside the MCP working directory or one of the server's allowedLocalDirectories."),
|
|
14
|
-
remotePath: z.string().describe("Destination path on the remote server. Must be an absolute POSIX path
|
|
14
|
+
remotePath: z.string().describe("Destination path on the remote server. Must be an absolute POSIX path (e.g. /home/user/uploads/file.txt); restricted to allowedRemoteDirectories only if the server configures that list."),
|
|
15
15
|
connectionName: z.string().optional().describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
|
|
16
16
|
skipIfIdentical: z.boolean().optional().describe("When true (default), skip the upload if the remote file is already identical (byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts ignore CRLF\u2194LF differences). Set to false to force re-upload."),
|
|
17
17
|
}, async ({ localPath, remotePath, connectionName, skipIfIdentical }) => {
|
package/package.json
CHANGED
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@aaarc/handfree-ssh-mcp",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
|
|
5
|
-
"main": "build/index.js",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"handfree-ssh-mcp": "build/index.js"
|
|
9
|
-
},
|
|
10
|
-
"repository": {
|
|
11
|
-
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/Ironboxplus/handfree-ssh-mcp.git"
|
|
13
|
-
},
|
|
14
|
-
"bugs": {
|
|
15
|
-
"url": "https://github.com/Ironboxplus/handfree-ssh-mcp/issues"
|
|
16
|
-
},
|
|
17
|
-
"homepage": "https://github.com/Ironboxplus/handfree-ssh-mcp#readme",
|
|
18
|
-
"publishConfig": {
|
|
19
|
-
"access": "public"
|
|
20
|
-
},
|
|
21
|
-
"scripts": {
|
|
22
|
-
"test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js",
|
|
23
|
-
"test:config": "npm run build && node --test build/tests/config.test.js",
|
|
24
|
-
"test:cmd": "npm run build && node --test build/tests/command-validation.test.js",
|
|
25
|
-
"test:tools": "npm run build && node --test build/tests/tools.test.js",
|
|
26
|
-
"test:integration": "npm run build && node build/tests/integration.test.js",
|
|
27
|
-
"test:all": "npm run test && npm run test:integration",
|
|
28
|
-
"build": "node scripts/build.js",
|
|
29
|
-
"prepublishOnly": "npm run build"
|
|
30
|
-
},
|
|
31
|
-
"keywords": [
|
|
32
|
-
"ssh",
|
|
33
|
-
"mcp",
|
|
34
|
-
"server",
|
|
35
|
-
"cli"
|
|
36
|
-
],
|
|
37
|
-
"author": "Junki",
|
|
38
|
-
"license": "ISC",
|
|
39
|
-
"dependencies": {
|
|
40
|
-
"@modelcontextprotocol/sdk": "1.17.5",
|
|
41
|
-
"js-yaml": "^4.1.0",
|
|
42
|
-
"socks": "^2.8.6",
|
|
43
|
-
"ssh2": "^1.16.0",
|
|
44
|
-
"zod": "^3.24.2"
|
|
45
|
-
},
|
|
46
|
-
"devDependencies": {
|
|
47
|
-
"@types/js-yaml": "^4.0.9",
|
|
48
|
-
"@types/node": "^22.13.10",
|
|
49
|
-
"@types/ssh2": "^1.15.5",
|
|
50
|
-
"typescript": "^5.8.2"
|
|
51
|
-
},
|
|
52
|
-
"files": [
|
|
53
|
-
"build/config/**/*",
|
|
54
|
-
"build/core/**/*",
|
|
55
|
-
"build/models/**/*",
|
|
56
|
-
"build/services/**/*",
|
|
57
|
-
"build/tools/**/*",
|
|
58
|
-
"build/utils/**/*",
|
|
59
|
-
"build/index.js"
|
|
60
|
-
]
|
|
61
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@aaarc/handfree-ssh-mcp",
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"handfree-ssh-mcp": "build/index.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Ironboxplus/handfree-ssh-mcp.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Ironboxplus/handfree-ssh-mcp/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/Ironboxplus/handfree-ssh-mcp#readme",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "npm run build && node --test build/tests/config.test.js build/tests/command-validation.test.js build/tests/tool-error.test.js build/tests/tools.test.js build/tests/output-collector.test.js build/tests/output-log-writer.test.js build/tests/recent-fixes.test.js",
|
|
23
|
+
"test:config": "npm run build && node --test build/tests/config.test.js",
|
|
24
|
+
"test:cmd": "npm run build && node --test build/tests/command-validation.test.js",
|
|
25
|
+
"test:tools": "npm run build && node --test build/tests/tools.test.js",
|
|
26
|
+
"test:integration": "npm run build && node build/tests/integration.test.js",
|
|
27
|
+
"test:all": "npm run test && npm run test:integration",
|
|
28
|
+
"build": "node scripts/build.js",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"ssh",
|
|
33
|
+
"mcp",
|
|
34
|
+
"server",
|
|
35
|
+
"cli"
|
|
36
|
+
],
|
|
37
|
+
"author": "Junki",
|
|
38
|
+
"license": "ISC",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@modelcontextprotocol/sdk": "1.17.5",
|
|
41
|
+
"js-yaml": "^4.1.0",
|
|
42
|
+
"socks": "^2.8.6",
|
|
43
|
+
"ssh2": "^1.16.0",
|
|
44
|
+
"zod": "^3.24.2"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/js-yaml": "^4.0.9",
|
|
48
|
+
"@types/node": "^22.13.10",
|
|
49
|
+
"@types/ssh2": "^1.15.5",
|
|
50
|
+
"typescript": "^5.8.2"
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"build/config/**/*",
|
|
54
|
+
"build/core/**/*",
|
|
55
|
+
"build/models/**/*",
|
|
56
|
+
"build/services/**/*",
|
|
57
|
+
"build/tools/**/*",
|
|
58
|
+
"build/utils/**/*",
|
|
59
|
+
"build/index.js"
|
|
60
|
+
]
|
|
61
|
+
}
|