@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 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
- - **Timeout Configuration:**
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. Safe deletion (two-step):
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, sanitizePassword, } from './ssh/command-utils.js';
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: '1.5.0',
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
- const sshConfig = {
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) {
@@ -125,6 +125,9 @@ export class ProfileManager {
125
125
  }
126
126
  return profile;
127
127
  }
128
+ getProfileById(profileId) {
129
+ return this.getProfile(profileId);
130
+ }
128
131
  getDefaults() {
129
132
  const loaded = this.ensureLoaded();
130
133
  return loaded.config.defaults ?? {};
@@ -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 = new Promise((resolve, reject) => {
24
- this.conn = new Client();
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
- this.conn?.end();
27
- this.conn = null;
28
- this.isConnecting = false;
29
- this.connectionPromise = null;
30
- reject(new McpError(ErrorCode.InternalError, 'SSH connection timeout'));
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
- this.conn.on('ready', async () => {
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
- this.conn.on('error', (err) => {
113
+ client.on('error', (err) => {
47
114
  clearTimeout(timeoutId);
48
- this.conn = null;
49
- this.isConnecting = false;
50
- this.connectionPromise = null;
51
- reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
115
+ if (this.conn === client) {
116
+ this.conn = null;
117
+ }
118
+ reject(err);
52
119
  });
53
- this.conn.on('end', () => {
54
- this.conn = null;
55
- this.isConnecting = false;
56
- this.connectionPromise = null;
120
+ client.on('end', () => {
121
+ if (this.conn === client) {
122
+ this.conn = null;
123
+ }
57
124
  });
58
- this.conn.on('close', () => {
59
- this.conn = null;
60
- this.isConnecting = false;
61
- this.connectionPromise = null;
125
+ client.on('close', () => {
126
+ if (this.conn === client) {
127
+ this.conn = null;
128
+ }
62
129
  });
63
- this.conn.connect(this.sshConfig);
130
+ client.connect(this.getConnectConfig());
64
131
  });
65
- return this.connectionPromise;
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, 'su elevation timed out'));
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, 'su authentication failed'));
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, 'su shell closed before elevation completed'));
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, 'SSH connection not established');
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
- conn.connect(sshConfig);
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
+ }
@@ -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
- }, async ({ command, description }) => {
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), runtime.timeoutMs);
38
+ return await execSshCommandWithConnection(manager, appendDescription(sanitizedCommand, description), effectiveTimeoutMs);
32
39
  }
33
40
  catch (err) {
34
41
  if (err instanceof McpError)
@@ -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'),
@@ -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
- }, async ({ command, description }) => {
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, runtime.timeoutMs);
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.0.0",
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
  }