@aaarc/handfree-ssh-mcp 1.0.4 → 1.0.6

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,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 close-connection tool
7
+ */
8
+ export function registerCloseConnectionTool(server) {
9
+ const sshManager = SSHConnectionManager.getInstance();
10
+ server.tool("close-connection", "Close the cached SSH connection for a configured server. Use this after a timeout, stale connection suspicion, or before retrying a host with a clean cached connection. This affects only cached/reused SSH clients; reuseConnection=false command clients already close after each command. Closing a jump host also closes cached target connections whose jump chain uses that host.", {
11
+ connectionName: z
12
+ .string()
13
+ .optional()
14
+ .describe("Target server name from list-servers. Required when multiple servers are enabled; optional when only one server is enabled."),
15
+ }, async ({ connectionName }) => {
16
+ try {
17
+ const result = sshManager.closeConnection(connectionName);
18
+ return {
19
+ content: [
20
+ {
21
+ type: "text",
22
+ text: JSON.stringify(result),
23
+ },
24
+ ],
25
+ };
26
+ }
27
+ catch (error) {
28
+ const toolError = toToolError(error, "INVALID_CONFIGURATION");
29
+ Logger.handleError(toolError, "Failed to close SSH connection");
30
+ return {
31
+ content: [{ type: "text", text: formatToolErrorResponse(toolError) }],
32
+ isError: true,
33
+ };
34
+ }
35
+ });
36
+ }
@@ -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. The remote source must live inside one of the server's allowedRemoteDirectories; call show-whitelist to discover the allowed paths.", {
11
- remotePath: z.string().describe("Path to the file on the remote server. Must be an absolute POSIX path inside one of the server's allowedRemoteDirectories (e.g. /var/log/app.log)."),
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 }) => {
@@ -11,7 +11,7 @@ import { formatToolErrorResponse, toToolError } from "../utils/tool-error.js";
11
11
  */
12
12
  export function registerExecuteCommandTool(server) {
13
13
  const sshManager = SSHConnectionManager.getInstance();
14
- server.tool("execute-command", "Execute a shell command on a remote server over SSH. Use this for command-line actions on the selected host. Streaming mode is enabled by default so long-running commands can emit progress; set stream=false for short commands where you only want the final output. The returned text is tail-only-capped at maxOutputBytes PER STREAM (default 65536 bytes for stdout and 65536 bytes for stderr, so the response can be up to ~2x that); the FULL stdout/stderr is always persisted to a local log file under <cwd>/.handfree-output/<server>/<user>/, and the response includes that path whenever output was truncated.", {
14
+ server.tool("execute-command", "Execute a shell command on a remote server over SSH. Use this for command-line actions on the selected host. Streaming mode is enabled by default so long-running commands can emit progress; set stream=false for short commands where you only want the final output. SSH connections are reused by default for speed; if an execute-command call times out or you suspect a stale cached SSH connection, retry with reuseConnection=false to force a fresh TCP/SSH connection for that command. Set vvv=true only when debugging SSH/channel issues; with reuseConnection=false it includes ssh2 handshake/debug lines in the result or error. The returned text is tail-only-capped at maxOutputBytes PER STREAM (default 65536 bytes for stdout and 65536 bytes for stderr, so the response can be up to ~2x that); the FULL stdout/stderr is always persisted to a local log file under <cwd>/.handfree-output/<server>/<user>/, and the response includes that path whenever output was truncated.", {
15
15
  cmdString: z.string().describe("Exact remote shell command to run. Prefer a single command per call, for example 'pwd', 'ls -la', 'cat /etc/hostname', or 'git status'. Compound commands may be blocked by command policy even if each subcommand is safe."),
16
16
  connectionName: z
17
17
  .string()
@@ -20,18 +20,26 @@ export function registerExecuteCommandTool(server) {
20
20
  timeout: z
21
21
  .number()
22
22
  .optional()
23
- .describe("Maximum runtime in milliseconds. Defaults to 300000 when stream=true and 30000 when stream=false. Increase this for long-running commands; reduce it for fast probes."),
23
+ .describe("Timeout in milliseconds applied separately to SSH connection setup, exec-channel opening, and remote command execution for each attempt. Defaults to 300000 when stream=true and 30000 when stream=false. Increase this for long-running commands; reduce it for fast probes."),
24
24
  stream: z
25
25
  .boolean()
26
26
  .optional()
27
27
  .describe("Whether to stream progress output. Default is true. Use false for short commands like pwd, ls, cat, head, tail, or git status when you only need the final result."),
28
+ reuseConnection: z
29
+ .boolean()
30
+ .optional()
31
+ .describe("Whether to reuse the cached SSH connection for this server. Default is true for speed. Set false after a timeout or suspected stale/bad cached connection; false opens a fresh SSH connection for this command and closes it afterwards."),
32
+ vvv: z
33
+ .boolean()
34
+ .optional()
35
+ .describe("Default false. When true, append bounded SSH/channel debug output to the result or error. For full ssh2 handshake debug, also set reuseConnection=false; already-reused cached clients cannot retroactively emit handshake logs."),
28
36
  maxOutputBytes: z
29
37
  .number()
30
38
  .int()
31
39
  .nonnegative()
32
40
  .optional()
33
41
  .describe("Per-stream cap on bytes returned to the caller (tail-only). Applied independently to stdout and stderr, so the combined response can be up to ~2x this value. Defaults to 65536 (64 KiB). The full output is always saved to disk regardless; only the returned text is trimmed. Set higher when you need more context, lower to save tokens."),
34
- }, async ({ cmdString, connectionName, timeout, stream, maxOutputBytes }, extra) => {
42
+ }, async ({ cmdString, connectionName, timeout, stream, reuseConnection, vvv, maxOutputBytes }, extra) => {
35
43
  try {
36
44
  const resolvedName = sshManager.resolveServer(connectionName);
37
45
  const useStream = stream !== false;
@@ -54,6 +62,8 @@ export function registerExecuteCommandTool(server) {
54
62
  const result = await sshManager.executeCommandWithProgress(cmdString, resolvedName, {
55
63
  timeout: timeout || 300000,
56
64
  maxOutputBytes,
65
+ reuseConnection,
66
+ vvv,
57
67
  onProgress,
58
68
  });
59
69
  return {
@@ -63,6 +73,8 @@ export function registerExecuteCommandTool(server) {
63
73
  const result = await sshManager.executeCommand(cmdString, resolvedName, {
64
74
  timeout: timeout || 30000,
65
75
  maxOutputBytes,
76
+ reuseConnection,
77
+ vvv,
66
78
  });
67
79
  return {
68
80
  content: [{ type: "text", text: result }],
@@ -18,19 +18,30 @@ Example:
18
18
  Parameters:
19
19
  cmdString (string, required) The shell command to execute.
20
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).
21
+ stream (boolean, optional) Default true. Set false for short
22
+ commands when you only need the final output.
23
+ reuseConnection (boolean, optional) Default true. Set false after a timeout
24
+ or suspected stale cached SSH connection to force a fresh
25
+ TCP/SSH connection for this command.
26
+ vvv (boolean, optional) Default false. Append bounded
27
+ SSH/channel debug output. Use with reuseConnection=false
28
+ when you need fresh handshake logs.
29
+ timeout (number, optional) Per-attempt phase timeout in ms:
30
+ SSH setup, exec-channel open, and
31
+ remote command execution each use this
32
+ cap.
33
+ Defaults: 300000 (stream) / 30000 (non-stream).
25
34
 
26
35
  connectionName rule:
27
36
  • If only one server is enabled → optional (auto-selected).
28
37
  • If multiple servers are enabled → REQUIRED.
29
38
 
30
39
  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 }`,
40
+ execute-command { cmdString: "pwd" }
41
+ execute-command { cmdString: "docker ps -a", connectionName: "prod", stream: false }
42
+ execute-command { cmdString: "tail -f /var/log/syslog", stream: true, timeout: 600000 }
43
+ execute-command { cmdString: "hostname", connectionName: "scnet", stream: false, reuseConnection: false }
44
+ execute-command { cmdString: "hostname", connectionName: "scnet", stream: false, reuseConnection: false, vvv: true }`,
34
45
  "show-whitelist": `show-whitelist — Show the active command policy for a server.
35
46
 
36
47
  Parameters:
@@ -41,7 +52,25 @@ connectionName rule:
41
52
  • If multiple servers are enabled → REQUIRED.
42
53
 
43
54
  Returns: Command mode, built-in blacklist, configured whitelist/blacklist patterns, and example commands when whitelist mode is active.`,
44
- "upload": `uploadUpload a single local file to a remote server over SFTP.
55
+ "close-connection": `close-connectionClose a cached SSH connection.
56
+
57
+ Parameters:
58
+ connectionName (string, see below) Target server name from list-servers.
59
+
60
+ connectionName rule:
61
+ • If only one server is enabled → optional (auto-selected).
62
+ • If multiple servers are enabled → REQUIRED.
63
+
64
+ Behavior:
65
+ • Closes the cached/reused SSH client for the target server.
66
+ • Closing a jump host also closes cached targets whose jump chain uses it.
67
+ • Does not affect reuseConnection=false commands, because those one-shot
68
+ connections already close after each command.
69
+
70
+ Examples:
71
+ close-connection { connectionName: "scnet" }
72
+ close-connection { connectionName: "dcu" }`,
73
+ "upload": `upload — Upload a single local file to a remote server over SFTP.
45
74
 
46
75
  Parameters:
47
76
  localPath (string, required) File path on the MCP host.
@@ -113,18 +142,20 @@ Example:
113
142
  };
114
143
  const TOOL_OVERVIEW = `Available tools (use help { tool: "<name>" } for details):
115
144
 
116
- list-servers Discover available SSH servers and their status.
117
- execute-command Run a shell command on a remote server.
145
+ list-servers Discover available SSH servers and their status.
146
+ execute-command Run a shell command on a remote server.
118
147
  show-whitelist Show the active command policy.
119
- upload Upload a single file to a remote server.
120
- download Download a single file from a remote server.
148
+ close-connection Close a cached SSH connection for a server.
149
+ upload Upload a single file to a remote server.
150
+ download Download a single file from a remote server.
121
151
  transfer Move files: single, recursive, or cross-server relay.
122
152
  help Show this help or detailed per-tool usage.
123
153
 
124
154
  Quick start:
125
- 1. list-servers → discover server names
155
+ 1. list-servers → discover server names
126
156
  2. show-whitelist { connectionName: "<name>" } → inspect command policy
127
- 3. execute-command { cmdString: "pwd", connectionName: "<name>" }`;
157
+ 3. execute-command { cmdString: "pwd", connectionName: "<name>" }
158
+ 4. close-connection { connectionName: "<name>" } → drop a stale cached SSH client`;
128
159
  /**
129
160
  * Register help tool
130
161
  */
@@ -3,6 +3,7 @@ import { registerUploadTool } from "./upload.js";
3
3
  import { registerDownloadTool } from "./download.js";
4
4
  import { registerListServersTool } from "./list-servers.js";
5
5
  import { registerShowWhitelistTool } from "./show-whitelist.js";
6
+ import { registerCloseConnectionTool } from "./close-connection.js";
6
7
  import { registerTransferTool } from "./transfer.js";
7
8
  import { registerHelpTool } from "./help.js";
8
9
  /**
@@ -15,6 +16,7 @@ export function registerAllTools(server) {
15
16
  registerDownloadTool(server);
16
17
  registerListServersTool(server);
17
18
  registerShowWhitelistTool(server);
19
+ registerCloseConnectionTool(server);
18
20
  registerTransferTool(server);
19
21
  registerHelpTool(server);
20
22
  }
@@ -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 += `⚠️ **\`allowedRemoteDirectories\` is NOT configured.** ` +
107
- `SFTP upload/download/transfer is **disabled** for this server every call will be rejected with \`REMOTE_PATH_NOT_ALLOWED\`. ` +
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
- output += `Always implicitly allowed: \`${mcpCwd}\` _(the MCP working directory)_\n\n`;
120
- if (allowedLocalDirs.length === 0) {
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 += `Additional entries (${allowedLocalDirs.length}):\n\n`;
127
- for (const dir of allowedLocalDirs) {
128
- output += `- \`${dir}\`\n`;
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 += `- A path is allowed iff it equals an entry above, or starts with \`<entry> + separator\`.\n`;
135
- output += `- Remote paths must be absolute POSIX (\`/...\`); \`..\` segments and null bytes are rejected up-front.\n`;
136
- output += `- Local paths are resolved on the MCP host first, then matched.\n`;
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();
@@ -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. Must live inside one of the server's allowedRemoteDirectories — call show-whitelist to discover the allowed paths."),
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. Must live inside the source server's allowedRemoteDirectories."),
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. Must live inside the destination server's allowedRemoteDirectories."),
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. " +
@@ -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. The remote destination must live inside one of the server's allowedRemoteDirectories; call show-whitelist to discover the allowed paths. " +
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 inside one of the server's allowedRemoteDirectories (e.g. /home/user/uploads/file.txt)."),
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaarc/handfree-ssh-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Handfree SSH MCP Server - A hands-free SSH automation tool via MCP protocol",
5
5
  "main": "build/index.js",
6
6
  "type": "module",