ssh-mcp 1.3.0 → 1.5.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 +2 -0
- package/build/index.js +151 -83
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -44,11 +44,13 @@
|
|
|
44
44
|
- `exec`: Execute a shell command on the remote server
|
|
45
45
|
- **Parameters:**
|
|
46
46
|
- `command` (required): Shell command to execute on the remote SSH server
|
|
47
|
+
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
47
48
|
- **Timeout Configuration:**
|
|
48
49
|
|
|
49
50
|
- `sudo-exec`: Execute a shell command with sudo elevation
|
|
50
51
|
- **Parameters:**
|
|
51
52
|
- `command` (required): Shell command to execute as root using sudo
|
|
53
|
+
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
52
54
|
- **Notes:**
|
|
53
55
|
- Requires `--sudoPassword` to be set for password-protected sudo
|
|
54
56
|
- Can be disabled by passing the `--disableSudo` flag at startup if sudo access is not needed or not available
|
package/build/index.js
CHANGED
|
@@ -130,18 +130,18 @@ export class SSHConnectionManager {
|
|
|
130
130
|
this.conn.on('ready', async () => {
|
|
131
131
|
clearTimeout(timeoutId);
|
|
132
132
|
this.isConnecting = false;
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// elevation
|
|
137
|
-
if (this.sshConfig.suPassword) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Do not reject connection; just log the error. Subsequent
|
|
143
|
-
// will either use the su shell if available or
|
|
144
|
-
}
|
|
133
|
+
// In test mode, don't wait for su elevation during connection setup, as it
|
|
134
|
+
// may cause JSON-RPC server initialization to hang. Instead, elevation will
|
|
135
|
+
// be triggered on-demand when a command is executed.
|
|
136
|
+
// In production, elevation during connection is desirable for robustness.
|
|
137
|
+
if (this.sshConfig.suPassword && !process.env.SSH_MCP_TEST) {
|
|
138
|
+
try {
|
|
139
|
+
await this.ensureElevated();
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
// Do not reject the connection; just log the error. Subsequent commands
|
|
143
|
+
// will either use the su shell if available or fall back to normal execution.
|
|
144
|
+
}
|
|
145
145
|
}
|
|
146
146
|
resolve();
|
|
147
147
|
});
|
|
@@ -174,6 +174,31 @@ export class SSHConnectionManager {
|
|
|
174
174
|
getSudoPassword() {
|
|
175
175
|
return this.sshConfig.sudoPassword;
|
|
176
176
|
}
|
|
177
|
+
getSuPassword() {
|
|
178
|
+
return this.sshConfig.suPassword;
|
|
179
|
+
}
|
|
180
|
+
async setSuPassword(pwd) {
|
|
181
|
+
this.sshConfig.suPassword = pwd;
|
|
182
|
+
if (pwd) {
|
|
183
|
+
try {
|
|
184
|
+
await this.ensureElevated();
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
console.error('setSuPassword: failed to elevate to su shell:', err);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// If clearing suPassword, drop any existing suShell
|
|
192
|
+
if (this.suShell) {
|
|
193
|
+
try {
|
|
194
|
+
this.suShell.end();
|
|
195
|
+
}
|
|
196
|
+
catch (e) { /* ignore */ }
|
|
197
|
+
this.suShell = null;
|
|
198
|
+
this.isElevated = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
177
202
|
async ensureElevated() {
|
|
178
203
|
if (this.isElevated && this.suShell)
|
|
179
204
|
return;
|
|
@@ -183,15 +208,20 @@ export class SSHConnectionManager {
|
|
|
183
208
|
return this.suPromise;
|
|
184
209
|
this.suPromise = new Promise((resolve, reject) => {
|
|
185
210
|
const conn = this.getConnection();
|
|
186
|
-
//
|
|
187
|
-
|
|
211
|
+
// Add a safety timeout so elevation doesn't hang forever
|
|
212
|
+
const timeoutId = setTimeout(() => {
|
|
213
|
+
this.suPromise = null;
|
|
214
|
+
reject(new McpError(ErrorCode.InternalError, 'su elevation timed out'));
|
|
215
|
+
}, 10000); // 10 second timeout for elevation
|
|
188
216
|
conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => {
|
|
189
217
|
if (err) {
|
|
218
|
+
clearTimeout(timeoutId);
|
|
190
219
|
this.suPromise = null;
|
|
191
220
|
reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su: ${err.message}`));
|
|
192
221
|
return;
|
|
193
222
|
}
|
|
194
223
|
let buffer = '';
|
|
224
|
+
let passwordSent = false;
|
|
195
225
|
const cleanup = () => {
|
|
196
226
|
try {
|
|
197
227
|
stream.removeAllListeners('data');
|
|
@@ -199,26 +229,30 @@ export class SSHConnectionManager {
|
|
|
199
229
|
catch (e) { /* ignore */ }
|
|
200
230
|
};
|
|
201
231
|
const onData = (data) => {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
232
|
+
const text = data.toString();
|
|
233
|
+
buffer += text;
|
|
234
|
+
// If we haven't sent the password yet, look for the password prompt
|
|
235
|
+
if (!passwordSent && /password[: ]/i.test(buffer)) {
|
|
236
|
+
passwordSent = true;
|
|
206
237
|
stream.write(this.sshConfig.suPassword + '\n');
|
|
207
|
-
|
|
208
|
-
return;
|
|
238
|
+
// Don't return; keep looking for root prompt
|
|
209
239
|
}
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
240
|
+
// After password is sent, look for any root indicator
|
|
241
|
+
// Look for '#' which indicates root prompt (may be followed by spaces, escape codes, etc)
|
|
242
|
+
if (passwordSent) {
|
|
243
|
+
if (/#/.test(buffer)) {
|
|
244
|
+
clearTimeout(timeoutId);
|
|
245
|
+
cleanup();
|
|
246
|
+
this.suShell = stream;
|
|
247
|
+
this.isElevated = true;
|
|
248
|
+
this.suPromise = null;
|
|
249
|
+
resolve();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
219
252
|
}
|
|
220
|
-
// Detect authentication failure
|
|
221
|
-
if (/authentication failure|incorrect password|su: .*failed/i.test(buffer)) {
|
|
253
|
+
// Detect authentication failure messages
|
|
254
|
+
if (/authentication failure|incorrect password|su: .*failed|su: failure/i.test(buffer)) {
|
|
255
|
+
clearTimeout(timeoutId);
|
|
222
256
|
cleanup();
|
|
223
257
|
this.suPromise = null;
|
|
224
258
|
reject(new McpError(ErrorCode.InternalError, `su authentication failed: ${buffer}`));
|
|
@@ -227,6 +261,7 @@ export class SSHConnectionManager {
|
|
|
227
261
|
};
|
|
228
262
|
stream.on('data', onData);
|
|
229
263
|
stream.on('close', () => {
|
|
264
|
+
clearTimeout(timeoutId);
|
|
230
265
|
if (!this.isElevated) {
|
|
231
266
|
this.suPromise = null;
|
|
232
267
|
reject(new McpError(ErrorCode.InternalError, 'su shell closed before elevation completed'));
|
|
@@ -267,7 +302,7 @@ export class SSHConnectionManager {
|
|
|
267
302
|
let connectionManager = null;
|
|
268
303
|
const server = new McpServer({
|
|
269
304
|
name: 'SSH MCP Server',
|
|
270
|
-
version: '1.
|
|
305
|
+
version: '1.5.0',
|
|
271
306
|
capabilities: {
|
|
272
307
|
resources: {},
|
|
273
308
|
tools: {},
|
|
@@ -275,7 +310,8 @@ const server = new McpServer({
|
|
|
275
310
|
});
|
|
276
311
|
server.tool("exec", "Execute a shell command on the remote SSH server and return the output.", {
|
|
277
312
|
command: z.string().describe("Shell command to execute on the remote SSH server"),
|
|
278
|
-
|
|
313
|
+
description: z.string().optional().describe("Optional description of what this command will do"),
|
|
314
|
+
}, async ({ command, description }) => {
|
|
279
315
|
// Sanitize command input
|
|
280
316
|
const sanitizedCommand = sanitizeCommand(command);
|
|
281
317
|
try {
|
|
@@ -303,7 +339,27 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
|
|
|
303
339
|
}
|
|
304
340
|
// Ensure connection is active (reconnect if needed)
|
|
305
341
|
await connectionManager.ensureConnected();
|
|
306
|
-
|
|
342
|
+
// If a suPassword was provided, explicitly wait for elevation before executing.
|
|
343
|
+
// This is critical: ensureElevated is idempotent and will return immediately if
|
|
344
|
+
// already elevated, so this ensures we have a su shell before we try to use it.
|
|
345
|
+
if (connectionManager.getSuPassword && connectionManager.getSuPassword()) {
|
|
346
|
+
try {
|
|
347
|
+
const elevationPromise = connectionManager.ensureElevated();
|
|
348
|
+
// Add a short timeout for elevation to complete
|
|
349
|
+
await Promise.race([
|
|
350
|
+
elevationPromise,
|
|
351
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Elevation timeout')), 5000))
|
|
352
|
+
]);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
// Log but don't fail; fall back to non-elevated execution if elevation times out
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Append description as comment if provided
|
|
359
|
+
const commandWithDescription = description
|
|
360
|
+
? `${sanitizedCommand} # ${description.replace(/#/g, '\\#')}`
|
|
361
|
+
: sanitizedCommand;
|
|
362
|
+
const result = await execSshCommandWithConnection(connectionManager, commandWithDescription);
|
|
307
363
|
return result;
|
|
308
364
|
}
|
|
309
365
|
catch (err) {
|
|
@@ -317,7 +373,8 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
|
|
|
317
373
|
if (!DISABLE_SUDO) {
|
|
318
374
|
server.tool("sudo-exec", "Execute a shell command on the remote SSH server using sudo. Will use sudo password if provided, otherwise assumes passwordless sudo.", {
|
|
319
375
|
command: z.string().describe("Shell command to execute with sudo on the remote SSH server"),
|
|
320
|
-
|
|
376
|
+
description: z.string().optional().describe("Optional description of what this command will do"),
|
|
377
|
+
}, async ({ command, description }) => {
|
|
321
378
|
const sanitizedCommand = sanitizeCommand(command);
|
|
322
379
|
try {
|
|
323
380
|
if (!connectionManager) {
|
|
@@ -345,17 +402,32 @@ if (!DISABLE_SUDO) {
|
|
|
345
402
|
connectionManager = new SSHConnectionManager(sshConfig);
|
|
346
403
|
}
|
|
347
404
|
await connectionManager.ensureConnected();
|
|
405
|
+
// If suPassword or sudoPassword were provided on this call but the
|
|
406
|
+
// existing connection manager was created earlier without them,
|
|
407
|
+
// update the manager's values so the subsequent sudo-exec call uses
|
|
408
|
+
// the latest passwords.
|
|
409
|
+
if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
|
|
410
|
+
await connectionManager.setSuPassword(sanitizePassword(SUPASSWORD));
|
|
411
|
+
}
|
|
412
|
+
if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
|
|
413
|
+
// update sudoPassword on the manager instance
|
|
414
|
+
connectionManager.sshConfig = { ...connectionManager.sshConfig, sudoPassword: sanitizePassword(SUDOPASSWORD) };
|
|
415
|
+
}
|
|
348
416
|
let wrapped;
|
|
349
417
|
const sudoPassword = connectionManager.getSudoPassword();
|
|
418
|
+
// Append description as comment if provided
|
|
419
|
+
const commandWithDescription = description
|
|
420
|
+
? `${sanitizedCommand} # ${description.replace(/#/g, '\\#')}`
|
|
421
|
+
: sanitizedCommand;
|
|
350
422
|
if (!sudoPassword) {
|
|
351
423
|
// No password provided, use -n to fail if sudo requires a password
|
|
352
|
-
wrapped = `sudo -n sh -c '${
|
|
424
|
+
wrapped = `sudo -n sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
|
|
353
425
|
}
|
|
354
426
|
else {
|
|
355
427
|
// Password provided — pipe it into sudo using printf. This avoids complex
|
|
356
428
|
// PTY/stdin handling on the SSH channel and is simpler and more reliable.
|
|
357
429
|
const pwdEscaped = sudoPassword.replace(/'/g, "'\\''");
|
|
358
|
-
wrapped = `printf '%s\\n' '${pwdEscaped}' | sudo -p "" -S sh -c '${
|
|
430
|
+
wrapped = `printf '%s\\n' '${pwdEscaped}' | sudo -p "" -S sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
|
|
359
431
|
}
|
|
360
432
|
return await execSshCommandWithConnection(connectionManager, wrapped);
|
|
361
433
|
}
|
|
@@ -377,40 +449,42 @@ export async function execSshCommandWithConnection(manager, command, stdin) {
|
|
|
377
449
|
timeoutId = setTimeout(() => {
|
|
378
450
|
if (!isResolved) {
|
|
379
451
|
isResolved = true;
|
|
380
|
-
// Try to abort the running command
|
|
381
|
-
const abortTimeout = setTimeout(() => {
|
|
382
|
-
// If abort command itself times out, we'll just reject
|
|
383
|
-
}, 5000);
|
|
384
|
-
conn.exec('timeout 3s pkill -f \'' + escapeCommandForShell(command) + '\' 2>/dev/null || true', (err, abortStream) => {
|
|
385
|
-
if (abortStream) {
|
|
386
|
-
abortStream.on('close', () => {
|
|
387
|
-
clearTimeout(abortTimeout);
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
clearTimeout(abortTimeout);
|
|
392
|
-
}
|
|
393
|
-
});
|
|
394
452
|
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
|
|
395
453
|
}
|
|
396
454
|
}, DEFAULT_TIMEOUT);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
455
|
+
// If we have an active su shell, use it directly (commands run as root in session)
|
|
456
|
+
if (shell) {
|
|
457
|
+
let buffer = '';
|
|
458
|
+
const dataHandler = (data) => {
|
|
459
|
+
const text = data.toString();
|
|
460
|
+
buffer += text;
|
|
461
|
+
// Wait for root prompt (#) to know command is complete
|
|
462
|
+
// Match # which indicates root prompt (may be followed by spaces, escape codes, etc)
|
|
463
|
+
if (/#/.test(buffer)) {
|
|
464
|
+
if (!isResolved) {
|
|
465
|
+
isResolved = true;
|
|
466
|
+
clearTimeout(timeoutId);
|
|
467
|
+
// Extract output: remove the command echo and final prompt
|
|
468
|
+
const lines = buffer.split('\n');
|
|
469
|
+
// First line is often the echoed command; last line is the prompt
|
|
470
|
+
let output = lines.slice(1, -1).join('\n');
|
|
471
|
+
resolve({
|
|
472
|
+
content: [{
|
|
473
|
+
type: 'text',
|
|
474
|
+
text: output + (output ? '\n' : ''),
|
|
475
|
+
}],
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
shell.removeListener('data', dataHandler);
|
|
411
479
|
}
|
|
412
480
|
};
|
|
413
|
-
|
|
481
|
+
shell.on('data', dataHandler);
|
|
482
|
+
// Send command immediately; shell is ready after elevation
|
|
483
|
+
shell.write(command + '\n');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
// No persistent su shell; use normal exec with optional password piping
|
|
487
|
+
conn.exec(command, (err, stream) => {
|
|
414
488
|
if (err) {
|
|
415
489
|
if (!isResolved) {
|
|
416
490
|
isResolved = true;
|
|
@@ -421,31 +495,25 @@ export async function execSshCommandWithConnection(manager, command, stdin) {
|
|
|
421
495
|
}
|
|
422
496
|
let stdout = '';
|
|
423
497
|
let stderr = '';
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
stdout += data.toString();
|
|
427
|
-
});
|
|
428
|
-
stream.stderr.on('data', (data) => {
|
|
429
|
-
stderr += data.toString();
|
|
430
|
-
});
|
|
431
|
-
// If stdin provided, write it to the stream
|
|
432
|
-
if (stdin && stdin.length > 0 && !shell) {
|
|
498
|
+
// If stdin provided (e.g., sudo password), write it
|
|
499
|
+
if (stdin && stdin.length > 0) {
|
|
433
500
|
try {
|
|
434
|
-
// Write to the exec channel (send to remote stdin)
|
|
435
501
|
stream.write(stdin);
|
|
436
|
-
stream.end(); // End stdin after writing password
|
|
437
502
|
}
|
|
438
503
|
catch (e) {
|
|
439
504
|
console.error('Error writing to stdin:', e);
|
|
440
505
|
}
|
|
441
506
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
try {
|
|
445
|
-
stream.end();
|
|
446
|
-
}
|
|
447
|
-
catch (e) { /* ignore */ }
|
|
507
|
+
try {
|
|
508
|
+
stream.end();
|
|
448
509
|
}
|
|
510
|
+
catch (e) { /* ignore */ }
|
|
511
|
+
stream.on('data', (data) => {
|
|
512
|
+
stdout += data.toString();
|
|
513
|
+
});
|
|
514
|
+
stream.stderr.on('data', (data) => {
|
|
515
|
+
stderr += data.toString();
|
|
516
|
+
});
|
|
449
517
|
stream.on('close', (code, signal) => {
|
|
450
518
|
if (!isResolved) {
|
|
451
519
|
isResolved = true;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ssh-mcp",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"description": "MCP server exposing SSH control for Linux and Windows systems via Model Context Protocol.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -58,4 +58,4 @@
|
|
|
58
58
|
"engines": {
|
|
59
59
|
"node": ">=18"
|
|
60
60
|
}
|
|
61
|
-
}
|
|
61
|
+
}
|