ssh-mcp 1.2.2 → 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 (3) hide show
  1. package/README.md +18 -0
  2. package/build/index.js +319 -36
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -45,6 +45,16 @@
45
45
  - **Parameters:**
46
46
  - `command` (required): Shell command to execute on the remote SSH server
47
47
  - **Timeout Configuration:**
48
+
49
+ - `sudo-exec`: Execute a shell command with sudo elevation
50
+ - **Parameters:**
51
+ - `command` (required): Shell command to execute as root using sudo
52
+ - **Notes:**
53
+ - Requires `--sudoPassword` to be set for password-protected sudo
54
+ - Can be disabled by passing the `--disableSudo` flag at startup if sudo access is not needed or not available
55
+ - For persistent root access, consider using `--suPassword` instead which establishes a root shell
56
+ - Tool will not be available at all if server is started with `--disableSudo`
57
+ - **Timeout Configuration:**
48
58
  - Timeout is configured via command line argument `--timeout` (in milliseconds)
49
59
  - Default timeout: 60000ms (1 minute)
50
60
  - When a command times out, the server automatically attempts to abort the running process before closing the connection
@@ -77,8 +87,11 @@ You can configure your IDE or LLM like Cursor, Windsurf, Claude Desktop to use t
77
87
  - `port`: SSH port (default: 22)
78
88
  - `password`: SSH password (or use `key` for key-based auth)
79
89
  - `key`: Path to private SSH key
90
+ - `sudoPassword`: Password for sudo elevation (when executing commands with sudo)
91
+ - `suPassword`: Password for su elevation (when you need a persistent root shell)
80
92
  - `timeout`: Command execution timeout in milliseconds (default: 60000ms = 1 minute)
81
93
  - `maxChars`: Maximum allowed characters for the `command` input (default: 1000). Use `none` or `0` to disable the limit.
94
+ - `disableSudo`: Flag to disable the `sudo-exec` tool completely. Useful when sudo access is not needed or not available.
82
95
 
83
96
 
84
97
  ```commandline
@@ -130,6 +143,11 @@ claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=example.com
130
143
  claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=192.168.1.100 --user=admin --password=your_password --timeout=120000 --maxChars=none
131
144
  ```
132
145
 
146
+ **With Sudo and Su Support:**
147
+ ```bash
148
+ claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=192.168.1.100 --user=admin --password=your_password --sudoPassword=sudo_pass --suPassword=root_pass
149
+ ```
150
+
133
151
  **Installation Scopes:**
134
152
 
135
153
  You can specify the scope when adding the server:
package/build/index.js CHANGED
@@ -1,27 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
4
- import { Client as SSHClient } from 'ssh2';
4
+ import { Client } from 'ssh2';
5
5
  import { z } from 'zod';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- // Example usage: node build/index.js --host=1.2.3.4 --port=22 --user=root --password=pass --key=path/to/key --timeout=5000
7
+ // Example usage: node build/index.js --host=1.2.3.4 --port=22 --user=root --password=pass --key=path/to/key --timeout=5000 --disableSudo
8
8
  function parseArgv() {
9
9
  const args = process.argv.slice(2);
10
10
  const config = {};
11
11
  for (const arg of args) {
12
- const match = arg.match(/^--([^=]+)=(.*)$/);
13
- if (match) {
14
- config[match[1]] = match[2];
12
+ if (arg.startsWith('--')) {
13
+ const equalIndex = arg.indexOf('=');
14
+ if (equalIndex === -1) {
15
+ // Flag without value
16
+ config[arg.slice(2)] = null;
17
+ }
18
+ else {
19
+ // Key=value pair
20
+ config[arg.slice(2, equalIndex)] = arg.slice(equalIndex + 1);
21
+ }
15
22
  }
16
23
  }
17
24
  return config;
18
25
  }
26
+ const isTestMode = process.env.SSH_MCP_TEST === '1';
19
27
  const isCliEnabled = process.env.SSH_MCP_DISABLE_MAIN !== '1';
20
- const argvConfig = isCliEnabled ? parseArgv() : {};
28
+ const argvConfig = (isCliEnabled || isTestMode) ? parseArgv() : {};
21
29
  const HOST = argvConfig.host;
22
30
  const PORT = argvConfig.port ? parseInt(argvConfig.port) : 22;
23
31
  const USER = argvConfig.user;
24
32
  const PASSWORD = argvConfig.password;
33
+ const SUPASSWORD = argvConfig.suPassword;
34
+ const SUDOPASSWORD = argvConfig.sudoPassword;
35
+ const DISABLE_SUDO = argvConfig.disableSudo !== undefined;
25
36
  const KEY = argvConfig.key;
26
37
  const DEFAULT_TIMEOUT = argvConfig.timeout ? parseInt(argvConfig.timeout) : 60000; // 60 seconds default timeout
27
38
  // Max characters configuration:
@@ -75,17 +86,27 @@ export function sanitizeCommand(command) {
75
86
  }
76
87
  return trimmedCommand;
77
88
  }
