ssh-mcp 1.2.2 → 1.3.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 +247 -22
  3. package/package.json +1 -1
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
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
+ });
145
+ }
113
146
  resolve();
114
147
  });
115
148
  this.conn.on('error', (err) => {
@@ -138,6 +171,73 @@ 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
+ async ensureElevated() {
178
+ if (this.isElevated && this.suShell)
179
+ return;
180
+ if (!this.sshConfig.suPassword)
181
+ return;
182
+ if (this.suPromise)
183
+ return this.suPromise;
184
+ this.suPromise = new Promise((resolve, reject) => {
185
+ 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.
188
+ conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => {
189
+ if (err) {
190
+ this.suPromise = null;
191
+ reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su: ${err.message}`));
192
+ return;
193
+ }
194
+ let buffer = '';
195
+ const cleanup = () => {
196
+ try {
197
+ stream.removeAllListeners('data');
198
+ }
199
+ catch (e) { /* ignore */ }
200
+ };
201
+ const onData = (data) => {
202
+ buffer += data.toString();
203
+ // Detect password prompt from su
204
+ if (/password[: ]*$/i.test(buffer)) {
205
+ // Send the su password
206
+ stream.write(this.sshConfig.suPassword + '\n');
207
+ buffer = '';
208
+ return;
209
+ }
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;
219
+ }
220
+ // Detect authentication failure
221
+ if (/authentication failure|incorrect password|su: .*failed/i.test(buffer)) {
222
+ cleanup();
223
+ this.suPromise = null;
224
+ reject(new McpError(ErrorCode.InternalError, `su authentication failed: ${buffer}`));
225
+ return;
226
+ }
227
+ };
228
+ stream.on('data', onData);
229
+ stream.on('close', () => {
230
+ if (!this.isElevated) {
231
+ this.suPromise = null;
232
+ reject(new McpError(ErrorCode.InternalError, 'su shell closed before elevation completed'));
233
+ }
234
+ });
235
+ // Kick off the su command
236
+ stream.write('su -\n');
237
+ });
238
+ });
239
+ return this.suPromise;
240
+ }
141
241
  async ensureConnected() {
142
242
  if (!this.isConnected()) {
143
243
  await this.connect();
@@ -151,6 +251,14 @@ export class SSHConnectionManager {
151
251
  }
152
252
  close() {
153
253
  if (this.conn) {
254
+ if (this.suShell) {
255
+ try {
256
+ this.suShell.end();
257
+ }
258
+ catch (e) { /* ignore */ }
259
+ this.suShell = null;
260
+ this.isElevated = false;
261
+ }
154
262
  this.conn.end();
155
263
  this.conn = null;
156
264
  }
@@ -159,7 +267,7 @@ export class SSHConnectionManager {
159
267
  let connectionManager = null;
160
268
  const server = new McpServer({
161
269
  name: 'SSH MCP Server',
162
- version: '1.2.2',
270
+ version: '1.3.0',
163
271
  capabilities: {
164
272
  resources: {},
165
273
  tools: {},
@@ -173,6 +281,9 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
173
281
  try {
174
282
  // Initialize connection manager if not already done
175
283
  if (!connectionManager) {
284
+ if (!HOST || !USER) {
285
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
286
+ }
176
287
  const sshConfig = {
177
288
  host: HOST,
178
289
  port: PORT,
@@ -185,6 +296,9 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
185
296
  const fs = await import('fs/promises');
186
297
  sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
187
298
  }
299
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
300
+ sshConfig.suPassword = sanitizePassword(SUPASSWORD);
301
+ }
188
302
  connectionManager = new SSHConnectionManager(sshConfig);
189
303
  }
190
304
  // Ensure connection is active (reconnect if needed)
@@ -199,12 +313,66 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
199
313
  throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
200
314
  }
201
315
  });
316
+ // Expose sudo-exec tool unless explicitly disabled
317
+ if (!DISABLE_SUDO) {
318
+ 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
+ command: z.string().describe("Shell command to execute with sudo on the remote SSH server"),
320
+ }, async ({ command }) => {
321
+ const sanitizedCommand = sanitizeCommand(command);
322
+ try {
323
+ if (!connectionManager) {
324
+ if (!HOST || !USER) {
325
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
326
+ }
327
+ const sshConfig = {
328
+ host: HOST,
329
+ port: PORT || 22,
330
+ username: USER,
331
+ };
332
+ if (PASSWORD) {
333
+ sshConfig.password = PASSWORD;
334
+ }
335
+ else if (KEY) {
336
+ const fs = await import('fs/promises');
337
+ sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
338
+ }
339
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
340
+ sshConfig.suPassword = sanitizePassword(SUPASSWORD);
341
+ }
342
+ if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
343
+ sshConfig.sudoPassword = sanitizePassword(SUDOPASSWORD);
344
+ }
345
+ connectionManager = new SSHConnectionManager(sshConfig);
346
+ }
347
+ await connectionManager.ensureConnected();
348
+ let wrapped;
349
+ const sudoPassword = connectionManager.getSudoPassword();
350
+ if (!sudoPassword) {
351
+ // No password provided, use -n to fail if sudo requires a password
352
+ wrapped = `sudo -n sh -c '${sanitizedCommand.replace(/'/g, "'\\''")}'`;
353
+ }
354
+ else {
355
+ // Password provided — pipe it into sudo using printf. This avoids complex
356
+ // PTY/stdin handling on the SSH channel and is simpler and more reliable.
357
+ const pwdEscaped = sudoPassword.replace(/'/g, "'\\''");
358
+ wrapped = `printf '%s\\n' '${pwdEscaped}' | sudo -p "" -S sh -c '${sanitizedCommand.replace(/'/g, "'\\''")}'`;
359
+ }
360
+ return await execSshCommandWithConnection(connectionManager, wrapped);
361
+ }
362
+ catch (err) {
363
+ if (err instanceof McpError)
364
+ throw err;
365
+ throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
366
+ }
367
+ });
368
+ }
202
369
  // New function that uses persistent connection
203
- export async function execSshCommandWithConnection(manager, command) {
370
+ export async function execSshCommandWithConnection(manager, command, stdin) {
204
371
  return new Promise((resolve, reject) => {
205
372
  let timeoutId;
206
373
  let isResolved = false;
207
374
  const conn = manager.getConnection();
375
+ const shell = manager.suShell; // Use su shell if available
208
376
  // Set up timeout
209
377
  timeoutId = setTimeout(() => {
210
378
  if (!isResolved) {
@@ -226,7 +394,23 @@ export async function execSshCommandWithConnection(manager, command) {
226
394
  reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
227
395
  }
228
396
  }, DEFAULT_TIMEOUT);
229
- conn.exec(command, (err, stream) => {
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);
411
+ }
412
+ };
413
+ execFn((err, stream) => {
230
414
  if (err) {
231
415
  if (!isResolved) {
232
416
  isResolved = true;
@@ -237,6 +421,31 @@ export async function execSshCommandWithConnection(manager, command) {
237
421
  }
238
422
  let stdout = '';
239
423
  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) {
433
+ try {
434
+ // Write to the exec channel (send to remote stdin)
435
+ stream.write(stdin);
436
+ stream.end(); // End stdin after writing password
437
+ }
438
+ catch (e) {
439
+ console.error('Error writing to stdin:', e);
440
+ }
441
+ }
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 */ }
448
+ }
240
449
  stream.on('close', (code, signal) => {
241
450
  if (!isResolved) {
242
451
  isResolved = true;
@@ -254,19 +463,13 @@ export async function execSshCommandWithConnection(manager, command) {
254
463
  }
255
464
  }
256
465
  });
257
- stream.on('data', (data) => {
258
- stdout += data.toString();
259
- });
260
- stream.stderr.on('data', (data) => {
261
- stderr += data.toString();
262
- });
263
466
  });
264
467
  });
265
468
  }
266
469
  // Keep the old function for backward compatibility (used in tests)
267
- export async function execSshCommand(sshConfig, command) {
470
+ export async function execSshCommand(sshConfig, command, stdin) {
268
471
  return new Promise((resolve, reject) => {
269
- const conn = new SSHClient();
472
+ const conn = new Client();
270
473
  let timeoutId;
271
474
  let isResolved = false;
272
475
  // Set up timeout
@@ -304,6 +507,19 @@ export async function execSshCommand(sshConfig, command) {
304
507
  conn.end();
305
508
  return;
306
509
  }
510
+ // If stdin provided, write it to the stream and end stdin
511
+ if (stdin && stdin.length > 0) {
512
+ try {
513
+ stream.write(stdin);
514
+ }
515
+ catch (e) {
516
+ // ignore
517
+ }
518
+ }
519
+ try {
520
+ stream.end();
521
+ }
522
+ catch (e) { /* ignore */ }
307
523
  let stdout = '';
308
524
  let stderr = '';
309
525
  stream.on('close', (code, signal) => {
@@ -363,7 +579,16 @@ async function main() {
363
579
  }
364
580
  });
365
581
  }
366
- if (process.env.SSH_MCP_DISABLE_MAIN !== '1') {
582
+ // Initialize server in test mode for automated tests
583
+ if (isTestMode) {
584
+ const transport = new StdioServerTransport();
585
+ server.connect(transport).catch(error => {
586
+ console.error("Fatal error connecting server:", error);
587
+ process.exit(1);
588
+ });
589
+ }
590
+ // Start server in CLI mode
591
+ else if (isCliEnabled) {
367
592
  main().catch((error) => {
368
593
  console.error("Fatal error in main():", error);
369
594
  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.3.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": {