@aaarc/handfree-ssh-mcp 1.0.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.
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ const TOOL_HELP = {
3
+ "list-servers": `list-servers — Discover available SSH servers.
4
+
5
+ Parameters:
6
+ refresh (boolean, optional) When true, re-collects live system status
7
+ (hostname, CPU, memory, disk, GPUs) from all enabled servers.
8
+ Without this, cached status from connection time is returned.
9
+
10
+ Returns: JSON array of server objects with name, host, port, username,
11
+ connected, enabled, and optional status.
12
+
13
+ Example:
14
+ list-servers → cached info
15
+ list-servers { refresh: true } → fresh system status`,
16
+ "execute-command": `execute-command — Run a shell command on a remote server.
17
+
18
+ Parameters:
19
+ cmdString (string, required) The shell command to execute.
20
+ connectionName (string, see below) Target server name from list-servers.
21
+ stream (boolean, optional) Default true. Set false for short
22
+ commands when you only need the final output.
23
+ timeout (number, optional) Max runtime in ms.
24
+ Defaults: 300000 (stream) / 30000 (non-stream).
25
+
26
+ connectionName rule:
27
+ • If only one server is enabled → optional (auto-selected).
28
+ • If multiple servers are enabled → REQUIRED.
29
+
30
+ Examples:
31
+ execute-command { cmdString: "pwd" }
32
+ execute-command { cmdString: "docker ps -a", connectionName: "prod", stream: false }
33
+ execute-command { cmdString: "tail -f /var/log/syslog", stream: true, timeout: 600000 }`,
34
+ "show-whitelist": `show-whitelist — Show allowed/blocked command patterns for a server.
35
+
36
+ Parameters:
37
+ connectionName (string, see below) Target server name from list-servers.
38
+
39
+ connectionName rule:
40
+ • If only one server is enabled → optional (auto-selected).
41
+ • If multiple servers are enabled → REQUIRED.
42
+
43
+ Returns: Whitelist patterns, blacklist patterns, and example commands.`,
44
+ "upload": `upload — Upload a single local file to a remote server over SFTP.
45
+
46
+ Parameters:
47
+ localPath (string, required) File path on the MCP host.
48
+ Must be inside the MCP working directory.
49
+ remotePath (string, required) Destination path on the remote server.
50
+ connectionName (string, see below) Target server name from list-servers.
51
+
52
+ connectionName rule:
53
+ • If only one server is enabled → optional (auto-selected).
54
+ • If multiple servers are enabled → REQUIRED.
55
+
56
+ Example:
57
+ upload { localPath: "data.csv", remotePath: "/tmp/data.csv" }`,
58
+ "download": `download — Download a single file from a remote server over SFTP.
59
+
60
+ Parameters:
61
+ remotePath (string, required) File path on the remote server.
62
+ localPath (string, required) Destination on the MCP host.
63
+ Must be inside the MCP working directory.
64
+ connectionName (string, see below) Target server name from list-servers.
65
+
66
+ connectionName rule:
67
+ • If only one server is enabled → optional (auto-selected).
68
+ • If multiple servers are enabled → REQUIRED.
69
+
70
+ Example:
71
+ download { remotePath: "/var/log/app.log", localPath: "app.log" }`,
72
+ "transfer": `transfer — Move files between hosts (single/recursive/cross-server).
73
+
74
+ Modes:
75
+ upload Push local → remote (single file or recursive directory).
76
+ download Pull remote → local (single file or recursive directory).
77
+ relay Stream a file from remote-A → remote-B via SFTP piping.
78
+ No temp file on the MCP host, no SCP, no authorized-key
79
+ exchange between the two servers. Each side uses its own
80
+ existing SSH session.
81
+
82
+ Parameters for upload / download:
83
+ mode (string, required) "upload" or "download"
84
+ localPath (string, required) Path on the MCP host.
85
+ remotePath (string, required) Path on the remote server.
86
+ connectionName (string, see below) Target server name.
87
+ recursive (boolean, optional) True to transfer a whole directory tree.
88
+
89
+ Parameters for relay:
90
+ mode (string, required) "relay"
91
+ sourceServer (string, required) Server name to read from.
92
+ sourceRemotePath (string, required) File path on source server.
93
+ destServer (string, required) Server name to write to.
94
+ destRemotePath (string, required) File path on dest server.
95
+
96
+ connectionName rule (upload/download):
97
+ • If only one server is enabled → optional.
98
+ • If multiple servers are enabled → REQUIRED.
99
+
100
+ Examples:
101
+ transfer { mode: "upload", localPath: "dist/", remotePath: "/opt/app/dist", recursive: true }
102
+ transfer { mode: "relay", sourceServer: "prod", sourceRemotePath: "/var/log/app.log",
103
+ destServer: "backup", destRemotePath: "/backup/app.log" }`,
104
+ "help": `help — Show detailed usage for one or all tools.
105
+
106
+ Parameters:
107
+ tool (string, optional) Tool name to get help for.
108
+ If omitted, shows a summary of all available tools.
109
+
110
+ Example:
111
+ help → overview of all tools
112
+ help { tool: "execute-command" } → detailed usage for execute-command`,
113
+ };
114
+ const TOOL_OVERVIEW = `Available tools (use help { tool: "<name>" } for details):
115
+
116
+ list-servers Discover available SSH servers and their status.
117
+ execute-command Run a shell command on a remote server.
118
+ show-whitelist Show allowed/blocked command patterns.
119
+ upload Upload a single file to a remote server.
120
+ download Download a single file from a remote server.
121
+ transfer Move files: single, recursive, or cross-server relay.
122
+ help Show this help or detailed per-tool usage.
123
+
124
+ Quick start:
125
+ 1. list-servers → discover server names
126
+ 2. show-whitelist { connectionName: "<name>" } → see what's allowed
127
+ 3. execute-command { cmdString: "pwd", connectionName: "<name>" }`;
128
+ /**
129
+ * Register help tool
130
+ */
131
+ export function registerHelpTool(server) {
132
+ server.tool("help", "Show detailed usage instructions for one or all tools. Call with no arguments for an overview, or specify a tool name for full parameter docs and examples.", {
133
+ tool: z.string().optional().describe("Tool name to get detailed help for. Omit to see an overview of all tools."),
134
+ }, async ({ tool }) => {
135
+ if (tool) {
136
+ const text = TOOL_HELP[tool];
137
+ if (!text) {
138
+ return {
139
+ content: [{ type: "text", text: `Unknown tool: "${tool}". ${TOOL_OVERVIEW}` }],
140
+ isError: true,
141
+ };
142
+ }
143
+ return { content: [{ type: "text", text }] };
144
+ }
145
+ return { content: [{ type: "text", text: TOOL_OVERVIEW }] };
146
+ });
147
+ }
@@ -0,0 +1,20 @@
1
+ import { registerExecuteCommandTool } from "./execute-command.js";
2
+ import { registerUploadTool } from "./upload.js";
3
+ import { registerDownloadTool } from "./download.js";
4
+ import { registerListServersTool } from "./list-servers.js";
5
+ import { registerShowWhitelistTool } from "./show-whitelist.js";
6
+ import { registerTransferTool } from "./transfer.js";
7
+ import { registerHelpTool } from "./help.js";
8
+ /**
9
+ * Register all tools
10
+ * @param server MCP server instance
11
+ */
12
+ export function registerAllTools(server) {
13
+ registerExecuteCommandTool(server);
14
+ registerUploadTool(server);
15
+ registerDownloadTool(server);
16
+ registerListServersTool(server);
17
+ registerShowWhitelistTool(server);
18
+ registerTransferTool(server);
19
+ registerHelpTool(server);
20
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
3
+ import { Logger } from "../utils/logger.js";
4
+ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
5
+ /**
6
+ * Register list-servers tool
7
+ */
8
+ export function registerListServersTool(server) {
9
+ server.tool("list-servers", "List the SSH servers that were loaded from OpenSSH config and/or YAML and enabled for this MCP process. Use this first to discover valid connectionName values, confirm whether a server is enabled, and see basic connection state before calling other tools. Set refresh=true to collect live system status (hostname, CPU, memory, disk, GPUs) from connected servers.", {
10
+ refresh: z.boolean().optional().describe("When true, re-collects live system status from all enabled servers before returning. Without this, cached status from connection time is returned."),
11
+ }, async ({ refresh }) => {
12
+ try {
13
+ const sshManager = SSHConnectionManager.getInstance();
14
+ if (refresh) {
15
+ await sshManager.refreshStatus();
16
+ }
17
+ const servers = sshManager.getAllServerInfos();
18
+ return {
19
+ content: [
20
+ {
21
+ type: "text",
22
+ text: JSON.stringify(servers),
23
+ },
24
+ ],
25
+ };
26
+ }
27
+ catch (error) {
28
+ const toolError = toToolError(error, "INVALID_CONFIGURATION");
29
+ Logger.handleError(toolError, "Failed to list servers");
30
+ return {
31
+ content: [{ type: "text", text: formatToolErrorResponse(toolError) }],
32
+ isError: true,
33
+ };
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,216 @@
1
+ import { z } from "zod";
2
+ import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
3
+ import { Logger } from "../utils/logger.js";
4
+ import { ToolError, formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
5
+ /**
6
+ * Register show-whitelist tool
7
+ *
8
+ * Allows the LLM to see what commands are allowed for a server.
9
+ */
10
+ export function registerShowWhitelistTool(server) {
11
+ const sshManager = SSHConnectionManager.getInstance();
12
+ server.tool("show-whitelist", "Show the configured whitelist, blacklist, and SFTP path policy (allowedRemoteDirectories / allowedLocalDirectories) for a server. Use this before execute-command when you need to understand which commands are allowed, why a command may be rejected, or what command patterns are safe to try next. Also use it before upload / download / transfer to see which paths SFTP is permitted to touch.", {
13
+ connectionName: z
14
+ .string()
15
+ .optional()
16
+ .describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
17
+ }, async ({ connectionName }) => {
18
+ try {
19
+ const resolvedName = sshManager.resolveServer(connectionName);
20
+ const config = sshManager.getServerConfig(resolvedName);
21
+ if (!config) {
22
+ const toolError = new ToolError("INVALID_CONFIGURATION", `Server '${resolvedName}' not found or not enabled.`, false);
23
+ return {
24
+ content: [{
25
+ type: "text",
26
+ text: formatToolErrorResponse(toolError)
27
+ }],
28
+ isError: true,
29
+ };
30
+ }
31
+ const whitelist = config.commandWhitelist || [];
32
+ const blacklist = config.commandBlacklist || [];
33
+ let output = `# Command Permissions for: ${config.name}\n\n`;
34
+ output += `Host: ${config.username}@${config.host}:${config.port}\n\n`;
35
+ // Whitelist
36
+ output += `## ✅ Allowed Commands (Whitelist)\n\n`;
37
+ if (whitelist.length === 0) {
38
+ output += `⚠️ No whitelist configured - using default safe commands.\n\n`;
39
+ }
40
+ else {
41
+ output += `${whitelist.length} patterns:\n\n`;
42
+ for (const pattern of whitelist) {
43
+ const readable = patternToReadable(pattern);
44
+ output += `- \`${pattern}\``;
45
+ if (readable !== pattern) {
46
+ output += ` → ${readable}`;
47
+ }
48
+ output += `\n`;
49
+ }
50
+ output += `\n`;
51
+ }
52
+ // Blacklist
53
+ if (blacklist.length > 0) {
54
+ output += `## ❌ Blocked Commands (Blacklist)\n\n`;
55
+ output += `${blacklist.length} patterns:\n\n`;
56
+ for (const pattern of blacklist) {
57
+ const readable = patternToReadable(pattern);
58
+ output += `- \`${pattern}\``;
59
+ if (readable !== pattern) {
60
+ output += ` → ${readable}`;
61
+ }
62
+ output += `\n`;
63
+ }
64
+ output += `\n`;
65
+ }
66
+ // SFTP path policy (upload / download / transfer tools only)
67
+ const allowedRemoteDirs = config.allowedRemoteDirectories ?? [];
68
+ const allowedLocalDirs = config.allowedLocalDirectories ?? [];
69
+ const mcpCwd = process.cwd();
70
+ output += `## 📂 SFTP Path Policy (upload / download / transfer)\n\n`;
71
+ // ----- Remote directories -----
72
+ output += `### Allowed remote directories\n\n`;
73
+ if (allowedRemoteDirs.length === 0) {
74
+ output += `⚠️ **\`allowedRemoteDirectories\` is NOT configured.** ` +
75
+ `SFTP upload/download/transfer is **disabled** for this server — every call will be rejected with \`REMOTE_PATH_NOT_ALLOWED\`. ` +
76
+ `To enable file transfer, add absolute POSIX directories under \`allowedRemoteDirectories\` in the server's YAML config.\n\n`;
77
+ }
78
+ else {
79
+ output += `${allowedRemoteDirs.length} entr${allowedRemoteDirs.length === 1 ? "y" : "ies"}:\n\n`;
80
+ for (const dir of allowedRemoteDirs) {
81
+ output += `- \`${dir}\`\n`;
82
+ }
83
+ output += `\n`;
84
+ }
85
+ // ----- Local directories -----
86
+ output += `### Allowed local directories\n\n`;
87
+ output += `Always implicitly allowed: \`${mcpCwd}\` _(the MCP working directory)_\n\n`;
88
+ if (allowedLocalDirs.length === 0) {
89
+ output += `⚠️ **\`allowedLocalDirectories\` is NOT configured.** ` +
90
+ `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\`. ` +
91
+ `To allow more locations, add absolute host paths under \`allowedLocalDirectories\` in the server's YAML config.\n\n`;
92
+ }
93
+ else {
94
+ output += `Additional entries (${allowedLocalDirs.length}):\n\n`;
95
+ for (const dir of allowedLocalDirs) {
96
+ output += `- \`${dir}\`\n`;
97
+ }
98
+ output += `\n`;
99
+ }
100
+ // ----- Matching rules -----
101
+ output += `### Matching rules\n\n`;
102
+ output += `- A path is allowed iff it equals an entry above, or starts with \`<entry> + separator\`.\n`;
103
+ output += `- Remote paths must be absolute POSIX (\`/...\`); \`..\` segments and null bytes are rejected up-front.\n`;
104
+ output += `- Local paths are resolved on the MCP host first, then matched.\n`;
105
+ output += `- These path lists do **not** affect \`execute-command\`. They only restrict SFTP file transfers.\n\n`;
106
+ // ----- Output log policy (execute-command full-output persistence) -----
107
+ const outputLogRoot = sshManager.getOutputLogRoot();
108
+ const serverLogDir = `${outputLogRoot}/${config.name}/${config.username}`.replace(/\\/g, "/");
109
+ output += `## 📝 \`execute-command\` Output Logs\n\n`;
110
+ output += `Every \`execute-command\` call returns at most \`maxOutputBytes\` (default 65536) bytes per stream, tail-only. The FULL stdout/stderr is always persisted to disk:\n\n`;
111
+ output += `- Root: \`${outputLogRoot}\` _(override via the top-level \`outputLogDir:\` field in \`servers.yaml\`)_\n`;
112
+ output += `- This server's logs land under: \`${serverLogDir}/\`\n`;
113
+ output += `- File name: \`<timestamp>-<pid>-<rand>.log\` with \`=== META / STDOUT / STDERR / END ===\` markers.\n`;
114
+ output += `- When output is truncated, the response includes an \`[OUTPUT TRUNCATED]\` header with the on-disk log path.\n\n`;
115
+ // Quick examples
116
+ output += `## 💡 Example Commands\n\n`;
117
+ output += `Based on the whitelist, here are some commands you can likely use:\n\n`;
118
+ const examples = generateExamples(whitelist);
119
+ for (const ex of examples.slice(0, 10)) {
120
+ output += `- \`${ex}\`\n`;
121
+ }
122
+ return {
123
+ content: [{ type: "text", text: output }],
124
+ };
125
+ }
126
+ catch (error) {
127
+ const toolError = toToolError(error, "INVALID_CONFIGURATION");
128
+ Logger.handleError(toolError, "Failed to show whitelist");
129
+ return {
130
+ content: [{ type: "text", text: formatToolErrorResponse(toolError) }],
131
+ isError: true,
132
+ };
133
+ }
134
+ });
135
+ }
136
+ /**
137
+ * Convert regex pattern to human-readable description
138
+ */
139
+ function patternToReadable(pattern) {
140
+ let readable = pattern;
141
+ readable = readable.replace(/^\^/, "").replace(/\$$/, "");
142
+ if (readable === "ls( .*)?")
143
+ return "ls, ls -la, ls /path, etc.";
144
+ if (readable === "cat .*")
145
+ return "cat <file>";
146
+ if (readable === "pwd")
147
+ return "pwd (current directory)";
148
+ if (readable === "whoami")
149
+ return "whoami (current user)";
150
+ if (readable === "hostname")
151
+ return "hostname";
152
+ if (readable === "date")
153
+ return "date (current time)";
154
+ if (readable === "docker ps.*")
155
+ return "docker ps, docker ps -a, etc.";
156
+ if (readable === "docker logs.*")
157
+ return "docker logs <container>";
158
+ if (readable === "git .*")
159
+ return "git <any subcommand>";
160
+ if (readable.includes(".*"))
161
+ return readable.replace(/\.\*/g, "<any>");
162
+ if (readable.includes(".+"))
163
+ return readable.replace(/\.\+/g, "<required>");
164
+ return readable;
165
+ }
166
+ /**
167
+ * Generate example commands based on whitelist patterns
168
+ */
169
+ function generateExamples(whitelist) {
170
+ const examples = [];
171
+ for (const pattern of whitelist) {
172
+ if (pattern.includes("^ls"))
173
+ examples.push("ls -la");
174
+ if (pattern.includes("^cat"))
175
+ examples.push("cat /etc/hostname");
176
+ if (pattern.includes("^pwd"))
177
+ examples.push("pwd");
178
+ if (pattern.includes("^whoami"))
179
+ examples.push("whoami");
180
+ if (pattern.includes("^hostname"))
181
+ examples.push("hostname");
182
+ if (pattern.includes("^date"))
183
+ examples.push("date");
184
+ if (pattern.includes("^echo"))
185
+ examples.push("echo 'hello world'");
186
+ if (pattern.includes("^docker ps"))
187
+ examples.push("docker ps -a");
188
+ if (pattern.includes("^docker logs"))
189
+ examples.push("docker logs --tail 50 <container>");
190
+ if (pattern.includes("^git"))
191
+ examples.push("git status");
192
+ if (pattern.includes("^head"))
193
+ examples.push("head -n 20 <file>");
194
+ if (pattern.includes("^tail"))
195
+ examples.push("tail -n 50 <file>");
196
+ if (pattern.includes("^grep"))
197
+ examples.push("grep 'pattern' <file>");
198
+ if (pattern.includes("^find"))
199
+ examples.push("find . -name '*.log'");
200
+ if (pattern.includes("^df"))
201
+ examples.push("df -h");
202
+ if (pattern.includes("^du"))
203
+ examples.push("du -sh *");
204
+ if (pattern.includes("^ps"))
205
+ examples.push("ps aux");
206
+ if (pattern.includes("^uptime"))
207
+ examples.push("uptime");
208
+ if (pattern.includes("^free"))
209
+ examples.push("free -h");
210
+ if (pattern.includes("^curl"))
211
+ examples.push("curl -s https://example.com");
212
+ if (pattern.includes("systemctl status"))
213
+ examples.push("systemctl status <service>");
214
+ }
215
+ return [...new Set(examples)];
216
+ }
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
3
+ import { Logger } from "../utils/logger.js";
4
+ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
5
+ /**
6
+ * Register unified file transfer tool
7
+ *
8
+ * Supports three modes:
9
+ * upload — push a local file or directory to a remote server
10
+ * download — pull a remote file or directory to the MCP host
11
+ * relay — relay a file between two remote servers through the MCP host
12
+ */
13
+ export function registerTransferTool(server) {
14
+ const sshManager = SSHConnectionManager.getInstance();
15
+ server.tool("transfer", `Transfer files between the MCP host and remote servers, or between two remote servers.
16
+
17
+ Modes:
18
+ upload — push a local file or directory to a remote server.
19
+ download — pull a remote file or directory to the MCP host.
20
+ relay — stream a file from one remote server to another via SFTP piping.
21
+ No temp file touches the MCP host disk. No SCP or authorized-key
22
+ exchange between the two servers is needed — each side uses its
23
+ own existing SSH session.
24
+
25
+ Set recursive=true when transferring a directory (upload/download only).
26
+ For relay mode, specify sourceServer, sourceRemotePath, destServer, destRemotePath.`, {
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
+ 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. Must live inside one of the server's allowedRemoteDirectories — call show-whitelist to discover the allowed paths."),
30
+ connectionName: z.string().optional().describe("(upload/download only) Target server name from list-servers. Required when multiple servers are enabled."),
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. Must live inside the source server's allowedRemoteDirectories."),
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. Must live inside the destination server's allowedRemoteDirectories."),
35
+ recursive: z.boolean().optional().describe("(upload/download only) When true, transfers an entire directory tree recursively. Default false."),
36
+ skipIfIdentical: z.boolean().optional().describe("When true (default), skip the transfer if the destination already matches the source. " +
37
+ "Upload: byte-compare for files \u2264 256 MiB, MD5 otherwise; shell scripts (.sh / .bash / .zsh) ignore CRLF\u2194LF differences. " +
38
+ "Relay: size match + md5sum match on both servers (when available); falls back to transferring if md5sum is missing on either side. " +
39
+ "Download is never skipped. Set to false to force the transfer."),
40
+ }, async (params) => {
41
+ try {
42
+ const { mode } = params;
43
+ if (mode === "relay") {
44
+ const { sourceServer, sourceRemotePath, destServer, destRemotePath, skipIfIdentical } = params;
45
+ if (!sourceServer || !sourceRemotePath || !destServer || !destRemotePath) {
46
+ return {
47
+ content: [{ type: "text", text: "relay mode requires: sourceServer, sourceRemotePath, destServer, destRemotePath" }],
48
+ isError: true,
49
+ };
50
+ }
51
+ const result = await sshManager.transferBetweenServers(sourceServer, sourceRemotePath, destServer, destRemotePath, { skipIfIdentical: skipIfIdentical !== false });
52
+ return { content: [{ type: "text", text: result }] };
53
+ }
54
+ // upload or download
55
+ const { localPath, remotePath, connectionName, recursive, skipIfIdentical } = params;
56
+ if (!localPath || !remotePath) {
57
+ return {
58
+ content: [{ type: "text", text: `${mode} mode requires: localPath, remotePath` }],
59
+ isError: true,
60
+ };
61
+ }
62
+ const resolvedName = sshManager.resolveServer(connectionName);
63
+ const uploadOptions = { skipIfIdentical: skipIfIdentical !== false };
64
+ if (recursive) {
65
+ let files;
66
+ if (mode === "upload") {
67
+ files = await sshManager.uploadDirectory(localPath, remotePath, resolvedName, uploadOptions);
68
+ }
69
+ else {
70
+ files = await sshManager.downloadDirectory(remotePath, localPath, resolvedName);
71
+ }
72
+ const summary = `Recursive ${mode} complete. ${files.length} file(s) transferred.`;
73
+ return {
74
+ content: [{ type: "text", text: JSON.stringify({ summary, files }) }],
75
+ };
76
+ }
77
+ // Single file
78
+ if (mode === "upload") {
79
+ const result = await sshManager.upload(localPath, remotePath, resolvedName, uploadOptions);
80
+ return { content: [{ type: "text", text: result }] };
81
+ }
82
+ else {
83
+ const result = await sshManager.download(remotePath, localPath, resolvedName);
84
+ return { content: [{ type: "text", text: result }] };
85
+ }
86
+ }
87
+ catch (error) {
88
+ const toolError = toToolError(error, "SFTP_ERROR");
89
+ Logger.handleError(toolError, "File transfer failed");
90
+ return {
91
+ content: [{ type: "text", text: formatToolErrorResponse(toolError) }],
92
+ isError: true,
93
+ };
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
3
+ import { Logger } from "../utils/logger.js";
4
+ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
5
+ /**
6
+ * Register file upload tool
7
+ */
8
+ export function registerUploadTool(server) {
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. The remote destination must live inside one of the server's allowedRemoteDirectories; call show-whitelist to discover the allowed paths. " +
11
+ "Shell scripts (.sh / .bash / .zsh) with CRLF line endings are auto-converted to LF before upload (the response notes when this happens). " +
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
+ 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 inside one of the server's allowedRemoteDirectories (e.g. /home/user/uploads/file.txt)."),
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
+ 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
+ }, async ({ localPath, remotePath, connectionName, skipIfIdentical }) => {
18
+ try {
19
+ const resolvedName = sshManager.resolveServer(connectionName);
20
+ const result = await sshManager.upload(localPath, remotePath, resolvedName, {
21
+ skipIfIdentical: skipIfIdentical !== false,
22
+ });
23
+ return {
24
+ content: [{ type: "text", text: result }],
25
+ };
26
+ }
27
+ catch (error) {
28
+ const toolError = toToolError(error, "SFTP_ERROR");
29
+ Logger.handleError(toolError, "Failed to upload file");
30
+ return {
31
+ content: [{ type: "text", text: formatToolErrorResponse(toolError) }],
32
+ isError: true,
33
+ };
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Logger class
3
+ */
4
+ export class Logger {
5
+ /**
6
+ * Log a message
7
+ * Note: All logging goes to stderr to avoid interfering with MCP stdio protocol (which uses stdout)
8
+ */
9
+ static log(message, level = "info") {
10
+ const timestamp = new Date().toISOString();
11
+ const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
12
+ // Always write to stderr to avoid corrupting MCP protocol messages on stdout
13
+ process.stderr.write(formattedMessage + '\n');
14
+ }
15
+ /**
16
+ * Handle error
17
+ */
18
+ static handleError(error, prefix = "", exit = false) {
19
+ const errorMessage = error instanceof Error ? error.message : String(error);
20
+ const fullMessage = prefix ? `${prefix}: ${errorMessage}` : errorMessage;
21
+ Logger.log(fullMessage, "error");
22
+ if (exit) {
23
+ process.exit(1);
24
+ }
25
+ return fullMessage;
26
+ }
27
+ }
@@ -0,0 +1,76 @@
1
+ export class OutputCollector {
2
+ chunks = [];
3
+ bufferedBytes = 0;
4
+ totalBytes = 0;
5
+ droppedBytes = 0;
6
+ maxBytes;
7
+ constructor(maxBytes) {
8
+ if (!Number.isFinite(maxBytes) || maxBytes < 0) {
9
+ throw new Error(`OutputCollector: maxBytes must be a non-negative finite number, got ${maxBytes}`);
10
+ }
11
+ this.maxBytes = Math.floor(maxBytes);
12
+ }
13
+ /**
14
+ * Push a chunk of bytes into the collector. Accepts strings or Buffers.
15
+ * Strings are interpreted as UTF-8.
16
+ */
17
+ push(chunk) {
18
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
19
+ if (buf.length === 0)
20
+ return;
21
+ this.totalBytes += buf.length;
22
+ if (this.maxBytes === 0) {
23
+ this.droppedBytes += buf.length;
24
+ return;
25
+ }
26
+ this.chunks.push(buf);
27
+ this.bufferedBytes += buf.length;
28
+ this.trim();
29
+ }
30
+ /**
31
+ * Trim chunks from the head until bufferedBytes <= maxBytes.
32
+ * The most recent chunk may need to be partially sliced.
33
+ */
34
+ trim() {
35
+ while (this.bufferedBytes > this.maxBytes && this.chunks.length > 0) {
36
+ const head = this.chunks[0];
37
+ const overshoot = this.bufferedBytes - this.maxBytes;
38
+ if (head.length <= overshoot) {
39
+ this.chunks.shift();
40
+ this.bufferedBytes -= head.length;
41
+ this.droppedBytes += head.length;
42
+ }
43
+ else {
44
+ this.chunks[0] = head.subarray(overshoot);
45
+ this.bufferedBytes -= overshoot;
46
+ this.droppedBytes += overshoot;
47
+ }
48
+ }
49
+ }
50
+ /**
51
+ * Get the current tail snapshot. The returned Buffer is a fresh copy.
52
+ */
53
+ getSnapshot() {
54
+ const tail = this.chunks.length === 0
55
+ ? Buffer.alloc(0)
56
+ : Buffer.concat(this.chunks, this.bufferedBytes);
57
+ return {
58
+ tail,
59
+ totalBytes: this.totalBytes,
60
+ droppedBytes: this.droppedBytes,
61
+ truncated: this.droppedBytes > 0,
62
+ };
63
+ }
64
+ getTotalBytes() {
65
+ return this.totalBytes;
66
+ }
67
+ getDroppedBytes() {
68
+ return this.droppedBytes;
69
+ }
70
+ isTruncated() {
71
+ return this.droppedBytes > 0;
72
+ }
73
+ getMaxBytes() {
74
+ return this.maxBytes;
75
+ }
76
+ }