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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/build/index.js +151 -83
  3. 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
- 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.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
- }, async ({ command }) => {
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
- const result = await execSshCommandWithConnection(connectionManager, sanitizedCommand);
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
- }, async ({ command }) => {
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 '${sanitizedCommand.replace(/'/g, "'\\''")}'`;
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 '${sanitizedCommand.replace(/'/g, "'\\''")}'`;
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
- 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);
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
- execFn((err, stream) => {
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
- // 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) {
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
- 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 */ }
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.3.0",
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
+ }