ssh-mcp 1.0.9 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/build/index.js +203 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,6 +48,10 @@
|
|
|
48
48
|
- Timeout is configured via command line argument `--timeout` (in milliseconds)
|
|
49
49
|
- Default timeout: 60000ms (1 minute)
|
|
50
50
|
- When a command times out, the server automatically attempts to abort the running process before closing the connection
|
|
51
|
+
- **Max Command Length Configuration:**
|
|
52
|
+
- Max command characters are configured via `--maxChars`
|
|
53
|
+
- Default: `1000`
|
|
54
|
+
- No-limit mode: set `--maxChars=none` or any `<= 0` value (e.g. `--maxChars=0`)
|
|
51
55
|
|
|
52
56
|
## Installation
|
|
53
57
|
|
|
@@ -74,6 +78,7 @@ You can configure Claude Desktop to use this MCP Server.
|
|
|
74
78
|
- `password`: SSH password (or use `key` for key-based auth)
|
|
75
79
|
- `key`: Path to private SSH key
|
|
76
80
|
- `timeout`: Command execution timeout in milliseconds (default: 60000ms = 1 minute)
|
|
81
|
+
- `maxChars`: Maximum allowed characters for the `command` input (default: 1000). Use `none` or `0` to disable the limit.
|
|
77
82
|
|
|
78
83
|
|
|
79
84
|
```commandline
|
|
@@ -90,7 +95,8 @@ You can configure Claude Desktop to use this MCP Server.
|
|
|
90
95
|
"--user=root",
|
|
91
96
|
"--password=pass",
|
|
92
97
|
"--key=path/to/key",
|
|
93
|
-
"--timeout=30000"
|
|
98
|
+
"--timeout=30000",
|
|
99
|
+
"--maxChars=none"
|
|
94
100
|
]
|
|
95
101
|
}
|
|
96
102
|
}
|
package/build/index.js
CHANGED
|
@@ -24,6 +24,27 @@ const USER = argvConfig.user;
|
|
|
24
24
|
const PASSWORD = argvConfig.password;
|
|
25
25
|
const KEY = argvConfig.key;
|
|
26
26
|
const DEFAULT_TIMEOUT = argvConfig.timeout ? parseInt(argvConfig.timeout) : 60000; // 60 seconds default timeout
|
|
27
|
+
// Max characters configuration:
|
|
28
|
+
// - Default: 1000 characters
|
|
29
|
+
// - When set via --maxChars:
|
|
30
|
+
// * a positive integer enforces that limit
|
|
31
|
+
// * 0 or a negative value disables the limit (no max)
|
|
32
|
+
// * the string "none" (case-insensitive) disables the limit (no max)
|
|
33
|
+
const MAX_CHARS_RAW = argvConfig.maxChars;
|
|
34
|
+
const MAX_CHARS = (() => {
|
|
35
|
+
if (typeof MAX_CHARS_RAW === 'string') {
|
|
36
|
+
const lowered = MAX_CHARS_RAW.toLowerCase();
|
|
37
|
+
if (lowered === 'none')
|
|
38
|
+
return Infinity;
|
|
39
|
+
const parsed = parseInt(MAX_CHARS_RAW);
|
|
40
|
+
if (isNaN(parsed))
|
|
41
|
+
return 1000;
|
|
42
|
+
if (parsed <= 0)
|
|
43
|
+
return Infinity;
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
return 1000;
|
|
47
|
+
})();
|
|
27
48
|
function validateConfig(config) {
|
|
28
49
|
const errors = [];
|
|
29
50
|
if (!config.host)
|
|
@@ -49,8 +70,8 @@ export function sanitizeCommand(command) {
|
|
|
49
70
|
throw new McpError(ErrorCode.InvalidParams, 'Command cannot be empty');
|
|
50
71
|
}
|
|
51
72
|
// Length check
|
|
52
|
-
if (trimmedCommand.length >
|
|
53
|
-
throw new McpError(ErrorCode.InvalidParams,
|
|
73
|
+
if (Number.isFinite(MAX_CHARS) && trimmedCommand.length > MAX_CHARS) {
|
|
74
|
+
throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${MAX_CHARS} characters)`);
|
|
54
75
|
}
|
|
55
76
|
return trimmedCommand;
|
|
56
77
|
}
|
|
@@ -59,9 +80,86 @@ export function escapeCommandForShell(command) {
|
|
|
59
80
|
// Replace single quotes with escaped single quotes
|
|
60
81
|
return command.replace(/'/g, "'\"'\"'");
|
|
61
82
|
}
|
|
83
|
+
// SSH Connection Manager to maintain persistent connection
|
|
84
|
+
export class SSHConnectionManager {
|
|
85
|
+
conn = null;
|
|
86
|
+
sshConfig = null;
|
|
87
|
+
isConnecting = false;
|
|
88
|
+
connectionPromise = null;
|
|
89
|
+
constructor(config) {
|
|
90
|
+
this.sshConfig = config;
|
|
91
|
+
}
|
|
92
|
+
async connect() {
|
|
93
|
+
if (this.conn && this.isConnected()) {
|
|
94
|
+
return; // Already connected
|
|
95
|
+
}
|
|
96
|
+
if (this.isConnecting && this.connectionPromise) {
|
|
97
|
+
return this.connectionPromise; // Wait for ongoing connection
|
|
98
|
+
}
|
|
99
|
+
this.isConnecting = true;
|
|
100
|
+
this.connectionPromise = new Promise((resolve, reject) => {
|
|
101
|
+
this.conn = new SSHClient();
|
|
102
|
+
const timeoutId = setTimeout(() => {
|
|
103
|
+
this.conn?.end();
|
|
104
|
+
this.conn = null;
|
|
105
|
+
this.isConnecting = false;
|
|
106
|
+
this.connectionPromise = null;
|
|
107
|
+
reject(new McpError(ErrorCode.InternalError, 'SSH connection timeout'));
|
|
108
|
+
}, 30000); // 30 seconds connection timeout
|
|
109
|
+
this.conn.on('ready', () => {
|
|
110
|
+
clearTimeout(timeoutId);
|
|
111
|
+
this.isConnecting = false;
|
|
112
|
+
console.error('SSH connection established');
|
|
113
|
+
resolve();
|
|
114
|
+
});
|
|
115
|
+
this.conn.on('error', (err) => {
|
|
116
|
+
clearTimeout(timeoutId);
|
|
117
|
+
this.conn = null;
|
|
118
|
+
this.isConnecting = false;
|
|
119
|
+
this.connectionPromise = null;
|
|
120
|
+
reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
|
|
121
|
+
});
|
|
122
|
+
this.conn.on('end', () => {
|
|
123
|
+
console.error('SSH connection ended');
|
|
124
|
+
this.conn = null;
|
|
125
|
+
this.isConnecting = false;
|
|
126
|
+
this.connectionPromise = null;
|
|
127
|
+
});
|
|
128
|
+
this.conn.on('close', () => {
|
|
129
|
+
console.error('SSH connection closed');
|
|
130
|
+
this.conn = null;
|
|
131
|
+
this.isConnecting = false;
|
|
132
|
+
this.connectionPromise = null;
|
|
133
|
+
});
|
|
134
|
+
this.conn.connect(this.sshConfig);
|
|
135
|
+
});
|
|
136
|
+
return this.connectionPromise;
|
|
137
|
+
}
|
|
138
|
+
isConnected() {
|
|
139
|
+
return this.conn !== null && this.conn._sock && !this.conn._sock.destroyed;
|
|
140
|
+
}
|
|
141
|
+
async ensureConnected() {
|
|
142
|
+
if (!this.isConnected()) {
|
|
143
|
+
await this.connect();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
getConnection() {
|
|
147
|
+
if (!this.conn) {
|
|
148
|
+
throw new McpError(ErrorCode.InternalError, 'SSH connection not established');
|
|
149
|
+
}
|
|
150
|
+
return this.conn;
|
|
151
|
+
}
|
|
152
|
+
close() {
|
|
153
|
+
if (this.conn) {
|
|
154
|
+
this.conn.end();
|
|
155
|
+
this.conn = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let connectionManager = null;
|
|
62
160
|
const server = new McpServer({
|
|
63
161
|
name: 'SSH MCP Server',
|
|
64
|
-
version: '1.0
|
|
162
|
+
version: '1.2.0',
|
|
65
163
|
capabilities: {
|
|
66
164
|
resources: {},
|
|
67
165
|
tools: {},
|
|
@@ -72,20 +170,26 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
|
|
|
72
170
|
}, async ({ command }) => {
|
|
73
171
|
// Sanitize command input
|
|
74
172
|
const sanitizedCommand = sanitizeCommand(command);
|
|
75
|
-
const sshConfig = {
|
|
76
|
-
host: HOST,
|
|
77
|
-
port: PORT,
|
|
78
|
-
username: USER,
|
|
79
|
-
};
|
|
80
173
|
try {
|
|
81
|
-
if
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
174
|
+
// Initialize connection manager if not already done
|
|
175
|
+
if (!connectionManager) {
|
|
176
|
+
const sshConfig = {
|
|
177
|
+
host: HOST,
|
|
178
|
+
port: PORT,
|
|
179
|
+
username: USER,
|
|
180
|
+
};
|
|
181
|
+
if (PASSWORD) {
|
|
182
|
+
sshConfig.password = PASSWORD;
|
|
183
|
+
}
|
|
184
|
+
else if (KEY) {
|
|
185
|
+
const fs = await import('fs/promises');
|
|
186
|
+
sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
|
|
187
|
+
}
|
|
188
|
+
connectionManager = new SSHConnectionManager(sshConfig);
|
|
87
189
|
}
|
|
88
|
-
|
|
190
|
+
// Ensure connection is active (reconnect if needed)
|
|
191
|
+
await connectionManager.ensureConnected();
|
|
192
|
+
const result = await execSshCommandWithConnection(connectionManager, sanitizedCommand);
|
|
89
193
|
return result;
|
|
90
194
|
}
|
|
91
195
|
catch (err) {
|
|
@@ -95,6 +199,71 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
|
|
|
95
199
|
throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
|
|
96
200
|
}
|
|
97
201
|
});
|
|
202
|
+
// New function that uses persistent connection
|
|
203
|
+
export async function execSshCommandWithConnection(manager, command) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
let timeoutId;
|
|
206
|
+
let isResolved = false;
|
|
207
|
+
const conn = manager.getConnection();
|
|
208
|
+
// Set up timeout
|
|
209
|
+
timeoutId = setTimeout(() => {
|
|
210
|
+
if (!isResolved) {
|
|
211
|
+
isResolved = true;
|
|
212
|
+
// Try to abort the running command
|
|
213
|
+
const abortTimeout = setTimeout(() => {
|
|
214
|
+
// If abort command itself times out, we'll just reject
|
|
215
|
+
}, 5000);
|
|
216
|
+
conn.exec('timeout 3s pkill -f \'' + escapeCommandForShell(command) + '\' 2>/dev/null || true', (err, abortStream) => {
|
|
217
|
+
if (abortStream) {
|
|
218
|
+
abortStream.on('close', () => {
|
|
219
|
+
clearTimeout(abortTimeout);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
clearTimeout(abortTimeout);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
|
|
227
|
+
}
|
|
228
|
+
}, DEFAULT_TIMEOUT);
|
|
229
|
+
conn.exec(command, (err, stream) => {
|
|
230
|
+
if (err) {
|
|
231
|
+
if (!isResolved) {
|
|
232
|
+
isResolved = true;
|
|
233
|
+
clearTimeout(timeoutId);
|
|
234
|
+
reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
let stdout = '';
|
|
239
|
+
let stderr = '';
|
|
240
|
+
stream.on('close', (code, signal) => {
|
|
241
|
+
if (!isResolved) {
|
|
242
|
+
isResolved = true;
|
|
243
|
+
clearTimeout(timeoutId);
|
|
244
|
+
if (stderr) {
|
|
245
|
+
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
resolve({
|
|
249
|
+
content: [{
|
|
250
|
+
type: 'text',
|
|
251
|
+
text: stdout,
|
|
252
|
+
}],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
stream.on('data', (data) => {
|
|
258
|
+
stdout += data.toString();
|
|
259
|
+
});
|
|
260
|
+
stream.stderr.on('data', (data) => {
|
|
261
|
+
stderr += data.toString();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// Keep the old function for backward compatibility (used in tests)
|
|
98
267
|
export async function execSshCommand(sshConfig, command) {
|
|
99
268
|
return new Promise((resolve, reject) => {
|
|
100
269
|
const conn = new SSHClient();
|
|
@@ -177,10 +346,29 @@ async function main() {
|
|
|
177
346
|
const transport = new StdioServerTransport();
|
|
178
347
|
await server.connect(transport);
|
|
179
348
|
console.error("SSH MCP Server running on stdio");
|
|
349
|
+
// Handle graceful shutdown
|
|
350
|
+
const cleanup = () => {
|
|
351
|
+
console.error("Shutting down SSH MCP Server...");
|
|
352
|
+
if (connectionManager) {
|
|
353
|
+
connectionManager.close();
|
|
354
|
+
connectionManager = null;
|
|
355
|
+
}
|
|
356
|
+
process.exit(0);
|
|
357
|
+
};
|
|
358
|
+
process.on('SIGINT', cleanup);
|
|
359
|
+
process.on('SIGTERM', cleanup);
|
|
360
|
+
process.on('exit', () => {
|
|
361
|
+
if (connectionManager) {
|
|
362
|
+
connectionManager.close();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
180
365
|
}
|
|
181
366
|
if (process.env.SSH_MCP_DISABLE_MAIN !== '1') {
|
|
182
367
|
main().catch((error) => {
|
|
183
368
|
console.error("Fatal error in main():", error);
|
|
369
|
+
if (connectionManager) {
|
|
370
|
+
connectionManager.close();
|
|
371
|
+
}
|
|
184
372
|
process.exit(1);
|
|
185
373
|
});
|
|
186
374
|
}
|