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.
Files changed (2) hide show
  1. package/build/index.js +136 -78
  2. 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
- console.error('SSH connection established');
134
- // If suPassword is provided, attempt to elevate to su shell in background
135
- // Do not block connection establishment on elevation (it may take time);
136
- // elevation is attempted asynchronously and errors are logged.
137
- if (this.sshConfig.suPassword) {
138
- this.ensureElevated().then(() => {
139
- console.error('Successfully elevated to su shell');
140
- }).catch((err) => {
141
- console.error('Failed to elevate to su shell:', err);
142
- // Do not reject connection; just log the error. Subsequent sudo calls
143
- // will either use the su shell if available or provide sudo password.
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
- // Open an interactive shell and run `su -` inside it, responding to the password prompt.
187
- // Request a PTY so that su/sudo can prompt for a password interactively.
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
- buffer += data.toString();
203
- // Detect password prompt from su
204
- if (/password[: ]*$/i.test(buffer)) {
205
- // Send the su password
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
- buffer = '';
208
- return;
238
+ // Don't return; keep looking for root prompt
209
239
  }
210
- // Detect common root prompt (ends with #) or explicit root indicator
211
- if (/\n[^\n]*# $/.test(buffer) || /root[@:]/i.test(buffer)) {
212
- // We appear to be elevated; keep the interactive shell as suShell
213
- cleanup();
214
- this.suShell = stream;
215
- this.isElevated = true;
216
- this.suPromise = null;
217
- resolve();
218
- return;
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.3.0',
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
- let execCommand = command;
398
- const execFn = shell ?
399
- (cb) => {
400
- shell.write(execCommand + '\n');
401
- cb(null, shell);
402
- } :
403
- (cb) => {
404
- // If we have stdin to send (e.g., sudo -S), request a PTY so sudo can read the
405
- // password reliably in environments that require a TTY.
406
- if (stdin && stdin.length > 0) {
407
- conn.exec(execCommand, { pty: true }, cb);
408
- }
409
- else {
410
- conn.exec(execCommand, cb);
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
- execFn((err, stream) => {
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
- // Setup data handlers before sending any input
425
- stream.on('data', (data) => {
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
- else {
443
- // No stdin, just end the stream (guard in case stream.end is not available)
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.3.0",
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
+ }