@xiaoyankonling/ssh-mcp 2.0.0 → 2.1.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/LICENSE +4 -3
- package/README.md +15 -10
- package/build/index.js +4 -23
- package/build/profile/profile-manager.js +3 -0
- package/build/ssh/connection-manager.js +281 -37
- package/build/ssh/ssh-config.js +31 -0
- package/build/tools/exec.js +9 -2
- package/build/tools/profiles.js +32 -0
- package/build/tools/sudo-exec.js +9 -2
- package/package.json +16 -16
package/LICENSE
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 Tufan Tunç
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tufan Tunç
|
|
4
|
+
Copyright (c) 2026 xiaoyankonling
|
|
4
5
|
|
|
5
6
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
7
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
- Execute shell commands on remote Linux and Windows systems
|
|
37
37
|
- Secure authentication via password or SSH key
|
|
38
38
|
- Local profile configuration via YAML/JSON files
|
|
39
|
-
- Runtime profile management tools (`profiles-list/use/reload/note-update`)
|
|
39
|
+
- Runtime profile management tools (`profiles-list/find/use/reload/note-update/profiles-test`)
|
|
40
40
|
- Profile notes/tags for operational context
|
|
41
41
|
- Built with TypeScript and the official MCP SDK
|
|
42
42
|
- **Configurable timeout protection** with automatic process abortion
|
|
@@ -44,16 +44,18 @@
|
|
|
44
44
|
|
|
45
45
|
### Tools
|
|
46
46
|
|
|
47
|
-
- `exec`: Execute a shell command on the remote server
|
|
48
|
-
- **Parameters:**
|
|
49
|
-
- `command` (required): Shell command to execute on the remote SSH server
|
|
50
|
-
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
51
|
-
|
|
47
|
+
- `exec`: Execute a shell command on the remote server
|
|
48
|
+
- **Parameters:**
|
|
49
|
+
- `command` (required): Shell command to execute on the remote SSH server
|
|
50
|
+
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
51
|
+
- `timeoutMs` (optional): Per-command timeout override in milliseconds
|
|
52
|
+
- **Timeout Configuration:**
|
|
52
53
|
|
|
53
54
|
- `sudo-exec`: Execute a shell command with sudo elevation
|
|
54
|
-
- **Parameters:**
|
|
55
|
-
- `command` (required): Shell command to execute as root using sudo
|
|
56
|
-
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
55
|
+
- **Parameters:**
|
|
56
|
+
- `command` (required): Shell command to execute as root using sudo
|
|
57
|
+
- `description` (optional): Optional description of what this command will do (appended as a comment)
|
|
58
|
+
- `timeoutMs` (optional): Per-command timeout override in milliseconds
|
|
57
59
|
- **Notes:**
|
|
58
60
|
- Requires `--sudoPassword` to be set for password-protected sudo
|
|
59
61
|
- Can be disabled by passing the `--disableSudo` flag at startup if sudo access is not needed or not available
|
|
@@ -70,6 +72,7 @@
|
|
|
70
72
|
|
|
71
73
|
- `profiles-list`: List profile summaries (`id/name/host/port/note/tags/active`) with sensitive fields masked
|
|
72
74
|
- `profiles-find`: Find profile candidates by keyword across `id/name/host/user/note/tags`
|
|
75
|
+
- `profiles-test`: Test TCP connectivity, SSH handshake, and authentication for a profile
|
|
73
76
|
- `profiles-use`: Switch active profile at runtime, persist `activeProfile`, and recreate SSH connection on next command
|
|
74
77
|
- `profiles-reload`: Reload profile configuration from disk and validate active profile still exists
|
|
75
78
|
- `profiles-create`: Create profile templates dynamically at runtime (note optional but recommended)
|
|
@@ -157,7 +160,9 @@ Notes:
|
|
|
157
160
|
- by default, created profile is activated immediately
|
|
158
161
|
2. Keep note short and precise:
|
|
159
162
|
- if `note` is omitted, tool can derive a concise note from `contextSummary`
|
|
160
|
-
3.
|
|
163
|
+
3. Validate target quickly:
|
|
164
|
+
- call `profiles-test` to verify TCP/SSH/auth before executing commands
|
|
165
|
+
4. Safe deletion (two-step):
|
|
161
166
|
- call `profiles-delete-prepare` first
|
|
162
167
|
- review returned profile + backup path + confirmation text with user
|
|
163
168
|
- only then call `profiles-delete-confirm`
|
package/build/index.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile } from 'fs/promises';
|
|
3
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
4
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
6
5
|
import { determineStartupMode, parseArgv, resolveRuntimeOptions, } from './cli/args.js';
|
|
7
|
-
import { resolveKeyPath } from './config/loader.js';
|
|
8
6
|
import { ProfileManager } from './profile/profile-manager.js';
|
|
9
|
-
import { DEFAULT_MAX_CHARS, DEFAULT_TIMEOUT_MS, sanitizeCommand,
|
|
7
|
+
import { DEFAULT_MAX_CHARS, DEFAULT_TIMEOUT_MS, sanitizeCommand, } from './ssh/command-utils.js';
|
|
10
8
|
import { SSHConnectionManager, execSshCommand, execSshCommandWithConnection, } from './ssh/connection-manager.js';
|
|
9
|
+
import { buildSshConfigFromProfile } from './ssh/ssh-config.js';
|
|
11
10
|
import { registerExecTool } from './tools/exec.js';
|
|
12
11
|
import { registerProfileTools } from './tools/profiles.js';
|
|
13
12
|
import { registerSudoExecTool } from './tools/sudo-exec.js';
|
|
@@ -17,7 +16,7 @@ const shouldBootServer = isCliEnabled || isTestMode;
|
|
|
17
16
|
const argvConfig = shouldBootServer ? parseArgv() : {};
|
|
18
17
|
const server = new McpServer({
|
|
19
18
|
name: 'SSH MCP Server',
|
|
20
|
-
version: '
|
|
19
|
+
version: '2.0.0',
|
|
21
20
|
capabilities: {
|
|
22
21
|
resources: {},
|
|
23
22
|
tools: {},
|
|
@@ -48,25 +47,7 @@ async function buildSshConfig(mode) {
|
|
|
48
47
|
throw new McpError(ErrorCode.InternalError, 'Profile mode not initialized');
|
|
49
48
|
}
|
|
50
49
|
const profile = profileManager.getActiveProfile();
|
|
51
|
-
|
|
52
|
-
host: profile.host,
|
|
53
|
-
port: profile.port,
|
|
54
|
-
username: profile.user,
|
|
55
|
-
};
|
|
56
|
-
if (profile.auth.type === 'password') {
|
|
57
|
-
sshConfig.password = sanitizePassword(profile.auth.password);
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
const keyPath = resolveKeyPath(profile.auth.keyPath, profileManager.getConfigPath());
|
|
61
|
-
sshConfig.privateKey = await readFile(keyPath, 'utf8');
|
|
62
|
-
}
|
|
63
|
-
if (profile.suPassword !== undefined) {
|
|
64
|
-
sshConfig.suPassword = sanitizePassword(profile.suPassword);
|
|
65
|
-
}
|
|
66
|
-
if (profile.sudoPassword !== undefined) {
|
|
67
|
-
sshConfig.sudoPassword = sanitizePassword(profile.sudoPassword);
|
|
68
|
-
}
|
|
69
|
-
return sshConfig;
|
|
50
|
+
return buildSshConfigFromProfile(profile, profileManager.getConfigPath());
|
|
70
51
|
}
|
|
71
52
|
async function getConnectionManager() {
|
|
72
53
|
if (!startupMode) {
|
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { Client } from 'ssh2';
|
|
3
|
+
import net from 'net';
|
|
3
4
|
import { DEFAULT_TIMEOUT_MS, escapeCommandForShell } from './command-utils.js';
|
|
5
|
+
const DEFAULT_CONNECT_RETRY_DELAYS_MS = [200, 400];
|
|
6
|
+
const DEFAULT_TEST_TIMEOUT_MS = 10000;
|
|
7
|
+
const MAX_TEST_RETRIES = 5;
|
|
8
|
+
function sleep(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
function toErrorMessage(err) {
|
|
12
|
+
if (err instanceof Error)
|
|
13
|
+
return err.message;
|
|
14
|
+
return String(err);
|
|
15
|
+
}
|
|
16
|
+
function isAuthFailure(err) {
|
|
17
|
+
const message = toErrorMessage(err).toLowerCase();
|
|
18
|
+
return message.includes('authentication') ||
|
|
19
|
+
message.includes('permission denied') ||
|
|
20
|
+
message.includes('all configured authentication methods failed') ||
|
|
21
|
+
message.includes('auth failed');
|
|
22
|
+
}
|
|
23
|
+
function isRetryableConnectionError(err) {
|
|
24
|
+
if (isAuthFailure(err))
|
|
25
|
+
return false;
|
|
26
|
+
const message = toErrorMessage(err).toLowerCase();
|
|
27
|
+
const code = err?.code;
|
|
28
|
+
if (code && ['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ETIMEDOUT'].includes(code)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return message.includes('handshake') ||
|
|
32
|
+
message.includes('kex') ||
|
|
33
|
+
message.includes('timeout') ||
|
|
34
|
+
message.includes('timed out') ||
|
|
35
|
+
message.includes('connection closed') ||
|
|
36
|
+
message.includes('protocol');
|
|
37
|
+
}
|
|
38
|
+
export function formatTargetContext(config) {
|
|
39
|
+
const profileId = config.profileId ? config.profileId : 'unknown';
|
|
40
|
+
return `profileId=${profileId} host=${config.host} port=${config.port}`;
|
|
41
|
+
}
|
|
4
42
|
export class SSHConnectionManager {
|
|
5
43
|
conn = null;
|
|
6
44
|
sshConfig;
|
|
@@ -20,19 +58,48 @@ export class SSHConnectionManager {
|
|
|
20
58
|
return this.connectionPromise;
|
|
21
59
|
}
|
|
22
60
|
this.isConnecting = true;
|
|
23
|
-
this.connectionPromise =
|
|
24
|
-
|
|
61
|
+
this.connectionPromise = this.connectWithRetry()
|
|
62
|
+
.finally(() => {
|
|
63
|
+
this.isConnecting = false;
|
|
64
|
+
this.connectionPromise = null;
|
|
65
|
+
});
|
|
66
|
+
return this.connectionPromise;
|
|
67
|
+
}
|
|
68
|
+
async connectWithRetry() {
|
|
69
|
+
let lastError;
|
|
70
|
+
for (let attempt = 0; attempt <= DEFAULT_CONNECT_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
71
|
+
try {
|
|
72
|
+
await this.connectOnce();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
lastError = err;
|
|
77
|
+
if (!isRetryableConnectionError(err) || attempt === DEFAULT_CONNECT_RETRY_DELAYS_MS.length) {
|
|
78
|
+
throw this.wrapConnectionError(err);
|
|
79
|
+
}
|
|
80
|
+
await sleep(DEFAULT_CONNECT_RETRY_DELAYS_MS[attempt]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw this.wrapConnectionError(lastError);
|
|
84
|
+
}
|
|
85
|
+
getConnectConfig() {
|
|
86
|
+
const { profileId, ...config } = this.sshConfig;
|
|
87
|
+
return config;
|
|
88
|
+
}
|
|
89
|
+
async connectOnce() {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const client = new Client();
|
|
92
|
+
this.conn = client;
|
|
25
93
|
const timeoutId = setTimeout(() => {
|
|
26
|
-
|
|
27
|
-
this.conn
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
94
|
+
client.end();
|
|
95
|
+
if (this.conn === client) {
|
|
96
|
+
this.conn = null;
|
|
97
|
+
}
|
|
98
|
+
const timeoutError = Object.assign(new Error('SSH connection timeout'), { code: 'ETIMEDOUT' });
|
|
99
|
+
reject(timeoutError);
|
|
31
100
|
}, 30000);
|
|
32
|
-
|
|
101
|
+
client.on('ready', async () => {
|
|
33
102
|
clearTimeout(timeoutId);
|
|
34
|
-
this.isConnecting = false;
|
|
35
|
-
this.connectionPromise = null;
|
|
36
103
|
if (this.sshConfig.suPassword && !process.env.SSH_MCP_TEST) {
|
|
37
104
|
try {
|
|
38
105
|
await this.ensureElevated();
|
|
@@ -43,26 +110,30 @@ export class SSHConnectionManager {
|
|
|
43
110
|
}
|
|
44
111
|
resolve();
|
|
45
112
|
});
|
|
46
|
-
|
|
113
|
+
client.on('error', (err) => {
|
|
47
114
|
clearTimeout(timeoutId);
|
|
48
|
-
this.conn
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
reject(
|
|
115
|
+
if (this.conn === client) {
|
|
116
|
+
this.conn = null;
|
|
117
|
+
}
|
|
118
|
+
reject(err);
|
|
52
119
|
});
|
|
53
|
-
|
|
54
|
-
this.conn
|
|
55
|
-
|
|
56
|
-
|
|
120
|
+
client.on('end', () => {
|
|
121
|
+
if (this.conn === client) {
|
|
122
|
+
this.conn = null;
|
|
123
|
+
}
|
|
57
124
|
});
|
|
58
|
-
|
|
59
|
-
this.conn
|
|
60
|
-
|
|
61
|
-
|
|
125
|
+
client.on('close', () => {
|
|
126
|
+
if (this.conn === client) {
|
|
127
|
+
this.conn = null;
|
|
128
|
+
}
|
|
62
129
|
});
|
|
63
|
-
|
|
130
|
+
client.connect(this.getConnectConfig());
|
|
64
131
|
});
|
|
65
|
-
|
|
132
|
+
}
|
|
133
|
+
wrapConnectionError(err, targetOverride) {
|
|
134
|
+
const target = targetOverride ?? this.getTargetContext();
|
|
135
|
+
const message = toErrorMessage(err);
|
|
136
|
+
return new McpError(ErrorCode.InternalError, `SSH connection error (${target}): ${message}`);
|
|
66
137
|
}
|
|
67
138
|
isConnected() {
|
|
68
139
|
return this.conn !== null && Boolean(this.conn._sock) && !this.conn._sock.destroyed;
|
|
@@ -105,17 +176,18 @@ export class SSHConnectionManager {
|
|
|
105
176
|
return;
|
|
106
177
|
if (this.suPromise)
|
|
107
178
|
return this.suPromise;
|
|
179
|
+
const target = this.getTargetContext();
|
|
108
180
|
this.suPromise = new Promise((resolve, reject) => {
|
|
109
181
|
const conn = this.getConnection();
|
|
110
182
|
const timeoutId = setTimeout(() => {
|
|
111
183
|
this.suPromise = null;
|
|
112
|
-
reject(new McpError(ErrorCode.InternalError,
|
|
184
|
+
reject(new McpError(ErrorCode.InternalError, `su elevation timed out (${target})`));
|
|
113
185
|
}, 10000);
|
|
114
186
|
conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => {
|
|
115
187
|
if (err) {
|
|
116
188
|
clearTimeout(timeoutId);
|
|
117
189
|
this.suPromise = null;
|
|
118
|
-
reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su: ${err.message}`));
|
|
190
|
+
reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su (${target}): ${err.message}`));
|
|
119
191
|
return;
|
|
120
192
|
}
|
|
121
193
|
let buffer = '';
|
|
@@ -148,7 +220,7 @@ export class SSHConnectionManager {
|
|
|
148
220
|
clearTimeout(timeoutId);
|
|
149
221
|
cleanup();
|
|
150
222
|
this.suPromise = null;
|
|
151
|
-
reject(new McpError(ErrorCode.InternalError,
|
|
223
|
+
reject(new McpError(ErrorCode.InternalError, `su authentication failed (${target})`));
|
|
152
224
|
}
|
|
153
225
|
};
|
|
154
226
|
stream.on('data', onData);
|
|
@@ -156,7 +228,7 @@ export class SSHConnectionManager {
|
|
|
156
228
|
clearTimeout(timeoutId);
|
|
157
229
|
if (!this.isElevated) {
|
|
158
230
|
this.suPromise = null;
|
|
159
|
-
reject(new McpError(ErrorCode.InternalError,
|
|
231
|
+
reject(new McpError(ErrorCode.InternalError, `su shell closed before elevation completed (${target})`));
|
|
160
232
|
}
|
|
161
233
|
});
|
|
162
234
|
stream.write('su -\n');
|
|
@@ -171,10 +243,13 @@ export class SSHConnectionManager {
|
|
|
171
243
|
}
|
|
172
244
|
getConnection() {
|
|
173
245
|
if (!this.conn) {
|
|
174
|
-
throw new McpError(ErrorCode.InternalError,
|
|
246
|
+
throw new McpError(ErrorCode.InternalError, `SSH connection not established (${this.getTargetContext()})`);
|
|
175
247
|
}
|
|
176
248
|
return this.conn;
|
|
177
249
|
}
|
|
250
|
+
getTargetContext() {
|
|
251
|
+
return formatTargetContext(this.sshConfig);
|
|
252
|
+
}
|
|
178
253
|
getSuShell() {
|
|
179
254
|
return this.suShell;
|
|
180
255
|
}
|
|
@@ -199,12 +274,13 @@ export async function execSshCommandWithConnection(manager, command, timeoutMs =
|
|
|
199
274
|
return new Promise((resolve, reject) => {
|
|
200
275
|
let timeoutId;
|
|
201
276
|
let isResolved = false;
|
|
277
|
+
const target = manager.getTargetContext();
|
|
202
278
|
const conn = manager.getConnection();
|
|
203
279
|
const shell = manager.getSuShell();
|
|
204
280
|
timeoutId = setTimeout(() => {
|
|
205
281
|
if (!isResolved) {
|
|
206
282
|
isResolved = true;
|
|
207
|
-
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${timeoutMs}ms`));
|
|
283
|
+
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${timeoutMs}ms (${target})`));
|
|
208
284
|
}
|
|
209
285
|
}, timeoutMs);
|
|
210
286
|
if (shell) {
|
|
@@ -237,7 +313,7 @@ export async function execSshCommandWithConnection(manager, command, timeoutMs =
|
|
|
237
313
|
if (!isResolved) {
|
|
238
314
|
isResolved = true;
|
|
239
315
|
clearTimeout(timeoutId);
|
|
240
|
-
reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
|
|
316
|
+
reject(new McpError(ErrorCode.InternalError, `SSH exec error (${target}): ${err.message}`));
|
|
241
317
|
}
|
|
242
318
|
return;
|
|
243
319
|
}
|
|
@@ -269,7 +345,7 @@ export async function execSshCommandWithConnection(manager, command, timeoutMs =
|
|
|
269
345
|
isResolved = true;
|
|
270
346
|
clearTimeout(timeoutId);
|
|
271
347
|
if (stderr) {
|
|
272
|
-
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
|
|
348
|
+
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}) (${target}):\n${stderr}`));
|
|
273
349
|
return;
|
|
274
350
|
}
|
|
275
351
|
resolve({
|
|
@@ -287,6 +363,7 @@ export async function execSshCommand(sshConfig, command, stdin, timeoutMs = DEFA
|
|
|
287
363
|
const conn = new Client();
|
|
288
364
|
let timeoutId;
|
|
289
365
|
let isResolved = false;
|
|
366
|
+
const target = formatTargetContext(sshConfig);
|
|
290
367
|
timeoutId = setTimeout(() => {
|
|
291
368
|
if (isResolved)
|
|
292
369
|
return;
|
|
@@ -305,7 +382,7 @@ export async function execSshCommand(sshConfig, command, stdin, timeoutMs = DEFA
|
|
|
305
382
|
clearTimeout(abortTimeout);
|
|
306
383
|
conn.end();
|
|
307
384
|
});
|
|
308
|
-
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${timeoutMs}ms`));
|
|
385
|
+
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${timeoutMs}ms (${target})`));
|
|
309
386
|
}, timeoutMs);
|
|
310
387
|
conn.on('ready', () => {
|
|
311
388
|
conn.exec(command, (err, stream) => {
|
|
@@ -313,7 +390,7 @@ export async function execSshCommand(sshConfig, command, stdin, timeoutMs = DEFA
|
|
|
313
390
|
if (!isResolved) {
|
|
314
391
|
isResolved = true;
|
|
315
392
|
clearTimeout(timeoutId);
|
|
316
|
-
reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
|
|
393
|
+
reject(new McpError(ErrorCode.InternalError, `SSH exec error (${target}): ${err.message}`));
|
|
317
394
|
}
|
|
318
395
|
conn.end();
|
|
319
396
|
return;
|
|
@@ -341,7 +418,7 @@ export async function execSshCommand(sshConfig, command, stdin, timeoutMs = DEFA
|
|
|
341
418
|
clearTimeout(timeoutId);
|
|
342
419
|
conn.end();
|
|
343
420
|
if (stderr) {
|
|
344
|
-
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
|
|
421
|
+
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}) (${target}):\n${stderr}`));
|
|
345
422
|
return;
|
|
346
423
|
}
|
|
347
424
|
resolve({
|
|
@@ -366,6 +443,173 @@ export async function execSshCommand(sshConfig, command, stdin, timeoutMs = DEFA
|
|
|
366
443
|
clearTimeout(timeoutId);
|
|
367
444
|
reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
|
|
368
445
|
});
|
|
369
|
-
|
|
446
|
+
const { profileId, ...connectConfig } = sshConfig;
|
|
447
|
+
conn.connect(connectConfig);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function normalizeRetries(retries) {
|
|
451
|
+
if (retries === undefined || retries === null)
|
|
452
|
+
return 2;
|
|
453
|
+
if (!Number.isFinite(retries))
|
|
454
|
+
return 2;
|
|
455
|
+
const value = Math.max(0, Math.min(MAX_TEST_RETRIES, Math.floor(retries)));
|
|
456
|
+
return value;
|
|
457
|
+
}
|
|
458
|
+
async function testTcpConnection(host, port, timeoutMs) {
|
|
459
|
+
const start = Date.now();
|
|
460
|
+
return new Promise((resolve) => {
|
|
461
|
+
const socket = net.createConnection({ host, port });
|
|
462
|
+
let settled = false;
|
|
463
|
+
const finish = (ok, error) => {
|
|
464
|
+
if (settled)
|
|
465
|
+
return;
|
|
466
|
+
settled = true;
|
|
467
|
+
try {
|
|
468
|
+
socket.destroy();
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// no-op
|
|
472
|
+
}
|
|
473
|
+
resolve({
|
|
474
|
+
ok,
|
|
475
|
+
durationMs: Date.now() - start,
|
|
476
|
+
...(error ? { error } : {}),
|
|
477
|
+
});
|
|
478
|
+
};
|
|
479
|
+
socket.setTimeout(timeoutMs, () => {
|
|
480
|
+
finish(false, 'TCP connection timeout');
|
|
481
|
+
});
|
|
482
|
+
socket.once('error', (err) => {
|
|
483
|
+
finish(false, err.message);
|
|
484
|
+
});
|
|
485
|
+
socket.once('connect', () => {
|
|
486
|
+
finish(true);
|
|
487
|
+
});
|
|
370
488
|
});
|
|
371
489
|
}
|
|
490
|
+
async function testSshHandshakeAndAuth(sshConfig, timeoutMs) {
|
|
491
|
+
const start = Date.now();
|
|
492
|
+
return new Promise((resolve) => {
|
|
493
|
+
const client = new Client();
|
|
494
|
+
let settled = false;
|
|
495
|
+
let handshakeOk = false;
|
|
496
|
+
let authOk = false;
|
|
497
|
+
let handshakeDuration = 0;
|
|
498
|
+
let authDuration = 0;
|
|
499
|
+
let errorMessage;
|
|
500
|
+
let retryable = false;
|
|
501
|
+
const finish = () => {
|
|
502
|
+
if (settled)
|
|
503
|
+
return;
|
|
504
|
+
settled = true;
|
|
505
|
+
try {
|
|
506
|
+
client.end();
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// no-op
|
|
510
|
+
}
|
|
511
|
+
resolve({
|
|
512
|
+
handshake: {
|
|
513
|
+
ok: handshakeOk,
|
|
514
|
+
durationMs: handshakeDuration || Date.now() - start,
|
|
515
|
+
...(handshakeOk ? {} : { error: errorMessage ?? 'Handshake failed' }),
|
|
516
|
+
},
|
|
517
|
+
auth: {
|
|
518
|
+
ok: authOk,
|
|
519
|
+
durationMs: authDuration || Date.now() - start,
|
|
520
|
+
...(authOk ? {} : { error: errorMessage ?? 'Authentication failed' }),
|
|
521
|
+
},
|
|
522
|
+
retryable,
|
|
523
|
+
});
|
|
524
|
+
};
|
|
525
|
+
const timeoutId = setTimeout(() => {
|
|
526
|
+
errorMessage = 'SSH handshake timeout';
|
|
527
|
+
retryable = true;
|
|
528
|
+
finish();
|
|
529
|
+
}, timeoutMs);
|
|
530
|
+
client.on('banner', () => {
|
|
531
|
+
if (!handshakeOk) {
|
|
532
|
+
handshakeOk = true;
|
|
533
|
+
handshakeDuration = Date.now() - start;
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
client.on('ready', () => {
|
|
537
|
+
clearTimeout(timeoutId);
|
|
538
|
+
handshakeOk = true;
|
|
539
|
+
authOk = true;
|
|
540
|
+
if (!handshakeDuration) {
|
|
541
|
+
handshakeDuration = Date.now() - start;
|
|
542
|
+
}
|
|
543
|
+
authDuration = Date.now() - start;
|
|
544
|
+
finish();
|
|
545
|
+
});
|
|
546
|
+
client.on('error', (err) => {
|
|
547
|
+
clearTimeout(timeoutId);
|
|
548
|
+
errorMessage = err.message;
|
|
549
|
+
const authFailure = isAuthFailure(err);
|
|
550
|
+
if (authFailure) {
|
|
551
|
+
handshakeOk = true;
|
|
552
|
+
if (!handshakeDuration) {
|
|
553
|
+
handshakeDuration = Date.now() - start;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
retryable = !authFailure && isRetryableConnectionError(err);
|
|
557
|
+
finish();
|
|
558
|
+
});
|
|
559
|
+
const { profileId, ...connectConfig } = sshConfig;
|
|
560
|
+
client.connect(connectConfig);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
export async function testSshConnection(sshConfig, options = {}) {
|
|
564
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TEST_TIMEOUT_MS;
|
|
565
|
+
const retries = normalizeRetries(options.retries);
|
|
566
|
+
const target = formatTargetContext(sshConfig);
|
|
567
|
+
const tcp = await testTcpConnection(sshConfig.host, sshConfig.port, timeoutMs);
|
|
568
|
+
if (!tcp.ok) {
|
|
569
|
+
return {
|
|
570
|
+
target,
|
|
571
|
+
attempts: 0,
|
|
572
|
+
tcp,
|
|
573
|
+
handshake: {
|
|
574
|
+
ok: false,
|
|
575
|
+
durationMs: 0,
|
|
576
|
+
error: 'Skipped due to TCP failure',
|
|
577
|
+
},
|
|
578
|
+
auth: {
|
|
579
|
+
ok: false,
|
|
580
|
+
durationMs: 0,
|
|
581
|
+
error: 'Skipped due to TCP failure',
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
let attempts = 0;
|
|
586
|
+
let lastHandshake = { ok: false, durationMs: 0, error: 'Not attempted' };
|
|
587
|
+
let lastAuth = { ok: false, durationMs: 0, error: 'Not attempted' };
|
|
588
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
589
|
+
attempts += 1;
|
|
590
|
+
const result = await testSshHandshakeAndAuth(sshConfig, timeoutMs);
|
|
591
|
+
lastHandshake = result.handshake;
|
|
592
|
+
lastAuth = result.auth;
|
|
593
|
+
if (lastAuth.ok) {
|
|
594
|
+
return {
|
|
595
|
+
target,
|
|
596
|
+
attempts,
|
|
597
|
+
tcp,
|
|
598
|
+
handshake: lastHandshake,
|
|
599
|
+
auth: lastAuth,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (!result.retryable || attempt === retries) {
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
const delayMs = Math.min(200 * (2 ** attempt), 2000);
|
|
606
|
+
await sleep(delayMs);
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
target,
|
|
610
|
+
attempts,
|
|
611
|
+
tcp,
|
|
612
|
+
handshake: lastHandshake,
|
|
613
|
+
auth: lastAuth,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { resolveKeyPath } from '../config/loader.js';
|
|
3
|
+
import { sanitizePassword } from './command-utils.js';
|
|
4
|
+
export async function buildSshConfigFromProfile(profile, configPath) {
|
|
5
|
+
const sshConfig = {
|
|
6
|
+
profileId: profile.id,
|
|
7
|
+
host: profile.host,
|
|
8
|
+
port: profile.port,
|
|
9
|
+
username: profile.user,
|
|
10
|
+
};
|
|
11
|
+
try {
|
|
12
|
+
if (profile.auth.type === 'password') {
|
|
13
|
+
sshConfig.password = sanitizePassword(profile.auth.password);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
const keyPath = resolveKeyPath(profile.auth.keyPath, configPath);
|
|
17
|
+
sshConfig.privateKey = await readFile(keyPath, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
if (profile.suPassword !== undefined) {
|
|
20
|
+
sshConfig.suPassword = sanitizePassword(profile.suPassword);
|
|
21
|
+
}
|
|
22
|
+
if (profile.sudoPassword !== undefined) {
|
|
23
|
+
sshConfig.sudoPassword = sanitizePassword(profile.sudoPassword);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
const message = err?.message ?? String(err);
|
|
28
|
+
throw new Error(`Failed to load SSH credentials for profile "${profile.id}" (${profile.host}:${profile.port}): ${message}`);
|
|
29
|
+
}
|
|
30
|
+
return sshConfig;
|
|
31
|
+
}
|
package/build/tools/exec.js
CHANGED
|
@@ -11,9 +11,16 @@ export function registerExecTool(server, deps) {
|
|
|
11
11
|
server.tool('exec', 'Execute a shell command on the remote SSH server and return the output.', {
|
|
12
12
|
command: z.string().describe('Shell command to execute on the remote SSH server'),
|
|
13
13
|
description: z.string().optional().describe('Optional description of what this command will do'),
|
|
14
|
-
|
|
14
|
+
timeoutMs: z.number()
|
|
15
|
+
.int()
|
|
16
|
+
.positive()
|
|
17
|
+
.max(60 * 60 * 1000)
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Optional per-command timeout override in milliseconds'),
|
|
20
|
+
}, async ({ command, description, timeoutMs }) => {
|
|
15
21
|
const runtime = deps.getRuntimeOptions();
|
|
16
22
|
const sanitizedCommand = sanitizeCommand(command, runtime.maxChars);
|
|
23
|
+
const effectiveTimeoutMs = timeoutMs ?? runtime.timeoutMs;
|
|
17
24
|
try {
|
|
18
25
|
const manager = await deps.getConnectionManager();
|
|
19
26
|
await manager.ensureConnected();
|
|
@@ -28,7 +35,7 @@ export function registerExecTool(server, deps) {
|
|
|
28
35
|
// Intentionally swallow and fall back to normal execution.
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
|
-
return await execSshCommandWithConnection(manager, appendDescription(sanitizedCommand, description),
|
|
38
|
+
return await execSshCommandWithConnection(manager, appendDescription(sanitizedCommand, description), effectiveTimeoutMs);
|
|
32
39
|
}
|
|
33
40
|
catch (err) {
|
|
34
41
|
if (err instanceof McpError)
|
package/build/tools/profiles.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { buildSshConfigFromProfile } from '../ssh/ssh-config.js';
|
|
4
|
+
import { testSshConnection } from '../ssh/connection-manager.js';
|
|
3
5
|
import { asTextResult } from './result.js';
|
|
4
6
|
function toMcpError(err) {
|
|
5
7
|
if (err instanceof McpError)
|
|
@@ -74,6 +76,36 @@ export function registerProfileTools(server, deps) {
|
|
|
74
76
|
throw toMcpError(err);
|
|
75
77
|
}
|
|
76
78
|
});
|
|
79
|
+
server.tool('profiles-test', 'Test TCP connectivity, SSH handshake, and authentication for a profile without executing commands.', {
|
|
80
|
+
profileId: z.string().min(1).optional().describe('Optional profile id; defaults to current active profile'),
|
|
81
|
+
timeoutMs: z.number()
|
|
82
|
+
.int()
|
|
83
|
+
.positive()
|
|
84
|
+
.max(60 * 1000)
|
|
85
|
+
.optional()
|
|
86
|
+
.describe('Optional timeout per attempt in milliseconds (default 10000)'),
|
|
87
|
+
retries: z.number()
|
|
88
|
+
.int()
|
|
89
|
+
.min(0)
|
|
90
|
+
.max(5)
|
|
91
|
+
.optional()
|
|
92
|
+
.describe('Retry count for handshake/network failures (default 2)'),
|
|
93
|
+
}, async ({ profileId, timeoutMs, retries }) => {
|
|
94
|
+
try {
|
|
95
|
+
const profile = profileId
|
|
96
|
+
? deps.profileManager.getProfileById(profileId)
|
|
97
|
+
: deps.profileManager.getActiveProfile();
|
|
98
|
+
const sshConfig = await buildSshConfigFromProfile(profile, deps.profileManager.getConfigPath());
|
|
99
|
+
const result = await testSshConnection(sshConfig, { timeoutMs, retries });
|
|
100
|
+
return asTextResult({
|
|
101
|
+
profileId: profile.id,
|
|
102
|
+
result,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
throw toMcpError(err);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
77
109
|
server.tool('profiles-create', 'Create a new SSH profile template at runtime. Note is optional but recommended; if omitted, a short note is generated from context.', {
|
|
78
110
|
id: z.string().min(1).describe('Unique profile id (stable key)'),
|
|
79
111
|
name: z.string().min(1).describe('Human-readable profile name'),
|
package/build/tools/sudo-exec.js
CHANGED
|
@@ -11,9 +11,16 @@ export function registerSudoExecTool(server, deps) {
|
|
|
11
11
|
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.', {
|
|
12
12
|
command: z.string().describe('Shell command to execute with sudo on the remote SSH server'),
|
|
13
13
|
description: z.string().optional().describe('Optional description of what this command will do'),
|
|
14
|
-
|
|
14
|
+
timeoutMs: z.number()
|
|
15
|
+
.int()
|
|
16
|
+
.positive()
|
|
17
|
+
.max(60 * 60 * 1000)
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Optional per-command timeout override in milliseconds'),
|
|
20
|
+
}, async ({ command, description, timeoutMs }) => {
|
|
15
21
|
const runtime = deps.getRuntimeOptions();
|
|
16
22
|
const sanitizedCommand = sanitizeCommand(command, runtime.maxChars);
|
|
23
|
+
const effectiveTimeoutMs = timeoutMs ?? runtime.timeoutMs;
|
|
17
24
|
try {
|
|
18
25
|
const manager = await deps.getConnectionManager();
|
|
19
26
|
await manager.ensureConnected();
|
|
@@ -27,7 +34,7 @@ export function registerSudoExecTool(server, deps) {
|
|
|
27
34
|
const escapedPwd = sudoPassword.replace(/'/g, "'\\''");
|
|
28
35
|
wrapped = `printf '%s\\n' '${escapedPwd}' | sudo -p "" -S sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
|
|
29
36
|
}
|
|
30
|
-
return await execSshCommandWithConnection(manager, wrapped,
|
|
37
|
+
return await execSshCommandWithConnection(manager, wrapped, effectiveTimeoutMs);
|
|
31
38
|
}
|
|
32
39
|
catch (err) {
|
|
33
40
|
if (err instanceof McpError)
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@xiaoyankonling/ssh-mcp",
|
|
3
|
-
"license": "MIT",
|
|
4
|
-
"version": "2.
|
|
2
|
+
"name": "@xiaoyankonling/ssh-mcp",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "2.1.0",
|
|
5
5
|
"description": "MCP server exposing SSH control for Linux and Windows systems via Model Context Protocol.",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"ssh-mcp": "build/index.js"
|
|
9
|
-
},
|
|
10
|
-
"publishConfig": {
|
|
11
|
-
"access": "public"
|
|
12
|
-
},
|
|
7
|
+
"bin": {
|
|
8
|
+
"ssh-mcp": "build/index.js"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"prepare": "npm run build",
|
|
15
15
|
"build": "tsc && shx chmod +x build/*.js",
|
|
@@ -38,13 +38,13 @@
|
|
|
38
38
|
"typescript": "^5.9.2",
|
|
39
39
|
"vitest": "^3.2.4"
|
|
40
40
|
},
|
|
41
|
-
"homepage": "https://github.com/Bianshumeng/ssh-mcp#readme",
|
|
42
|
-
"repository": {
|
|
43
|
-
"type": "git",
|
|
44
|
-
"url": "git+https://github.com/Bianshumeng/ssh-mcp.git"
|
|
45
|
-
},
|
|
41
|
+
"homepage": "https://github.com/Bianshumeng/ssh-mcp#readme",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/Bianshumeng/ssh-mcp.git"
|
|
45
|
+
},
|
|
46
46
|
"bugs": {
|
|
47
|
-
"url": "https://github.com/Bianshumeng/ssh-mcp/issues"
|
|
47
|
+
"url": "https://github.com/Bianshumeng/ssh-mcp/issues"
|
|
48
48
|
},
|
|
49
49
|
"keywords": [
|
|
50
50
|
"ssh",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"cli",
|
|
59
59
|
"typescript"
|
|
60
60
|
],
|
|
61
|
-
"author": "xiaoyankonling",
|
|
61
|
+
"author": "xiaoyankonling",
|
|
62
62
|
"engines": {
|
|
63
63
|
"node": ">=18"
|
|
64
64
|
}
|