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.
- package/README.md +204 -0
- package/build/index.js +812 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# SSH MCP Server
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/ssh-mcp)
|
|
4
|
+
[](https://www.npmjs.com/package/ssh-mcp)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](https://github.com/tufantunc/ssh-mcp/stargazers)
|
|
8
|
+
[](https://github.com/tufantunc/ssh-mcp/forks)
|
|
9
|
+
[](https://github.com/tufantunc/ssh-mcp/actions)
|
|
10
|
+
[](https://github.com/tufantunc/ssh-mcp/issues)
|
|
11
|
+
|
|
12
|
+
[](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
|
+
}
|