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.
- package/README.md +18 -0
- package/build/index.js +319 -36
- 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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
+
// 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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|