ssh-mcp 1.1.0 → 1.2.2
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 +57 -1
- package/build/index.js +180 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
|
|
68
68
|
## Client Setup
|
|
69
69
|
|
|
70
|
-
You can configure Claude Desktop to use this MCP Server.
|
|
70
|
+
You can configure your IDE or LLM like Cursor, Windsurf, Claude Desktop to use this MCP Server.
|
|
71
71
|
|
|
72
72
|
**Required Parameters:**
|
|
73
73
|
- `host`: Hostname or IP of the Linux or Windows server
|
|
@@ -103,6 +103,62 @@ You can configure Claude Desktop to use this MCP Server.
|
|
|
103
103
|
}
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
### Claude Code
|
|
107
|
+
|
|
108
|
+
You can add this MCP server to Claude Code using the `claude mcp add` command. This is the recommended method for Claude Code.
|
|
109
|
+
|
|
110
|
+
**Basic Installation:**
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Installation Examples:**
|
|
117
|
+
|
|
118
|
+
**With Password Authentication:**
|
|
119
|
+
```bash
|
|
120
|
+
claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=192.168.1.100 --port=22 --user=admin --password=your_password
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**With SSH Key Authentication:**
|
|
124
|
+
```bash
|
|
125
|
+
claude mcp add --transport stdio ssh-mcp -- npx -y ssh-mcp -- --host=example.com --user=root --key=/path/to/private/key
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**With Custom Timeout and No Character Limit:**
|
|
129
|
+
```bash
|
|
130
|
+
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
|
+
```
|
|
132
|
+
|
|
133
|
+
**Installation Scopes:**
|
|
134
|
+
|
|
135
|
+
You can specify the scope when adding the server:
|
|
136
|
+
|
|
137
|
+
- **Local scope** (default): For personal use in the current project
|
|
138
|
+
```bash
|
|
139
|
+
claude mcp add --transport stdio ssh-mcp --scope local -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **Project scope**: Share with your team via `.mcp.json` file
|
|
143
|
+
```bash
|
|
144
|
+
claude mcp add --transport stdio ssh-mcp --scope project -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- **User scope**: Available across all your projects
|
|
148
|
+
```bash
|
|
149
|
+
claude mcp add --transport stdio ssh-mcp --scope user -- npx -y ssh-mcp -- --host=YOUR_HOST --user=YOUR_USER --password=YOUR_PASSWORD
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
**Verify Installation:**
|
|
154
|
+
|
|
155
|
+
After adding the server, restart Claude Code and ask Cascade to execute a command:
|
|
156
|
+
```
|
|
157
|
+
"Can you run 'ls -la' on the remote server?"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
For more information about MCP in Claude Code, see the [official documentation](https://docs.claude.com/en/docs/claude-code/mcp).
|
|
161
|
+
|
|
106
162
|
## Testing
|
|
107
163
|
|
|
108
164
|
You can use the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) for visual debugging of this MCP Server.
|
package/build/index.js
CHANGED
|
@@ -80,9 +80,86 @@ export function escapeCommandForShell(command) {
|
|
|
80
80
|
// Replace single quotes with escaped single quotes
|
|
81
81
|
return command.replace(/'/g, "'\"'\"'");
|
|
82
82
|
}
|
|
83
|
+
// SSH Connection Manager to maintain persistent connection
|
|
84
|
+
export class SSHConnectionManager {
|
|
85
|
+
conn = null;
|
|
86
|
+
sshConfig = null;
|
|
87
|
+
isConnecting = false;
|
|
88
|
+
connectionPromise = null;
|
|
89
|
+
constructor(config) {
|
|
90
|
+
this.sshConfig = config;
|
|
91
|
+
}
|
|
92
|
+
async connect() {
|
|
93
|
+
if (this.conn && this.isConnected()) {
|
|
94
|
+
return; // Already connected
|
|
95
|
+
}
|
|
96
|
+
if (this.isConnecting && this.connectionPromise) {
|
|
97
|
+
return this.connectionPromise; // Wait for ongoing connection
|
|
98
|
+
}
|
|
99
|
+
this.isConnecting = true;
|
|
100
|
+
this.connectionPromise = new Promise((resolve, reject) => {
|
|
101
|
+
this.conn = new SSHClient();
|
|
102
|
+
const timeoutId = setTimeout(() => {
|
|
103
|
+
this.conn?.end();
|
|
104
|
+
this.conn = null;
|
|
105
|
+
this.isConnecting = false;
|
|
106
|
+
this.connectionPromise = null;
|
|
107
|
+
reject(new McpError(ErrorCode.InternalError, 'SSH connection timeout'));
|
|
108
|
+
}, 30000); // 30 seconds connection timeout
|
|
109
|
+
this.conn.on('ready', () => {
|
|
110
|
+
clearTimeout(timeoutId);
|
|
111
|
+
this.isConnecting = false;
|
|
112
|
+
console.error('SSH connection established');
|
|
113
|
+
resolve();
|
|
114
|
+
});
|
|
115
|
+
this.conn.on('error', (err) => {
|
|
116
|
+
clearTimeout(timeoutId);
|
|
117
|
+
this.conn = null;
|
|
118
|
+
this.isConnecting = false;
|
|
119
|
+
this.connectionPromise = null;
|
|
120
|
+
reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
|
|
121
|
+
});
|
|
122
|
+
this.conn.on('end', () => {
|
|
123
|
+
console.error('SSH connection ended');
|
|
124
|
+
this.conn = null;
|
|
125
|
+
this.isConnecting = false;
|
|
126
|
+
this.connectionPromise = null;
|
|
127
|
+
});
|
|
128
|
+
this.conn.on('close', () => {
|
|
129
|
+
console.error('SSH connection closed');
|
|
130
|
+
this.conn = null;
|
|
131
|
+
this.isConnecting = false;
|
|
132
|
+
this.connectionPromise = null;
|
|
133
|
+
});
|
|
134
|
+
this.conn.connect(this.sshConfig);
|
|
135
|
+
});
|
|
136
|
+
return this.connectionPromise;
|
|
137
|
+
}
|
|
138
|
+
isConnected() {
|
|
139
|
+
return this.conn !== null && this.conn._sock && !this.conn._sock.destroyed;
|
|
140
|
+
}
|
|
141
|
+
async ensureConnected() {
|
|
142
|
+
if (!this.isConnected()) {
|
|
143
|
+
await this.connect();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
getConnection() {
|
|
147
|
+
if (!this.conn) {
|
|
148
|
+
throw new McpError(ErrorCode.InternalError, 'SSH connection not established');
|
|
149
|
+
}
|
|
150
|
+
return this.conn;
|
|
151
|
+
}
|
|
152
|
+
close() {
|
|
153
|
+
if (this.conn) {
|
|
154
|
+
this.conn.end();
|
|
155
|
+
this.conn = null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
let connectionManager = null;
|
|
83
160
|
const server = new McpServer({
|
|
84
161
|
name: 'SSH MCP Server',
|
|
85
|
-
version: '1.
|
|
162
|
+
version: '1.2.2',
|
|
86
163
|
capabilities: {
|
|
87
164
|
resources: {},
|
|
88
165
|
tools: {},
|
|
@@ -93,20 +170,26 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
|
|
|
93
170
|
}, async ({ command }) => {
|
|
94
171
|
// Sanitize command input
|
|
95
172
|
const sanitizedCommand = sanitizeCommand(command);
|
|
96
|
-
const sshConfig = {
|
|
97
|
-
host: HOST,
|
|
98
|
-
port: PORT,
|
|
99
|
-
username: USER,
|
|
100
|
-
};
|
|
101
173
|
try {
|
|
102
|
-
if
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
174
|
+
// Initialize connection manager if not already done
|
|
175
|
+
if (!connectionManager) {
|
|
176
|
+
const sshConfig = {
|
|
177
|
+
host: HOST,
|
|
178
|
+
port: PORT,
|
|
179
|
+
username: USER,
|
|
180
|
+
};
|
|
181
|
+
if (PASSWORD) {
|
|
182
|
+
sshConfig.password = PASSWORD;
|
|
183
|
+
}
|
|
184
|
+
else if (KEY) {
|
|
185
|
+
const fs = await import('fs/promises');
|
|
186
|
+
sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
|
|
187
|
+
}
|
|
188
|
+
connectionManager = new SSHConnectionManager(sshConfig);
|
|
108
189
|
}
|
|
109
|
-
|
|
190
|
+
// Ensure connection is active (reconnect if needed)
|
|
191
|
+
await connectionManager.ensureConnected();
|
|
192
|
+
const result = await execSshCommandWithConnection(connectionManager, sanitizedCommand);
|
|
110
193
|
return result;
|
|
111
194
|
}
|
|
112
195
|
catch (err) {
|
|
@@ -116,6 +199,71 @@ server.tool("exec", "Execute a shell command on the remote SSH server and return
|
|
|
116
199
|
throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
|
|
117
200
|
}
|
|
118
201
|
});
|
|
202
|
+
// New function that uses persistent connection
|
|
203
|
+
export async function execSshCommandWithConnection(manager, command) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
let timeoutId;
|
|
206
|
+
let isResolved = false;
|
|
207
|
+
const conn = manager.getConnection();
|
|
208
|
+
// Set up timeout
|
|
209
|
+
timeoutId = setTimeout(() => {
|
|
210
|
+
if (!isResolved) {
|
|
211
|
+
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
|
+
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
|
|
227
|
+
}
|
|
228
|
+
}, DEFAULT_TIMEOUT);
|
|
229
|
+
conn.exec(command, (err, stream) => {
|
|
230
|
+
if (err) {
|
|
231
|
+
if (!isResolved) {
|
|
232
|
+
isResolved = true;
|
|
233
|
+
clearTimeout(timeoutId);
|
|
234
|
+
reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
let stdout = '';
|
|
239
|
+
let stderr = '';
|
|
240
|
+
stream.on('close', (code, signal) => {
|
|
241
|
+
if (!isResolved) {
|
|
242
|
+
isResolved = true;
|
|
243
|
+
clearTimeout(timeoutId);
|
|
244
|
+
if (stderr) {
|
|
245
|
+
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
resolve({
|
|
249
|
+
content: [{
|
|
250
|
+
type: 'text',
|
|
251
|
+
text: stdout,
|
|
252
|
+
}],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
stream.on('data', (data) => {
|
|
258
|
+
stdout += data.toString();
|
|
259
|
+
});
|
|
260
|
+
stream.stderr.on('data', (data) => {
|
|
261
|
+
stderr += data.toString();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// Keep the old function for backward compatibility (used in tests)
|
|
119
267
|
export async function execSshCommand(sshConfig, command) {
|
|
120
268
|
return new Promise((resolve, reject) => {
|
|
121
269
|
const conn = new SSHClient();
|
|
@@ -198,10 +346,29 @@ async function main() {
|
|
|
198
346
|
const transport = new StdioServerTransport();
|
|
199
347
|
await server.connect(transport);
|
|
200
348
|
console.error("SSH MCP Server running on stdio");
|
|
349
|
+
// Handle graceful shutdown
|
|
350
|
+
const cleanup = () => {
|
|
351
|
+
console.error("Shutting down SSH MCP Server...");
|
|
352
|
+
if (connectionManager) {
|
|
353
|
+
connectionManager.close();
|
|
354
|
+
connectionManager = null;
|
|
355
|
+
}
|
|
356
|
+
process.exit(0);
|
|
357
|
+
};
|
|
358
|
+
process.on('SIGINT', cleanup);
|
|
359
|
+
process.on('SIGTERM', cleanup);
|
|
360
|
+
process.on('exit', () => {
|
|
361
|
+
if (connectionManager) {
|
|
362
|
+
connectionManager.close();
|
|
363
|
+
}
|
|
364
|
+
});
|
|
201
365
|
}
|
|
202
366
|
if (process.env.SSH_MCP_DISABLE_MAIN !== '1') {
|
|
203
367
|
main().catch((error) => {
|
|
204
368
|
console.error("Fatal error in main():", error);
|
|
369
|
+
if (connectionManager) {
|
|
370
|
+
connectionManager.close();
|
|
371
|
+
}
|
|
205
372
|
process.exit(1);
|
|
206
373
|
});
|
|
207
374
|
}
|