89
+ function sanitizePassword(password) {
90
+ if (typeof password !== 'string')
91
+ return undefined;
92
+ // minimal check, do not log or modify content
93
+ if (password.length === 0)
94
+ return undefined;
95
+ return password;
96
+ }
78
97
  // Escape command for use in shell contexts (like pkill)
79
98
  export function escapeCommandForShell(command) {
80
99
  // Replace single quotes with escaped single quotes
81
100
  return command.replace(/'/g, "'\"'\"'");
82
101
  }
83
- // SSH Connection Manager to maintain persistent connection
84
102
  export class SSHConnectionManager {
85
103
  conn = null;
86
- sshConfig = null;
104
+ sshConfig;
87
105
  isConnecting = false;
88
106
  connectionPromise = null;
107
+ suShell = null; // Store the elevated shell session
108
+ suPromise = null;
109
+ isElevated = false; // Track if we're in su mode
89
110
  constructor(config) {
90
111
  this.sshConfig = config;
91
112
  }
@@ -98,7 +119,7 @@ export class SSHConnectionManager {
98
119
  }
99
120
  this.isConnecting = true;
100
121
  this.connectionPromise = new Promise((resolve, reject) => {
101
- this.conn = new SSHClient();
122
+ this.conn = new Client();
102
123
  const timeoutId = setTimeout(() => {
103
124
  this.conn?.end();
104
125
  this.conn = null;
@@ -106,10 +127,22 @@ export class SSHConnectionManager {
106
127
  this.connectionPromise = null;
107
128
  reject(new McpError(ErrorCode.InternalError, 'SSH connection timeout'));
108
129
  }, 30000); // 30 seconds connection timeout
109
- this.conn.on('ready', () => {
130
+ this.conn.on('ready', async () => {
110
131
  clearTimeout(timeoutId);
111
132
  this.isConnecting = false;
112
- console.error('SSH connection established');
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
+ }
113
146
  resolve();
114
147
  });
115
148
  this.conn.on('error', (err) => {
@@ -138,6 +171,108 @@ export class SSHConnectionManager {
138
171
  isConnected() {
139
172
  return this.conn !== null && this.conn._sock && !this.conn._sock.destroyed;
140
173
  }
174
+ getSudoPassword() {
175
+ return this.sshConfig.sudoPassword;
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
+ }
202
+ async ensureElevated() {
203
+ if (this.isElevated && this.suShell)
204
+ return;
205
+ if (!this.sshConfig.suPassword)
206
+ return;
207
+ if (this.suPromise)
208
+ return this.suPromise;
209
+ this.suPromise = new Promise((resolve, reject) => {
210
+ const conn = this.getConnection();
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
216
+ conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => {
217
+ if (err) {
218
+ clearTimeout(timeoutId);
219
+ this.suPromise = null;
220
+ reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su: ${err.message}`));
221
+ return;
222
+ }
223
+ let buffer = '';
224
+ let passwordSent = false;
225
+ const cleanup = () => {
226
+ try {
227
+ stream.removeAllListeners('data');
228
+ }
229
+ catch (e) { /* ignore */ }
230
+ };
231
+ const onData = (data) => {
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;
237
+ stream.write(this.sshConfig.suPassword + '\n');
238
+ // Don't return; keep looking for root prompt
239
+ }
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
+ }
252
+ }
253
+ // Detect authentication failure messages
254
+ if (/authentication failure|incorrect password|su: .*failed|su: failure/i.test(buffer)) {
255
+ clearTimeout(timeoutId);
256
+ cleanup();
257
+ this.suPromise = null;
258
+ reject(new McpError(ErrorCode.InternalError, `su authentication failed: ${buffer}`));
259
+ return;
260
+ }
261
+ };
262
+ stream.on('data', onData);
263
+ stream.on('close', () => {
264
+ clearTimeout(timeoutId);
265
+ if (!this.isElevated) {
266
+ this.suPromise = null;
267
+ reject(new McpError(ErrorCode.InternalError, 'su shell closed before elevation completed'));
268
+ }
269
+ });
270
+ // Kick off the su command
271
+ stream.write('su -\n');
272
+ });
273
+ });
274
+ return this.suPromise;
275
+ }
141
276
  async ensureConnected() {
142
277
  if (!this.isConnected()) {
143
278
  await this.connect();
@@ -151,6 +286,14 @@ export class SSHConnectionManager {
151
286
  }
152
287
  close() {
153
288
  if (this.conn) {
289
+ if (this.suShell) {
290
+ try {
291
+ this.suShell.end();
292
+ }
293
+ catch (e) { /* ignore */ }
294
+ this.suShell = null;
295
+ this.isElevated = false;
296
+ }
154
297
  this.conn.end();
155
298
  this.conn = null;
156
299
  }
@@ -159,7 +302,7 @@ export class SSHConnectionManager {
159
302
  let connectionManager = null;
160
303
  const server = new McpServer({
161
304
  name: 'SSH MCP Server',
162
- version: '1.2.2',
305
+ version: '1.4.0',
163
306
  capabilities: {
164
307
  resources: {},
165
308
  tools: {},
@@ -173,6 +316,9 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
173
316
  try {
174
317
  // Initialize connection manager if not already done
175
318
  if (!connectionManager) {
319
+ if (!HOST || !USER) {
320
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
321
+ }
176
322
  const sshConfig = {
177
323
  host: HOST,
178
324
  port: PORT,
@@ -185,10 +331,29 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
185
331
  const fs = await import('fs/promises');
186
332
  sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
187
333
  }
334
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
335
+ sshConfig.suPassword = sanitizePassword(SUPASSWORD);
336
+ }
188
337
  connectionManager = new SSHConnectionManager(sshConfig);
189
338
  }
190
339
  // Ensure connection is active (reconnect if needed)
191
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
+ }
192
357
  const result = await execSshCommandWithConnection(connectionManager, sanitizedCommand);
193
358
  return result;
194
359
  }
@@ -199,33 +364,116 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
199
364
  throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
200
365
  }
201
366
  });
367
+ // Expose sudo-exec tool unless explicitly disabled
368
+ if (!DISABLE_SUDO) {
369
+ 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.", {
370
+ command: z.string().describe("Shell command to execute with sudo on the remote SSH server"),
371
+ }, async ({ command }) => {
372
+ const sanitizedCommand = sanitizeCommand(command);
373
+ try {
374
+ if (!connectionManager) {
375
+ if (!HOST || !USER) {
376
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
377
+ }
378
+ const sshConfig = {
379
+ host: HOST,
380
+ port: PORT || 22,
381
+ username: USER,
382
+ };
383
+ if (PASSWORD) {
384
+ sshConfig.password = PASSWORD;
385
+ }
386
+ else if (KEY) {
387
+ const fs = await import('fs/promises');
388
+ sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
389
+ }
390
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
391
+ sshConfig.suPassword = sanitizePassword(SUPASSWORD);
392
+ }
393
+ if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
394
+ sshConfig.sudoPassword = sanitizePassword(SUDOPASSWORD);
395
+ }
396
+ connectionManager = new SSHConnectionManager(sshConfig);
397
+ }
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
+ }
410
+ let wrapped;
411
+ const sudoPassword = connectionManager.getSudoPassword();
412
+ if (!sudoPassword) {
413
+ // No password provided, use -n to fail if sudo requires a password
414
+ wrapped = `sudo -n sh -c '${sanitizedCommand.replace(/'/g, "'\\''")}'`;
415
+ }
416
+ else {
417
+ // Password provided — pipe it into sudo using printf. This avoids complex
418
+ // PTY/stdin handling on the SSH channel and is simpler and more reliable.
419
+ const pwdEscaped = sudoPassword.replace(/'/g, "'\\''");
420
+ wrapped = `printf '%s\\n' '${pwdEscaped}' | sudo -p "" -S sh -c '${sanitizedCommand.replace(/'/g, "'\\''")}'`;
421
+ }
422
+ return await execSshCommandWithConnection(connectionManager, wrapped);
423
+ }
424
+ catch (err) {
425
+ if (err instanceof McpError)
426
+ throw err;
427
+ throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
428
+ }
429
+ });
430
+ }
202
431
  // New function that uses persistent connection
203
- export async function execSshCommandWithConnection(manager, command) {
432
+ export async function execSshCommandWithConnection(manager, command, stdin) {
204
433
  return new Promise((resolve, reject) => {
205
434
  let timeoutId;
206
435
  let isResolved = false;
207
436
  const conn = manager.getConnection();
437
+ const shell = manager.suShell; // Use su shell if available
208
438
  // Set up timeout
209
439
  timeoutId = setTimeout(() => {
210
440
  if (!isResolved) {
211
441
  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
442
  reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
227
443
  }
228
444
  }, DEFAULT_TIMEOUT);
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);
469
+ }
470
+ };
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
229
477
  conn.exec(command, (err, stream) => {
230
478
  if (err) {
231
479
  if (!isResolved) {
@@ -237,6 +485,25 @@ export async function execSshCommandWithConnection(manager, command) {
237
485
  }
238
486
  let stdout = '';
239
487
  let stderr = '';
488
+ // If stdin provided (e.g., sudo password), write it
489
+ if (stdin && stdin.length > 0) {
490
+ try {
491
+ stream.write(stdin);
492
+ }
493
+ catch (e) {
494
+ console.error('Error writing to stdin:', e);
495
+ }
496
+ }
497
+ try {
498
+ stream.end();
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
+ });
240
507
  stream.on('close', (code, signal) => {
241
508
  if (!isResolved) {
242
509
  isResolved = true;
@@ -254,19 +521,13 @@ export async function execSshCommandWithConnection(manager, command) {
254
521
  }
255
522
  }
256
523
  });
257
- stream.on('data', (data) => {
258
- stdout += data.toString();
259
- });
260
- stream.stderr.on('data', (data) => {
261
- stderr += data.toString();
262
- });
263
524
  });
264
525
  });
265
526
  }
266
527
  // Keep the old function for backward compatibility (used in tests)
267
- export async function execSshCommand(sshConfig, command) {
528
+ export async function execSshCommand(sshConfig, command, stdin) {
268
529
  return new Promise((resolve, reject) => {
269
- const conn = new SSHClient();
530
+ const conn = new Client();
270
531
  let timeoutId;
271
532
  let isResolved = false;
272
533
  // Set up timeout
@@ -304,6 +565,19 @@ export async function execSshCommand(sshConfig, command) {
304
565
  conn.end();
305
566
  return;
306
567
  }
568
+ // If stdin provided, write it to the stream and end stdin
569
+ if (stdin && stdin.length > 0) {
570
+ try {
571
+ stream.write(stdin);
572
+ }
573
+ catch (e) {
574
+ // ignore
575
+ }
576
+ }
577
+ try {
578
+ stream.end();
579
+ }
580
+ catch (e) { /* ignore */ }
307
581
  let stdout = '';
308
582
  let stderr = '';
309
583
  stream.on('close', (code, signal) => {
@@ -363,7 +637,16 @@ async function main() {
363
637
  }
364
638
  });
365
639
  }
366
- if (process.env.SSH_MCP_DISABLE_MAIN !== '1') {
640
+ // Initialize server in test mode for automated tests
641
+ if (isTestMode) {
642
+ const transport = new StdioServerTransport();
643
+ server.connect(transport).catch(error => {
644
+ console.error("Fatal error connecting server:", error);
645
+ process.exit(1);
646
+ });
647
+ }
648
+ // Start server in CLI mode
649
+ else if (isCliEnabled) {
367
650
  main().catch((error) => {
368
651
  console.error("Fatal error in main():", error);
369
652
  if (connectionManager) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ssh-mcp",
3
3
  "license": "MIT",
4
- "version": "1.2.2",
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
+ }