ssh-mcp 1.3.0 → 1.4.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/build/index.js +136 -78
- package/package.json +2 -2
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.4.0',
|
|
271
306
|
capabilities: {
|
|
272
307
|
resources: {},
|
|
273
308
|
tools: {},
|
|
@@ -303,6 +338,22 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
|
|
|
303
338
|
}
|
|
304
339
|
// Ensure connection is active (reconnect if needed)
|
|
305
340
|
await connectionManager.ensureConnected();
|
|
341
|
+
// If a suPassword was provided, explicitly wait for elevation before executing.
|
|
342
|
+
// This is critical: ensureElevated is idempotent and will return immediately if
|
|
343
|
+
// already elevated, so this ensures we have a su shell before we try to use it.
|
|
344
|
+
if (connectionManager.getSuPassword && connectionManager.getSuPassword()) {
|
|
345
|
+
try {
|
|
346
|
+
const elevationPromise = connectionManager.ensureElevated();
|
|
347
|
+
// Add a short timeout for elevation to complete
|
|
348
|
+
await Promise.race([
|
|
349
|
+
elevationPromise,
|
|
350
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Elevation timeout')), 5000))
|
|
351
|
+
]);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
// Log but don't fail; fall back to non-elevated execution if elevation times out
|
|
355
|
+
}
|
|
356
|
+
}
|
|
306
357
|
const result = await execSshCommandWithConnection(connectionManager, sanitizedCommand);
|
|
307
358
|
return result;
|
|
308
359
|
}
|
|
@@ -345,6 +396,17 @@ if (!DISABLE_SUDO) {
|
|
|
345
396
|
connectionManager = new SSHConnectionManager(sshConfig);
|
|
346
397
|
}
|
|
347
398
|
await connectionManager.ensureConnected();
|
|
399
|
+
// If suPassword or sudoPassword were provided on this call but the
|
|
400
|
+
// existing connection manager was created earlier without them,
|
|
401
|
+
// update the manager's values so the subsequent sudo-exec call uses
|
|
402
|
+
// the latest passwords.
|
|
403
|
+
if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
|
|
404
|
+
await connectionManager.setSuPassword(sanitizePassword(SUPASSWORD));
|
|
405
|
+
}
|
|
406
|
+
if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
|
|
407
|
+
// update sudoPassword on the manager instance
|
|
408
|
+
connectionManager.sshConfig = { ...connectionManager.sshConfig, sudoPassword: sanitizePassword(SUDOPASSWORD) };
|
|
409
|
+
}
|
|
348
410
|
let wrapped;
|
|
349
411
|
const sudoPassword = connectionManager.getSudoPassword();
|
|
350
412
|
if (!sudoPassword) {
|
|
@@ -377,40 +439,42 @@ export async function execSshCommandWithConnection(manager, command, stdin) {
|
|
|
377
439
|
timeoutId = setTimeout(() => {
|
|
378
440
|
if (!isResolved) {
|
|
379
441
|
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
442
|
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
|
|
395
443
|
}
|
|
396
444
|
}, DEFAULT_TIMEOUT);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
445
|
+
// If we have an active su shell, use it directly (commands run as root in session)
|
|
446
|
+
if (shell) {
|
|
447
|
+
let buffer = '';
|
|
448
|
+
const dataHandler = (data) => {
|
|
449
|
+
const text = data.toString();
|
|
450
|
+
buffer += text;
|
|
451
|
+
// Wait for root prompt (#) to know command is complete
|
|
452
|
+
// Match # which indicates root prompt (may be followed by spaces, escape codes, etc)
|
|
453
|
+
if (/#/.test(buffer)) {
|
|
454
|
+
if (!isResolved) {
|
|
455
|
+
isResolved = true;
|
|
456
|
+
clearTimeout(timeoutId);
|
|
457
|
+
// Extract output: remove the command echo and final prompt
|
|
458
|
+
const lines = buffer.split('\n');
|
|
459
|
+
// First line is often the echoed command; last line is the prompt
|
|
460
|
+
let output = lines.slice(1, -1).join('\n');
|
|
461
|
+
resolve({
|
|
462
|
+
content: [{
|
|
463
|
+
type: 'text',
|
|
464
|
+
text: output + (output ? '\n' : ''),
|
|
465
|
+
}],
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
shell.removeListener('data', dataHandler);
|
|
411
469
|
}
|
|
412
470
|
};
|
|
413
|
-
|
|
471
|
+
shell.on('data', dataHandler);
|
|
472
|
+
// Send command immediately; shell is ready after elevation
|
|
473
|
+
shell.write(command + '\n');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
// No persistent su shell; use normal exec with optional password piping
|
|
477
|
+
conn.exec(command, (err, stream) => {
|
|
414
478
|
if (err) {
|
|
415
479
|
if (!isResolved) {
|
|
416
480
|
isResolved = true;
|
|
@@ -421,31 +485,25 @@ export async function execSshCommandWithConnection(manager, command, stdin) {
|
|
|
421
485
|
}
|
|
422
486
|
let stdout = '';
|
|
423
487
|
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) {
|
|
488
|
+
// If stdin provided (e.g., sudo password), write it
|
|
489
|
+
if (stdin && stdin.length > 0) {
|
|
433
490
|
try {
|
|
434
|
-
// Write to the exec channel (send to remote stdin)
|
|
435
491
|
stream.write(stdin);
|
|
436
|
-
stream.end(); // End stdin after writing password
|
|
437
492
|
}
|
|
438
493
|
catch (e) {
|
|
439
494
|
console.error('Error writing to stdin:', e);
|
|
440
495
|
}
|
|
441
496
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
try {
|
|
445
|
-
stream.end();
|
|
446
|
-
}
|
|
447
|
-
catch (e) { /* ignore */ }
|
|
497
|
+
try {
|
|
498
|
+
stream.end();
|
|
448
499
|
}
|
|
500
|
+
catch (e) { /* ignore */ }
|
|
501
|
+
stream.on('data', (data) => {
|
|
502
|
+
stdout += data.toString();
|
|
503
|
+
});
|
|
504
|
+
stream.stderr.on('data', (data) => {
|
|
505
|
+
stderr += data.toString();
|
|
506
|
+
});
|
|
449
507
|
stream.on('close', (code, signal) => {
|
|
450
508
|
if (!isResolved) {
|
|
451
509
|
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.4.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
|
+
}
|