ssh-mcp2 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 +204 -0
  2. package/build/index.js +812 -0
  3. package/package.json +61 -0
package/README.md ADDED
@@ -0,0 +1,204 @@
1
+ # SSH MCP Server
2
+
3
+ [![NPM Version](https://img.shields.io/npm/v/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
4
+ [![Downloads](https://img.shields.io/npm/dm/ssh-mcp)](https://www.npmjs.com/package/ssh-mcp)
5
+ [![Node Version](https://img.shields.io/node/v/ssh-mcp)](https://nodejs.org/)
6
+ [![License](https://img.shields.io/github/license/tufantunc/ssh-mcp)](./LICENSE)
7
+ [![GitHub Stars](https://img.shields.io/github/stars/tufantunc/ssh-mcp?style=social)](https://github.com/tufantunc/ssh-mcp/stargazers)
8
+ [![GitHub Forks](https://img.shields.io/github/forks/tufantunc/ssh-mcp?style=social)](https://github.com/tufantunc/ssh-mcp/forks)
9
+ [![Build Status](https://github.com/tufantunc/ssh-mcp/actions/workflows/publish.yml/badge.svg)](https://github.com/tufantunc/ssh-mcp/actions)
10
+ [![GitHub issues](https://img.shields.io/github/issues/tufantunc/ssh-mcp)](https://github.com/tufantunc/ssh-mcp/issues)
11
+
12
+ [![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/tufantunc/ssh-mcp)](https://archestra.ai/mcp-catalog/tufantunc__ssh-mcp)
13
+
14
+ **SSH MCP Server** is a local Model Context Protocol (MCP) server that exposes SSH control for Linux and Windows systems, enabling LLMs and other MCP clients to execute shell commands securely via SSH.
15
+
16
+ ## Contents
17
+
18
+ - [Quick Start](#quick-start)
19
+ - [Features](#features)
20
+ - [Installation](#installation)
21
+ - [Client Setup](#client-setup)
22
+ - [Testing](#testing)
23
+ - [Disclaimer](#disclaimer)
24
+ - [Support](#support)
25
+
26
+ ## Quick Start
27
+
28
+ - [Install](#installation) SSH MCP Server
29
+ - [Configure](#configuration) SSH MCP Server
30
+ - [Set up](#client-setup) your MCP Client (e.g. Claude Desktop, Cursor, etc)
31
+ - Execute remote shell commands on your Linux or Windows server via natural language
32
+
33
+ ## Features
34
+
35
+ - MCP-compliant server exposing SSH capabilities
36
+ - Execute shell commands on remote Linux and Windows systems
37
+ - Secure authentication via password or SSH key
38
+ - Built with TypeScript and the official MCP SDK
39
+ - **Configurable timeout protection** with automatic process abortion
40
+ - **Graceful timeout handling** - attempts to kill hanging processes before closing connections
41
+
42
+ ### Tools
43
+
44
+ - `exec`: Execute a shell command on the remote server
45
+ - **Parameters:**
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)
48
+ - **Timeout Configuration:**
49
+
50
+ - `sudo-exec`: Execute a shell command with sudo elevation
51
+ - **Parameters:**
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)
54
+ - **Notes:**
55
+ - Requires `--sudoPassword` to be set for password-protected sudo
56
+ - Can be disabled by passing the `--disableSudo` flag at startup if sudo access is not needed or not available
57
+ - For persistent root access, consider using `--suPassword` instead which establishes a root shell
58
+ - Tool will not be available at all if server is started with `--disableSudo`
59
+ - **Timeout Configuration:**
60
+ - Timeout is configured via command line argument `--timeout` (in milliseconds)
61
+ - Default timeout: 60000ms (1 minute)
62
+ - When a command times out, the server automatically attempts to abort the running process before closing the connection
63
+ - **Max Command Length Configuration:**
64
+ - Max command characters are configured via `--maxChars`
65
+ - Default: `1000`
66
+ - No-limit mode: set `--maxChars=none` or any `<= 0` value (e.g. `--maxChars=0`)
67
+
68
+ ## Installation
69
+
70
+ 1. **Clone the repository:**
71
+ ```bash
72
+ git clone https://github.com/tufantunc/ssh-mcp.git
73
+ cd ssh-mcp
74
+ ```
75
+ 2. **Install dependencies:**
76
+ ```bash
77
+ npm install
78
+ ```
79
+
80
+ ## Client Setup
81
+
82
+ You can configure your IDE or LLM like Cursor, Windsurf, Claude Desktop to use this MCP Server.
83
+
84
+ **Required Parameters:**
85
+ - `host`: Hostname or IP of the Linux or Windows server
86
+ - `user`: SSH username
87
+
88
+ **Optional Parameters:**
89
+ - `port`: SSH port (default: 22)
90
+ - `password`: SSH password (or use `key` for key-based auth)
91
+ - `key`: Path to private SSH key
92
+ - `sudoPassword`: Password for sudo elevation (when executing commands with sudo)
93
+ - `suPassword`: Password for su elevation (when you need a persistent root shell)
94
+ - `timeout`: Command execution timeout in milliseconds (default: 60000ms = 1 minute)
95
+ - `maxChars`: Maximum allowed characters for the `command` input (default: 1000). Use `none` or `0` to disable the limit.
96
+ - `disableSudo`: Flag to disable the `sudo-exec` tool completely. Useful when sudo access is not needed or not available.
97
+
98
+
99
+ ```commandline
100
+ {
101
+ "mcpServers": {
102
+ "ssh-mcp": {
103
+ "command": "npx",
104
+ "args": [
105
+ "ssh-mcp",
106
+ "-y",
107
+ "--",
108
+ "--host=1.2.3.4",
109
+ "--port=22",
110
+ "--user=root",
111
+ "--password=pass",
112
+ "--key=path/to/key",
113
+ "--timeout=30000",
114
+ "--maxChars=none"
115
+ ]
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ ### Claude Code
122
+
123
+ You can add this MCP server to Claude Code using the `claude mcp add` command. This is the recommended method for Claude Code.
124
+
125
+ **Basic Installation:**
126
+
127
+ ```bash
128
+ claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
129
+ ```
130
+
131
+ **Installation Examples:**
132
+
133
+ **With Password Authentication:**
134
+ ```bash
135
+ claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=192.168.1.100 --port=22 --user=admin --password=your_password
136
+ ```
137
+
138
+ **With SSH Key Authentication:**
139
+ ```bash
140
+ claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=example.com --user=root --key=/path/to/private/key
141
+ ```
142
+
143
+ **With Custom Timeout and No Character Limit:**
144
+ ```bash
145
+ 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
146
+ ```
147
+
148
+ **With Sudo and Su Support:**
149
+ ```bash
150
+ 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
151
+ ```
152
+
153
+ **Installation Scopes:**
154
+
155
+ You can specify the scope when adding the server:
156
+
157
+ - **Local scope** (default): For personal use in the current project
158
+ ```bash
159
+ claude mcp add --transport stdio ssh-mcp --scope local -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
160
+ ```
161
+
162
+ - **Project scope**: Share with your team via `.mcp.json` file
163
+ ```bash
164
+ claude mcp add --transport stdio ssh-mcp --scope project -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
165
+ ```
166
+
167
+ - **User scope**: Available across all your projects
168
+ ```bash
169
+ claude mcp add --transport stdio ssh-mcp --scope user -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
170
+ ```
171
+
172
+
173
+ **Verify Installation:**
174
+
175
+ After adding the server, restart Claude Code and ask Cascade to execute a command:
176
+ ```
177
+ "Can you run 'ls -la' on the remote server?"
178
+ ```
179
+
180
+ For more information about MCP in Claude Code, see the [official documentation](https://docs.claude.com/en/docs/claude-code/mcp).
181
+
182
+ ## Testing
183
+
184
+ You can use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for visual debugging of this MCP Server.
185
+
186
+ ```sh
187
+ npm run inspect
188
+ ```
189
+
190
+ ## Disclaimer
191
+
192
+ SSH MCP Server is provided under the [MIT License](./LICENSE). Use at your own risk. This project is not affiliated with or endorsed by any SSH or MCP provider.
193
+
194
+ ## Contributing
195
+
196
+ We welcome contributions! Please see our [Contributing Guidelines](./CONTRIBUTING.md) for more information.
197
+
198
+ ## Code of Conduct
199
+
200
+ This project follows a [Code of Conduct](./CODE_OF_CONDUCT.md) to ensure a welcoming environment for everyone.
201
+
202
+ ## Support
203
+
204
+ If you find SSH MCP Server helpful, consider starring the repository or contributing! Pull requests and feedback are welcome.
package/build/index.js ADDED
@@ -0,0 +1,812 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
4
+ import { Client } from 'ssh2';
5
+ import { z } from 'zod';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { stat } from 'fs/promises';
8
+ import path from 'path';
9
+ // Example usage: node build/index.js --host=1.2.3.4 --port=22 --user=root --password=pass --key=path/to/key --timeout=5000 --disableSudo
10
+ function parseArgv() {
11
+ const args = process.argv.slice(2);
12
+ const config = {};
13
+ for (const arg of args) {
14
+ if (arg.startsWith('--')) {
15
+ const equalIndex = arg.indexOf('=');
16
+ if (equalIndex === -1) {
17
+ // Flag without value
18
+ config[arg.slice(2)] = null;
19
+ }
20
+ else {
21
+ // Key=value pair
22
+ config[arg.slice(2, equalIndex)] = arg.slice(equalIndex + 1);
23
+ }
24
+ }
25
+ }
26
+ return config;
27
+ }
28
+ const isTestMode = process.env.SSH_MCP_TEST === '1';
29
+ const isCliEnabled = process.env.SSH_MCP_DISABLE_MAIN !== '1';
30
+ const argvConfig = (isCliEnabled || isTestMode) ? parseArgv() : {};
31
+ const HOST = argvConfig.host;
32
+ const PORT = argvConfig.port ? parseInt(argvConfig.port) : 22;
33
+ const USER = argvConfig.user;
34
+ const PASSWORD = argvConfig.password;
35
+ const SUPASSWORD = argvConfig.suPassword;
36
+ const SUDOPASSWORD = argvConfig.sudoPassword;
37
+ const DISABLE_SUDO = argvConfig.disableSudo !== undefined;
38
+ const KEY = argvConfig.key;
39
+ const DEFAULT_TIMEOUT = argvConfig.timeout ? parseInt(argvConfig.timeout) : 60000; // 60 seconds default timeout
40
+ // Max characters configuration:
41
+ // - Default: 1000 characters
42
+ // - When set via --maxChars:
43
+ // * a positive integer enforces that limit
44
+ // * 0 or a negative value disables the limit (no max)
45
+ // * the string "none" (case-insensitive) disables the limit (no max)
46
+ const MAX_CHARS_RAW = argvConfig.maxChars;
47
+ const MAX_CHARS = (() => {
48
+ if (typeof MAX_CHARS_RAW === 'string') {
49
+ const lowered = MAX_CHARS_RAW.toLowerCase();
50
+ if (lowered === 'none')
51
+ return Infinity;
52
+ const parsed = parseInt(MAX_CHARS_RAW);
53
+ if (isNaN(parsed))
54
+ return 1000;
55
+ if (parsed <= 0)
56
+ return Infinity;
57
+ return parsed;
58
+ }
59
+ return 1000;
60
+ })();
61
+ function validateConfig(config) {
62
+ const errors = [];
63
+ if (!config.host)
64
+ errors.push('Missing required --host');
65
+ if (!config.user)
66
+ errors.push('Missing required --user');
67
+ if (config.port && isNaN(Number(config.port)))
68
+ errors.push('Invalid --port');
69
+ if (errors.length > 0) {
70
+ throw new Error('Configuration error:\n' + errors.join('\n'));
71
+ }
72
+ }
73
+ if (isCliEnabled) {
74
+ validateConfig(argvConfig);
75
+ }
76
+ // Command sanitization and validation
77
+ export function sanitizeCommand(command) {
78
+ if (typeof command !== 'string') {
79
+ throw new McpError(ErrorCode.InvalidParams, 'Command must be a string');
80
+ }
81
+ const trimmedCommand = command.trim();
82
+ if (!trimmedCommand) {
83
+ throw new McpError(ErrorCode.InvalidParams, 'Command cannot be empty');
84
+ }
85
+ // Length check
86
+ if (Number.isFinite(MAX_CHARS) && trimmedCommand.length > MAX_CHARS) {
87
+ throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${MAX_CHARS} characters)`);
88
+ }
89
+ return trimmedCommand;
90
+ }
91
+ function sanitizePassword(password) {
92
+ if (typeof password !== 'string')
93
+ return undefined;
94
+ // minimal check, do not log or modify content
95
+ if (password.length === 0)
96
+ return undefined;
97
+ return password;
98
+ }
99
+ // Escape command for use in shell contexts (like pkill)
100
+ export function escapeCommandForShell(command) {
101
+ // Replace single quotes with escaped single quotes
102
+ return command.replace(/'/g, "'\"'\"'");
103
+ }
104
+ export class SSHConnectionManager {
105
+ conn = null;
106
+ sshConfig;
107
+ isConnecting = false;
108
+ connectionPromise = null;
109
+ suShell = null; // Store the elevated shell session
110
+ sftpSession = null;
111
+ suShellQueue = Promise.resolve();
112
+ suPromise = null;
113
+ isElevated = false; // Track if we're in su mode
114
+ constructor(config) {
115
+ this.sshConfig = config;
116
+ }
117
+ async connect() {
118
+ if (this.conn && this.isConnected()) {
119
+ return; // Already connected
120
+ }
121
+ if (this.isConnecting && this.connectionPromise) {
122
+ return this.connectionPromise; // Wait for ongoing connection
123
+ }
124
+ this.isConnecting = true;
125
+ this.connectionPromise = new Promise((resolve, reject) => {
126
+ this.conn = new Client();
127
+ const timeoutId = setTimeout(() => {
128
+ this.conn?.end();
129
+ this.conn = null;
130
+ this.isConnecting = false;
131
+ this.connectionPromise = null;
132
+ reject(new McpError(ErrorCode.InternalError, 'SSH connection timeout'));
133
+ }, 30000); // 30 seconds connection timeout
134
+ this.conn.on('ready', async () => {
135
+ clearTimeout(timeoutId);
136
+ this.isConnecting = false;
137
+ // In test mode, don't wait for su elevation during connection setup, as it
138
+ // may cause JSON-RPC server initialization to hang. Instead, elevation will
139
+ // be triggered on-demand when a command is executed.
140
+ // In production, elevation during connection is desirable for robustness.
141
+ if (this.sshConfig.suPassword && !process.env.SSH_MCP_TEST) {
142
+ try {
143
+ await this.ensureElevated();
144
+ }
145
+ catch (err) {
146
+ // Do not reject the connection; just log the error. Subsequent commands
147
+ // will either use the su shell if available or fall back to normal execution.
148
+ }
149
+ }
150
+ resolve();
151
+ });
152
+ this.conn.on('error', (err) => {
153
+ clearTimeout(timeoutId);
154
+ this.conn = null;
155
+ this.isConnecting = false;
156
+ this.connectionPromise = null;
157
+ reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
158
+ });
159
+ this.conn.on('end', () => {
160
+ console.error('SSH connection ended');
161
+ this.conn = null;
162
+ this.isConnecting = false;
163
+ this.connectionPromise = null;
164
+ });
165
+ this.conn.on('close', () => {
166
+ console.error('SSH connection closed');
167
+ this.conn = null;
168
+ this.isConnecting = false;
169
+ this.connectionPromise = null;
170
+ });
171
+ this.conn.connect(this.sshConfig);
172
+ });
173
+ return this.connectionPromise;
174
+ }
175
+ isConnected() {
176
+ return this.conn !== null && this.conn._sock && !this.conn._sock.destroyed;
177
+ }
178
+ getSudoPassword() {
179
+ return this.sshConfig.sudoPassword;
180
+ }
181
+ getSuPassword() {
182
+ return this.sshConfig.suPassword;
183
+ }
184
+ async setSuPassword(pwd) {
185
+ this.sshConfig.suPassword = pwd;
186
+ if (pwd) {
187
+ try {
188
+ await this.ensureElevated();
189
+ }
190
+ catch (err) {
191
+ console.error('setSuPassword: failed to elevate to su shell:', err);
192
+ }
193
+ }
194
+ else {
195
+ // If clearing suPassword, drop any existing suShell
196
+ if (this.suShell) {
197
+ try {
198
+ this.suShell.end();
199
+ }
200
+ catch (e) { /* ignore */ }
201
+ this.suShell = null;
202
+ this.isElevated = false;
203
+ }
204
+ }
205
+ }
206
+ async ensureElevated() {
207
+ if (this.isElevated && this.suShell)
208
+ return;
209
+ if (!this.sshConfig.suPassword)
210
+ return;
211
+ if (this.suPromise)
212
+ return this.suPromise;
213
+ this.suPromise = new Promise((resolve, reject) => {
214
+ const conn = this.getConnection();
215
+ // Add a safety timeout so elevation doesn't hang forever
216
+ const timeoutId = setTimeout(() => {
217
+ this.suPromise = null;
218
+ reject(new McpError(ErrorCode.InternalError, 'su elevation timed out'));
219
+ }, 10000); // 10 second timeout for elevation
220
+ conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => {
221
+ if (err) {
222
+ clearTimeout(timeoutId);
223
+ this.suPromise = null;
224
+ reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su: ${err.message}`));
225
+ return;
226
+ }
227
+ let buffer = '';
228
+ let passwordSent = false;
229
+ const cleanup = () => {
230
+ try {
231
+ stream.removeAllListeners('data');
232
+ }
233
+ catch (e) { /* ignore */ }
234
+ };
235
+ const onData = (data) => {
236
+ const text = data.toString();
237
+ buffer += text;
238
+ // If we haven't sent the password yet, look for the password prompt
239
+ if (!passwordSent && /password[: ]/i.test(buffer)) {
240
+ passwordSent = true;
241
+ stream.write(this.sshConfig.suPassword + '\n');
242
+ // Don't return; keep looking for root prompt
243
+ }
244
+ // After password is sent, look for any root indicator
245
+ // Look for '#' which indicates root prompt (may be followed by spaces, escape codes, etc)
246
+ if (passwordSent) {
247
+ const cleanBuffer = buffer.replace(/\r/g, '').replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
248
+ if (/]#\s*$/.test(cleanBuffer) || /\n#\s*$/.test(cleanBuffer) || /^#\s*$/.test(cleanBuffer)) {
249
+ clearTimeout(timeoutId);
250
+ cleanup();
251
+ this.suShell = stream;
252
+ this.isElevated = true;
253
+ this.suPromise = null;
254
+ resolve();
255
+ return;
256
+ }
257
+ }
258
+ // Detect authentication failure messages
259
+ if (/authentication failure|incorrect password|su: .*failed|su: failure/i.test(buffer)) {
260
+ clearTimeout(timeoutId);
261
+ cleanup();
262
+ this.suPromise = null;
263
+ reject(new McpError(ErrorCode.InternalError, `su authentication failed: ${buffer}`));
264
+ return;
265
+ }
266
+ };
267
+ stream.on('data', onData);
268
+ stream.on('close', () => {
269
+ clearTimeout(timeoutId);
270
+ this.suShell = null;
271
+ if (this.isElevated) {
272
+ this.isElevated = false;
273
+ }
274
+ else {
275
+ this.suPromise = null;
276
+ reject(new McpError(ErrorCode.InternalError, 'su shell closed before elevation completed'));
277
+ }
278
+ });
279
+ // Kick off the su command
280
+ stream.write('su -\n');
281
+ });
282
+ });
283
+ return this.suPromise;
284
+ }
285
+ async ensureConnected() {
286
+ if (!this.isConnected()) {
287
+ await this.connect();
288
+ }
289
+ }
290
+ getConnection() {
291
+ if (!this.conn) {
292
+ throw new McpError(ErrorCode.InternalError, 'SSH connection not established');
293
+ }
294
+ return this.conn;
295
+ }
296
+ async sftp() {
297
+ if (this.sftpSession) {
298
+ return this.sftpSession;
299
+ }
300
+ const conn = this.getConnection();
301
+ return new Promise((resolve, reject) => {
302
+ conn.sftp((err, sftp) => {
303
+ if (err)
304
+ reject(new McpError(ErrorCode.InternalError, `SFTP error: ${err.message}`));
305
+ else {
306
+ sftp.on('close', () => { this.sftpSession = null; });
307
+ this.sftpSession = sftp;
308
+ resolve(sftp);
309
+ }
310
+ });
311
+ });
312
+ }
313
+ close() {
314
+ if (this.sftpSession) {
315
+ try {
316
+ this.sftpSession.end();
317
+ }
318
+ catch (e) { /* ignore */ }
319
+ this.sftpSession = null;
320
+ }
321
+ if (this.conn) {
322
+ if (this.suShell) {
323
+ try {
324
+ this.suShell.end();
325
+ }
326
+ catch (e) { /* ignore */ }
327
+ this.suShell = null;
328
+ this.isElevated = false;
329
+ }
330
+ this.conn.end();
331
+ this.conn = null;
332
+ }
333
+ }
334
+ }
335
+ let connectionManager = null;
336
+ const server = new McpServer({
337
+ name: 'SSH MCP Server',
338
+ version: '1.5.0',
339
+ capabilities: {
340
+ resources: {},
341
+ tools: {},
342
+ },
343
+ });
344
+ server.tool("exec", "Execute a shell command on the remote SSH server and return the output.", {
345
+ command: z.string().describe("Shell command to execute on the remote SSH server"),
346
+ description: z.string().optional().describe("Optional description of what this command will do"),
347
+ }, async ({ command, description }) => {
348
+ // Sanitize command input
349
+ const sanitizedCommand = sanitizeCommand(command);
350
+ try {
351
+ // Initialize connection manager if not already done
352
+ if (!connectionManager) {
353
+ if (!HOST || !USER) {
354
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
355
+ }
356
+ const sshConfig = {
357
+ host: HOST,
358
+ port: PORT,
359
+ username: USER,
360
+ };
361
+ if (PASSWORD) {
362
+ sshConfig.password = PASSWORD;
363
+ }
364
+ else if (KEY) {
365
+ const fs = await import('fs/promises');
366
+ sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
367
+ }
368
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
369
+ sshConfig.suPassword = sanitizePassword(SUPASSWORD);
370
+ }
371
+ connectionManager = new SSHConnectionManager(sshConfig);
372
+ }
373
+ // Ensure connection is active (reconnect if needed)
374
+ await connectionManager.ensureConnected();
375
+ // If a suPassword was provided, explicitly wait for elevation before executing.
376
+ // This is critical: ensureElevated is idempotent and will return immediately if
377
+ // already elevated, so this ensures we have a su shell before we try to use it.
378
+ if (connectionManager.getSuPassword && connectionManager.getSuPassword()) {
379
+ try {
380
+ const elevationPromise = connectionManager.ensureElevated();
381
+ // Add a short timeout for elevation to complete
382
+ await Promise.race([
383
+ elevationPromise,
384
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Elevation timeout')), 5000))
385
+ ]);
386
+ }
387
+ catch (err) {
388
+ // Log but don't fail; fall back to non-elevated execution if elevation times out
389
+ }
390
+ }
391
+ // Append description as comment if provided
392
+ const commandWithDescription = description
393
+ ? `${sanitizedCommand} # ${description.replace(/#/g, '\\#')}`
394
+ : sanitizedCommand;
395
+ const result = await execSshCommandWithConnection(connectionManager, commandWithDescription);
396
+ return result;
397
+ }
398
+ catch (err) {
399
+ // Wrap unexpected errors
400
+ if (err instanceof McpError)
401
+ throw err;
402
+ throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
403
+ }
404
+ });
405
+ // Expose sudo-exec tool unless explicitly disabled
406
+ if (!DISABLE_SUDO) {
407
+ 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.", {
408
+ command: z.string().describe("Shell command to execute with sudo on the remote SSH server"),
409
+ description: z.string().optional().describe("Optional description of what this command will do"),
410
+ }, async ({ command, description }) => {
411
+ const sanitizedCommand = sanitizeCommand(command);
412
+ try {
413
+ if (!connectionManager) {
414
+ if (!HOST || !USER) {
415
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
416
+ }
417
+ const sshConfig = {
418
+ host: HOST,
419
+ port: PORT || 22,
420
+ username: USER,
421
+ };
422
+ if (PASSWORD) {
423
+ sshConfig.password = PASSWORD;
424
+ }
425
+ else if (KEY) {
426
+ const fs = await import('fs/promises');
427
+ sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
428
+ }
429
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
430
+ sshConfig.suPassword = sanitizePassword(SUPASSWORD);
431
+ }
432
+ if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
433
+ sshConfig.sudoPassword = sanitizePassword(SUDOPASSWORD);
434
+ }
435
+ connectionManager = new SSHConnectionManager(sshConfig);
436
+ }
437
+ await connectionManager.ensureConnected();
438
+ // If suPassword or sudoPassword were provided on this call but the
439
+ // existing connection manager was created earlier without them,
440
+ // update the manager's values so the subsequent sudo-exec call uses
441
+ // the latest passwords.
442
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
443
+ await connectionManager.setSuPassword(sanitizePassword(SUPASSWORD));
444
+ }
445
+ if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
446
+ // update sudoPassword on the manager instance
447
+ connectionManager.sshConfig = { ...connectionManager.sshConfig, sudoPassword: sanitizePassword(SUDOPASSWORD) };
448
+ }
449
+ let wrapped;
450
+ const sudoPassword = connectionManager.getSudoPassword();
451
+ // Append description as comment if provided
452
+ const commandWithDescription = description
453
+ ? `${sanitizedCommand} # ${description.replace(/#/g, '\\#')}`
454
+ : sanitizedCommand;
455
+ if (!sudoPassword) {
456
+ // No password provided, use -n to fail if sudo requires a password
457
+ wrapped = `sudo -n sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
458
+ }
459
+ else {
460
+ // Password provided — pipe it into sudo using printf. This avoids complex
461
+ // PTY/stdin handling on the SSH channel and is simpler and more reliable.
462
+ const pwdEscaped = sudoPassword.replace(/'/g, "'\\''");
463
+ wrapped = `printf '%s\\n' '${pwdEscaped}' | sudo -p "" -S sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
464
+ }
465
+ return await execSshCommandWithConnection(connectionManager, wrapped);
466
+ }
467
+ catch (err) {
468
+ if (err instanceof McpError)
469
+ throw err;
470
+ throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
471
+ }
472
+ });
473
+ }
474
+ server.tool("upload_file", "Upload a local file to the remote server via SFTP.", {
475
+ localPath: z.string().describe("Absolute path to the local file"),
476
+ remotePath: z.string().describe("Destination path on the remote server"),
477
+ permissions: z.string().optional().describe("File permissions in octal, e.g. '0755'"),
478
+ }, async ({ localPath, remotePath, permissions }) => {
479
+ try {
480
+ if (!path.isAbsolute(localPath)) {
481
+ throw new McpError(ErrorCode.InvalidParams, 'localPath must be an absolute path');
482
+ }
483
+ if (!path.isAbsolute(remotePath)) {
484
+ throw new McpError(ErrorCode.InvalidParams, 'remotePath must be an absolute path');
485
+ }
486
+ if (!connectionManager) {
487
+ if (!HOST || !USER) {
488
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
489
+ }
490
+ const sshConfig = { host: HOST, port: PORT, username: USER };
491
+ if (PASSWORD) {
492
+ sshConfig.password = PASSWORD;
493
+ }
494
+ else if (KEY) {
495
+ const fs = await import('fs/promises');
496
+ sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
497
+ }
498
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
499
+ sshConfig.suPassword = sanitizePassword(SUPASSWORD);
500
+ }
501
+ if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
502
+ sshConfig.sudoPassword = sanitizePassword(SUDOPASSWORD);
503
+ }
504
+ connectionManager = new SSHConnectionManager(sshConfig);
505
+ }
506
+ await connectionManager.ensureConnected();
507
+ const sftp = await connectionManager.sftp();
508
+ const fileStat = await stat(localPath);
509
+ await new Promise((resolve, reject) => {
510
+ sftp.fastPut(localPath, remotePath, {
511
+ mode: permissions ? parseInt(permissions, 8) : undefined,
512
+ }, (err) => {
513
+ if (err)
514
+ reject(new McpError(ErrorCode.InternalError, `Upload failed: ${err.message}`));
515
+ else
516
+ resolve();
517
+ });
518
+ });
519
+ return {
520
+ content: [{ type: "text", text: `Uploaded ${localPath} → ${remotePath} (${fileStat.size} bytes)` }],
521
+ };
522
+ }
523
+ catch (err) {
524
+ if (err instanceof McpError)
525
+ throw err;
526
+ throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
527
+ }
528
+ });
529
+ server.tool("download_file", "Download a file from the remote server to local via SFTP.", {
530
+ remotePath: z.string().describe("Path to the file on the remote server"),
531
+ localPath: z.string().describe("Absolute destination path on the local machine"),
532
+ }, async ({ remotePath, localPath }) => {
533
+ try {
534
+ if (!path.isAbsolute(remotePath)) {
535
+ throw new McpError(ErrorCode.InvalidParams, 'remotePath must be an absolute path');
536
+ }
537
+ if (!path.isAbsolute(localPath)) {
538
+ throw new McpError(ErrorCode.InvalidParams, 'localPath must be an absolute path');
539
+ }
540
+ if (!connectionManager) {
541
+ if (!HOST || !USER) {
542
+ throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
543
+ }
544
+ const sshConfig = { host: HOST, port: PORT, username: USER };
545
+ if (PASSWORD) {
546
+ sshConfig.password = PASSWORD;
547
+ }
548
+ else if (KEY) {
549
+ const fs = await import('fs/promises');
550
+ sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
551
+ }
552
+ if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
553
+ sshConfig.suPassword = sanitizePassword(SUPASSWORD);
554
+ }
555
+ if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
556
+ sshConfig.sudoPassword = sanitizePassword(SUDOPASSWORD);
557
+ }
558
+ connectionManager = new SSHConnectionManager(sshConfig);
559
+ }
560
+ await connectionManager.ensureConnected();
561
+ const sftp = await connectionManager.sftp();
562
+ await new Promise((resolve, reject) => {
563
+ sftp.fastGet(remotePath, localPath, (err) => {
564
+ if (err)
565
+ reject(new McpError(ErrorCode.InternalError, `Download failed: ${err.message}`));
566
+ else
567
+ resolve();
568
+ });
569
+ });
570
+ return {
571
+ content: [{ type: "text", text: `Downloaded ${remotePath} → ${localPath}` }],
572
+ };
573
+ }
574
+ catch (err) {
575
+ if (err instanceof McpError)
576
+ throw err;
577
+ throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
578
+ }
579
+ });
580
+ // New function that uses persistent connection
581
+ export async function execSshCommandWithConnection(manager, command, stdin) {
582
+ const conn = manager.getConnection();
583
+ const shell = manager.suShell; // Use su shell if available
584
+ // If we have an active su shell, use it directly (commands run as root in session)
585
+ // Serialize through a queue to prevent concurrent-write corruption
586
+ if (shell) {
587
+ const result = manager.suShellQueue.then(() => {
588
+ return new Promise((resolve, reject) => {
589
+ let buffer = '';
590
+ let isResolved = false;
591
+ const timeoutId = setTimeout(() => {
592
+ if (!isResolved) {
593
+ isResolved = true;
594
+ shell.removeListener('data', dataHandler);
595
+ reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
596
+ }
597
+ }, DEFAULT_TIMEOUT);
598
+ const dataHandler = (data) => {
599
+ buffer += data.toString();
600
+ // Strip \r and ANSI escape codes, then check for root prompt #
601
+ const cleanBuffer = buffer.replace(/\r/g, '').replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
602
+ if (/\n#\s*$/.test(cleanBuffer) || /^#\s*$/.test(cleanBuffer)) {
603
+ if (!isResolved) {
604
+ isResolved = true;
605
+ clearTimeout(timeoutId);
606
+ shell.removeListener('data', dataHandler);
607
+ // Extract output: strip trailing # prompt and optional echoed command line
608
+ let output = cleanBuffer.replace(/\n#\s*$/, '');
609
+ const lines = output.split('\n');
610
+ if (lines.length > 0 && lines[0].trim() === command.trim()) {
611
+ lines.shift();
612
+ }
613
+ output = lines.join('\n').trimEnd();
614
+ resolve({
615
+ content: [{ type: 'text', text: output + (output ? '\n' : '') }],
616
+ });
617
+ }
618
+ }
619
+ };
620
+ shell.on('data', dataHandler);
621
+ shell.write(command + '\n');
622
+ });
623
+ });
624
+ manager.suShellQueue = result.then(() => { }, () => { });
625
+ return await result;
626
+ }
627
+ // No persistent su shell; use normal exec with optional password piping
628
+ return new Promise((resolve, reject) => {
629
+ let timeoutId;
630
+ let isResolved = false;
631
+ timeoutId = setTimeout(() => {
632
+ if (!isResolved) {
633
+ isResolved = true;
634
+ reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
635
+ }
636
+ }, DEFAULT_TIMEOUT);
637
+ conn.exec(command, (err, stream) => {
638
+ if (err) {
639
+ if (!isResolved) {
640
+ isResolved = true;
641
+ clearTimeout(timeoutId);
642
+ reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
643
+ }
644
+ return;
645
+ }
646
+ let stdout = '';
647
+ let stderr = '';
648
+ if (stdin && stdin.length > 0) {
649
+ try {
650
+ stream.write(stdin);
651
+ }
652
+ catch (e) { /* ignore */ }
653
+ }
654
+ try {
655
+ stream.end();
656
+ }
657
+ catch (e) { /* ignore */ }
658
+ stream.on('data', (data) => {
659
+ stdout += data.toString();
660
+ });
661
+ stream.stderr.on('data', (data) => {
662
+ stderr += data.toString();
663
+ });
664
+ stream.on('close', (code, signal) => {
665
+ if (!isResolved) {
666
+ isResolved = true;
667
+ clearTimeout(timeoutId);
668
+ if (stderr) {
669
+ reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
670
+ }
671
+ else {
672
+ resolve({
673
+ content: [{ type: 'text', text: stdout }],
674
+ });
675
+ }
676
+ }
677
+ });
678
+ });
679
+ });
680
+ }
681
+ // Keep the old function for backward compatibility (used in tests)
682
+ export async function execSshCommand(sshConfig, command, stdin) {
683
+ return new Promise((resolve, reject) => {
684
+ const conn = new Client();
685
+ let timeoutId;
686
+ let isResolved = false;
687
+ // Set up timeout
688
+ timeoutId = setTimeout(() => {
689
+ if (!isResolved) {
690
+ isResolved = true;
691
+ // Try to abort the running command before closing connection
692
+ const abortTimeout = setTimeout(() => {
693
+ // If abort command itself times out, force close connection
694
+ conn.end();
695
+ }, 5000); // 5 second timeout for abort command
696
+ conn.exec('timeout 3s pkill -f \'' + escapeCommandForShell(command) + '\' 2>/dev/null || true', (err, abortStream) => {
697
+ if (abortStream) {
698
+ abortStream.on('close', () => {
699
+ clearTimeout(abortTimeout);
700
+ conn.end();
701
+ });
702
+ }
703
+ else {
704
+ clearTimeout(abortTimeout);
705
+ conn.end();
706
+ }
707
+ });
708
+ reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
709
+ }
710
+ }, DEFAULT_TIMEOUT);
711
+ conn.on('ready', () => {
712
+ conn.exec(command, (err, stream) => {
713
+ if (err) {
714
+ if (!isResolved) {
715
+ isResolved = true;
716
+ clearTimeout(timeoutId);
717
+ reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
718
+ }
719
+ conn.end();
720
+ return;
721
+ }
722
+ // If stdin provided, write it to the stream and end stdin
723
+ if (stdin && stdin.length > 0) {
724
+ try {
725
+ stream.write(stdin);
726
+ }
727
+ catch (e) {
728
+ // ignore
729
+ }
730
+ }
731
+ try {
732
+ stream.end();
733
+ }
734
+ catch (e) { /* ignore */ }
735
+ let stdout = '';
736
+ let stderr = '';
737
+ stream.on('close', (code, signal) => {
738
+ if (!isResolved) {
739
+ isResolved = true;
740
+ clearTimeout(timeoutId);
741
+ conn.end();
742
+ if (stderr) {
743
+ reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
744
+ }
745
+ else {
746
+ resolve({
747
+ content: [{
748
+ type: 'text',
749
+ text: stdout,
750
+ }],
751
+ });
752
+ }
753
+ }
754
+ });
755
+ stream.on('data', (data) => {
756
+ stdout += data.toString();
757
+ });
758
+ stream.stderr.on('data', (data) => {
759
+ stderr += data.toString();
760
+ });
761
+ });
762
+ });
763
+ conn.on('error', (err) => {
764
+ if (!isResolved) {
765
+ isResolved = true;
766
+ clearTimeout(timeoutId);
767
+ reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
768
+ }
769
+ });
770
+ conn.connect(sshConfig);
771
+ });
772
+ }
773
+ async function main() {
774
+ const transport = new StdioServerTransport();
775
+ await server.connect(transport);
776
+ console.error("SSH MCP Server running on stdio");
777
+ // Handle graceful shutdown
778
+ const cleanup = () => {
779
+ console.error("Shutting down SSH MCP Server...");
780
+ if (connectionManager) {
781
+ connectionManager.close();
782
+ connectionManager = null;
783
+ }
784
+ process.exit(0);
785
+ };
786
+ process.on('SIGINT', cleanup);
787
+ process.on('SIGTERM', cleanup);
788
+ process.on('exit', () => {
789
+ if (connectionManager) {
790
+ connectionManager.close();
791
+ }
792
+ });
793
+ }
794
+ // Initialize server in test mode for automated tests
795
+ if (isTestMode) {
796
+ const transport = new StdioServerTransport();
797
+ server.connect(transport).catch(error => {
798
+ console.error("Fatal error connecting server:", error);
799
+ process.exit(1);
800
+ });
801
+ }
802
+ // Start server in CLI mode
803
+ else if (isCliEnabled) {
804
+ main().catch((error) => {
805
+ console.error("Fatal error in main():", error);
806
+ if (connectionManager) {
807
+ connectionManager.close();
808
+ }
809
+ process.exit(1);
810
+ });
811
+ }
812
+ export { parseArgv, validateConfig };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "ssh-mcp2",
3
+ "license": "MIT",
4
+ "version": "1.5.0",
5
+ "description": "MCP server exposing SSH control for Linux and Windows systems via Model Context Protocol.",
6
+ "type": "module",
7
+ "bin": {
8
+ "ssh-mcp": "build/index.js"
9
+ },
10
+ "scripts": {
11
+ "prepare": "npm run build",
12
+ "build": "tsc && shx chmod +x build/*.js",
13
+ "inspect": "npx @modelcontextprotocol/inspector node build/index.js",
14
+ "test": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run",
15
+ "test:watch": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest",
16
+ "coverage": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run --coverage"
17
+ },
18
+ "files": [
19
+ "build"
20
+ ],
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.17.5",
23
+ "ssh2": "^1.17.0",
24
+ "zod": "3.23.8"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^24.5.2",
28
+ "@types/ssh2": "^1.15.5",
29
+ "cross-env": "^7.0.3",
30
+ "shx": "^0.4.0",
31
+ "testcontainers": "^11.7.0",
32
+ "ts-node": "^10.9.2",
33
+ "tsx": "^4.20.6",
34
+ "typescript": "^5.9.2",
35
+ "vitest": "^3.2.4"
36
+ },
37
+ "homepage": "https://github.com/xxd-169/ssh-mcp2#readme",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/xxd-169/ssh-mcp2.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/xxd-169/ssh-mcp2/issues"
44
+ },
45
+ "keywords": [
46
+ "ssh",
47
+ "mcp",
48
+ "model-context-protocol",
49
+ "server",
50
+ "windows",
51
+ "linux",
52
+ "automation",
53
+ "remote",
54
+ "cli",
55
+ "typescript"
56
+ ],
57
+ "author": "rock169",
58
+ "engines": {
59
+ "node": ">=18"
60
+ }
61
+ }