ssh-mcp 1.5.0 → 2.0.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/build/index.js CHANGED
@@ -1,668 +1,223 @@
1
1
  #!/usr/bin/env node
2
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
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- // Example usage: node build/index.js --host=1.2.3.4 --port=22 --user=root --password=pass --key=path/to/key --timeout=5000 --disableSudo
4
+ import { loadConfig } from './config/loader.js';
5
+ import { ConnectionRegistry } from './ssh/connection-registry.js';
6
+ import { PolicyEngine, DEFAULT_RULES } from './policy/engine.js';
7
+ import { AuditStore } from './audit/store.js';
8
+ import { registerTools, registerResources, getToolHashes } from './tools/registry.js';
9
+ import { initKeychain } from './config/credential-resolver.js';
10
+ import { SERVER_VERSION } from './version.js';
8
11
  function parseArgv() {
9
12
  const args = process.argv.slice(2);
10
13
  const config = {};
11
14
  for (const arg of args) {
12
15
  if (arg.startsWith('--')) {
13
- const equalIndex = arg.indexOf('=');
14
- if (equalIndex === -1) {
15
- // Flag without value
16
+ const eq = arg.indexOf('=');
17
+ if (eq === -1) {
16
18
  config[arg.slice(2)] = null;
17
19
  }
18
20
  else {
19
- // Key=value pair
20
- config[arg.slice(2, equalIndex)] = arg.slice(equalIndex + 1);
21
+ config[arg.slice(2, eq)] = arg.slice(eq + 1);
21
22
  }
22
23
  }
23
24
  }
24
25
  return config;
25
26
  }
26
- const isTestMode = process.env.SSH_MCP_TEST === '1';
27
- const isCliEnabled = process.env.SSH_MCP_DISABLE_MAIN !== '1';
28
- const argvConfig = (isCliEnabled || isTestMode) ? parseArgv() : {};
29
- const HOST = argvConfig.host;
30
- const PORT = argvConfig.port ? parseInt(argvConfig.port) : 22;
31
- const USER = argvConfig.user;
32
- const PASSWORD = argvConfig.password;
33
- const SUPASSWORD = argvConfig.suPassword;
34
- const SUDOPASSWORD = argvConfig.sudoPassword;
35
- const DISABLE_SUDO = argvConfig.disableSudo !== undefined;
36
- const KEY = argvConfig.key;
37
- const DEFAULT_TIMEOUT = argvConfig.timeout ? parseInt(argvConfig.timeout) : 60000; // 60 seconds default timeout
38
- // Max characters configuration:
39
- // - Default: 1000 characters
40
- // - When set via --maxChars:
41
- // * a positive integer enforces that limit
42
- // * 0 or a negative value disables the limit (no max)
43
- // * the string "none" (case-insensitive) disables the limit (no max)
44
- const MAX_CHARS_RAW = argvConfig.maxChars;
45
- const MAX_CHARS = (() => {
46
- if (typeof MAX_CHARS_RAW === 'string') {
47
- const lowered = MAX_CHARS_RAW.toLowerCase();
48
- if (lowered === 'none')
49
- return Infinity;
50
- const parsed = parseInt(MAX_CHARS_RAW);
51
- if (isNaN(parsed))
52
- return 1000;
53
- if (parsed <= 0)
54
- return Infinity;
55
- return parsed;
56
- }
57
- return 1000;
58
- })();
59
- function validateConfig(config) {
60
- const errors = [];
61
- if (!config.host)
62
- errors.push('Missing required --host');
63
- if (!config.user)
64
- errors.push('Missing required --user');
65
- if (config.port && isNaN(Number(config.port)))
66
- errors.push('Invalid --port');
67
- if (errors.length > 0) {
68
- throw new Error('Configuration error:\n' + errors.join('\n'));
69
- }
70
- }
71
- if (isCliEnabled) {
72
- validateConfig(argvConfig);
27
+ /**
28
+ * v1 flags that v2 removed. Silently ignoring them means the user only finds
29
+ * out at the first command, as an auth failure with no hint about the cause.
30
+ */
31
+ const REMOVED_V1_FLAGS = {
32
+ password: 'Use the SSH_MCP_PASSWORD env var (or a per-profile SSH_MCP_<PROFILE>_PASSWORD).',
33
+ suPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
34
+ sudoPassword: 'Use the SSH_MCP_SUDO_PASSWORD env var.',
35
+ disableSudo: 'Sudo is now a separate tool; restrict it with a role/policy that disallows the "privileged" class.',
36
+ };
37
+ function checkRemovedFlags(argv) {
38
+ const found = Object.keys(REMOVED_V1_FLAGS).filter((f) => f in argv);
39
+ if (found.length === 0)
40
+ return;
41
+ throw new Error('These flags were removed in v2:\n' +
42
+ found.map((f) => ` --${f}: ${REMOVED_V1_FLAGS[f]}`).join('\n') +
43
+ '\nSee the "Migrating from v1" section of the README.');
73
44
  }
74
- // Command sanitization and validation
75
- export function sanitizeCommand(command) {
76
- if (typeof command !== 'string') {
77
- throw new McpError(ErrorCode.InvalidParams, 'Command must be a string');
78
- }
79
- const trimmedCommand = command.trim();
80
- if (!trimmedCommand) {
81
- throw new McpError(ErrorCode.InvalidParams, 'Command cannot be empty');
82
- }
83
- // Length check
84
- if (Number.isFinite(MAX_CHARS) && trimmedCommand.length > MAX_CHARS) {
85
- throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${MAX_CHARS} characters)`);
86
- }
87
- return trimmedCommand;
45
+ /**
46
+ * v1 documented `--maxChars=none` (and 0/negative) as "no limit". Parsing it
47
+ * with `parseInt(...) || 5000` silently turned that into a 5000-char cap.
48
+ */
49
+ function parseMaxChars(raw) {
50
+ if (typeof raw !== 'string' || raw === '')
51
+ return 5_000;
52
+ if (raw.toLowerCase() === 'none')
53
+ return Number.MAX_SAFE_INTEGER;
54
+ const parsed = parseInt(raw);
55
+ if (isNaN(parsed))
56
+ return 5_000;
57
+ return parsed <= 0 ? Number.MAX_SAFE_INTEGER : parsed;
88
58
  }
89
- function sanitizePassword(password) {
90
- if (typeof password !== 'string')
91
- return undefined;
92
- // minimal check, do not log or modify content
93
- if (password.length === 0)
94
- return undefined;
95
- return password;
59
+ /**
60
+ * `strict` was previously unreachable: nothing but test code could select it,
61
+ * yet host-key.ts pointed users at a `--acceptNewHostKey` flag that never
62
+ * existed. `--insecureHostKey` is kept as an alias for the documented flag.
63
+ */
64
+ function resolveHostKeyMode(argv) {
65
+ if (argv.insecureHostKey !== undefined)
66
+ return 'insecure';
67
+ const mode = argv.hostKeyMode;
68
+ if (mode === null || mode === undefined)
69
+ return 'tofu';
70
+ if (mode === 'tofu' || mode === 'strict' || mode === 'insecure')
71
+ return mode;
72
+ throw new Error(`Invalid --hostKeyMode=${mode}. Expected one of: tofu, strict, insecure.`);
96
73
  }
97
- // Escape command for use in shell contexts (like pkill)
98
- export function escapeCommandForShell(command) {
99
- // Replace single quotes with escaped single quotes
100
- return command.replace(/'/g, "'\"'\"'");
101
- }
102
- export class SSHConnectionManager {
103
- conn = null;
104
- sshConfig;
105
- isConnecting = false;
106
- connectionPromise = null;
107
- suShell = null; // Store the elevated shell session
108
- suPromise = null;
109
- isElevated = false; // Track if we're in su mode
110
- constructor(config) {
111
- this.sshConfig = config;
74
+ async function buildAppConfig(argv) {
75
+ if (argv.config) {
76
+ return loadConfig(argv.config);
112
77
  }
113
- async connect() {
114
- if (this.conn && this.isConnected()) {
115
- return; // Already connected
116
- }
117
- if (this.isConnecting && this.connectionPromise) {
118
- return this.connectionPromise; // Wait for ongoing connection
119
- }
120
- this.isConnecting = true;
121
- this.connectionPromise = new Promise((resolve, reject) => {
122
- this.conn = new Client();
123
- const timeoutId = setTimeout(() => {
124
- this.conn?.end();
125
- this.conn = null;
126
- this.isConnecting = false;
127
- this.connectionPromise = null;
128
- reject(new McpError(ErrorCode.InternalError, 'SSH connection timeout'));
129
- }, 30000); // 30 seconds connection timeout
130
- this.conn.on('ready', async () => {
131
- clearTimeout(timeoutId);
132
- this.isConnecting = false;
133
- // In test mode, don't wait for su elevation during connection setup, as it
134
- // may cause JSON-RPC server initialization to hang. Instead, elevation will
135
- // be triggered on-demand when a command is executed.
136
- // In production, elevation during connection is desirable for robustness.
137
- if (this.sshConfig.suPassword && !process.env.SSH_MCP_TEST) {
138
- try {
139
- await this.ensureElevated();
140
- }
141
- catch (err) {
142
- // Do not reject the connection; just log the error. Subsequent commands
143
- // will either use the su shell if available or fall back to normal execution.
144
- }
145
- }
146
- resolve();
147
- });
148
- this.conn.on('error', (err) => {
149
- clearTimeout(timeoutId);
150
- this.conn = null;
151
- this.isConnecting = false;
152
- this.connectionPromise = null;
153
- reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
154
- });
155
- this.conn.on('end', () => {
156
- console.error('SSH connection ended');
157
- this.conn = null;
158
- this.isConnecting = false;
159
- this.connectionPromise = null;
160
- });
161
- this.conn.on('close', () => {
162
- console.error('SSH connection closed');
163
- this.conn = null;
164
- this.isConnecting = false;
165
- this.connectionPromise = null;
166
- });
167
- this.conn.connect(this.sshConfig);
78
+ try {
79
+ return await loadConfig();
80
+ }
81
+ catch {
82
+ // No config file — fall through to CLI args
83
+ }
84
+ if (!argv.host || !argv.user) {
85
+ throw new Error('No config file found and missing required --host/--user.\n' +
86
+ 'Either create a config file at ~/.config/ssh-mcp/config.toml or pass --config <path>.\n' +
87
+ 'For quick start: --host=<host> --user=<user> (credentials via env vars).');
88
+ }
89
+ const defaults = {
90
+ sessionMaxPerConnection: parseInt(argv.sessionMax) || 5,
91
+ sessionIdleTimeoutMs: parseInt(argv.sessionTtl) || 600_000,
92
+ sessionBackgroundMaxMs: 3_600_000,
93
+ commandTimeoutMs: parseInt(argv.timeout) || 60_000,
94
+ commandMaxChars: parseMaxChars(argv.maxChars),
95
+ commandMaxOutputBytes: 1_048_576,
96
+ connectionIdleReapMs: 900_000,
97
+ commandQuotaPerDay: parseInt(argv.commandQuota) || 0,
98
+ approvalGrantTtlMs: parseInt(argv.approvalGrantTtl) || 0,
99
+ approvalMode: 'ask-destructive',
100
+ };
101
+ const profile = {
102
+ name: 'default',
103
+ host: argv.host,
104
+ port: parseInt(argv.port) || 22,
105
+ user: argv.user,
106
+ auth: argv.key ? 'key' : 'password',
107
+ keyRef: argv.key || undefined,
108
+ workdir: argv.workdir || undefined,
109
+ tty: false,
110
+ timeout: defaults.commandTimeoutMs,
111
+ maxChars: defaults.commandMaxChars,
112
+ maxOutputBytes: defaults.commandMaxOutputBytes,
113
+ role: 'admin',
114
+ readOnly: false,
115
+ approvalPolicy: argv.disableApproval ? 'auto' : defaults.approvalMode,
116
+ cert: false,
117
+ sessionMaxPerConnection: defaults.sessionMaxPerConnection,
118
+ sessionIdleTimeoutMs: defaults.sessionIdleTimeoutMs,
119
+ sessionBackgroundMaxMs: defaults.sessionBackgroundMaxMs,
120
+ commandQuotaPerDay: defaults.commandQuotaPerDay,
121
+ };
122
+ return { defaults, profiles: [profile] };
123
+ }
124
+ async function main() {
125
+ const argv = parseArgv();
126
+ checkRemovedFlags(argv);
127
+ if (argv.dumpToolHashes !== undefined) {
128
+ console.log(JSON.stringify(getToolHashes(), null, 2));
129
+ return;
130
+ }
131
+ const config = await buildAppConfig(argv);
132
+ const hostKeyMode = resolveHostKeyMode(argv);
133
+ const entropyScan = !!argv.auditEntropyScan;
134
+ const tamperEvident = !!argv.auditTamperEvident;
135
+ await initKeychain();
136
+ if (argv.otelEndpoint) {
137
+ const { initTracing } = await import('./observability/tracer.js');
138
+ await initTracing(argv.otelEndpoint, argv.otelServiceName || 'ssh-mcp');
139
+ }
140
+ const registry = new ConnectionRegistry(config, hostKeyMode);
141
+ const policy = new PolicyEngine(DEFAULT_RULES);
142
+ const audit = new AuditStore(undefined, entropyScan, tamperEvident);
143
+ if (argv.opaUrl) {
144
+ policy.setOpaUrl(argv.opaUrl);
145
+ console.error(`OPA sidecar enabled: ${argv.opaUrl}`);
146
+ }
147
+ // An McpServer binds to a single transport, so HTTP needs one per session.
148
+ const createMcpServer = () => {
149
+ // capabilities moved from serverInfo to ServerOptions in SDK 1.30.
150
+ const server = new McpServer({ name: 'SSH MCP Server', version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
151
+ registerTools(server, registry, policy, audit, {
152
+ approvalGrantTtlMs: config.defaults.approvalGrantTtlMs,
168
153
  });
169
- return this.connectionPromise;
170
- }
171
- isConnected() {
172
- return this.conn !== null && this.conn._sock && !this.conn._sock.destroyed;
173
- }
174
- getSudoPassword() {
175
- return this.sshConfig.sudoPassword;
176
- }
177
- getSuPassword() {
178
- return this.sshConfig.suPassword;
179
- }
180
- async setSuPassword(pwd) {
181
- this.sshConfig.suPassword = pwd;
182
- if (pwd) {
183
- try {
184
- await this.ensureElevated();
185
- }
186
- catch (err) {
187
- console.error('setSuPassword: failed to elevate to su shell:', err);
188
- }
189
- }
190
- else {
191
- // If clearing suPassword, drop any existing suShell
192
- if (this.suShell) {
193
- try {
194
- this.suShell.end();
195
- }
196
- catch (e) { /* ignore */ }
197
- this.suShell = null;
198
- this.isElevated = false;
199
- }
200
- }
201
- }
202
- async ensureElevated() {
203
- if (this.isElevated && this.suShell)
204
- return;
205
- if (!this.sshConfig.suPassword)
206
- return;
207
- if (this.suPromise)
208
- return this.suPromise;
209
- this.suPromise = new Promise((resolve, reject) => {
210
- const conn = this.getConnection();
211
- // Add a safety timeout so elevation doesn't hang forever
212
- const timeoutId = setTimeout(() => {
213
- this.suPromise = null;
214
- reject(new McpError(ErrorCode.InternalError, 'su elevation timed out'));
215
- }, 10000); // 10 second timeout for elevation
216
- conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => {
217
- if (err) {
218
- clearTimeout(timeoutId);
219
- this.suPromise = null;
220
- reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su: ${err.message}`));
221
- return;
222
- }
223
- let buffer = '';
224
- let passwordSent = false;
225
- const cleanup = () => {
226
- try {
227
- stream.removeAllListeners('data');
228
- }
229
- catch (e) { /* ignore */ }
230
- };
231
- const onData = (data) => {
232
- const text = data.toString();
233
- buffer += text;
234
- // If we haven't sent the password yet, look for the password prompt
235
- if (!passwordSent && /password[: ]/i.test(buffer)) {
236
- passwordSent = true;
237
- stream.write(this.sshConfig.suPassword + '\n');
238
- // Don't return; keep looking for root prompt
239
- }
240
- // After password is sent, look for any root indicator
241
- // Look for '#' which indicates root prompt (may be followed by spaces, escape codes, etc)
242
- if (passwordSent) {
243
- if (/#/.test(buffer)) {
244
- clearTimeout(timeoutId);
245
- cleanup();
246
- this.suShell = stream;
247
- this.isElevated = true;
248
- this.suPromise = null;
249
- resolve();
250
- return;
251
- }
252
- }
253
- // Detect authentication failure messages
254
- if (/authentication failure|incorrect password|su: .*failed|su: failure/i.test(buffer)) {
255
- clearTimeout(timeoutId);
256
- cleanup();
257
- this.suPromise = null;
258
- reject(new McpError(ErrorCode.InternalError, `su authentication failed: ${buffer}`));
259
- return;
260
- }
261
- };
262
- stream.on('data', onData);
263
- stream.on('close', () => {
264
- clearTimeout(timeoutId);
265
- if (!this.isElevated) {
266
- this.suPromise = null;
267
- reject(new McpError(ErrorCode.InternalError, 'su shell closed before elevation completed'));
268
- }
269
- });
270
- // Kick off the su command
271
- stream.write('su -\n');
272
- });
154
+ registerResources(server, registry);
155
+ return server;
156
+ };
157
+ const transportMode = argv.transport || 'stdio';
158
+ if (transportMode === 'http') {
159
+ const { startHttpServer } = await import('./transport/http.js');
160
+ await startHttpServer(createMcpServer, {
161
+ port: parseInt(argv.httpPort) || 3000,
162
+ host: argv.httpHost || '127.0.0.1',
163
+ bearerToken: argv.bearerToken,
164
+ rateLimit: parseInt(argv.rateLimit) || 0,
165
+ allowedHosts: argv.allowedHosts?.split(',').map((h) => h.trim()).filter(Boolean),
166
+ registry,
273
167
  });
274
- return this.suPromise;
275
- }
276
- async ensureConnected() {
277
- if (!this.isConnected()) {
278
- await this.connect();
279
- }
280
- }
281
- getConnection() {
282
- if (!this.conn) {
283
- throw new McpError(ErrorCode.InternalError, 'SSH connection not established');
284
- }
285
- return this.conn;
286
168
  }
287
- close() {
288
- if (this.conn) {
289
- if (this.suShell) {
290
- try {
291
- this.suShell.end();
292
- }
293
- catch (e) { /* ignore */ }
294
- this.suShell = null;
295
- this.isElevated = false;
296
- }
297
- this.conn.end();
298
- this.conn = null;
299
- }
169
+ else {
170
+ const transport = new StdioServerTransport();
171
+ await createMcpServer().connect(transport);
172
+ console.error('SSH MCP Server v2 running on stdio');
300
173
  }
301
- }
302
- let connectionManager = null;
303
- const server = new McpServer({
304
- name: 'SSH MCP Server',
305
- version: '1.5.0',
306
- capabilities: {
307
- resources: {},
308
- tools: {},
309
- },
310
- });
311
- server.tool("exec", "Execute a shell command on the remote SSH server and return the output.", {
312
- command: z.string().describe("Shell command to execute on the remote SSH server"),
313
- description: z.string().optional().describe("Optional description of what this command will do"),
314
- }, async ({ command, description }) => {
315
- // Sanitize command input
316
- const sanitizedCommand = sanitizeCommand(command);
317
- try {
318
- // Initialize connection manager if not already done
319
- if (!connectionManager) {
320
- if (!HOST || !USER) {
321
- throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
322
- }
323
- const sshConfig = {
324
- host: HOST,
325
- port: PORT,
326
- username: USER,
327
- };
328
- if (PASSWORD) {
329
- sshConfig.password = PASSWORD;
330
- }
331
- else if (KEY) {
332
- const fs = await import('fs/promises');
333
- sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
334
- }
335
- if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
336
- sshConfig.suPassword = sanitizePassword(SUPASSWORD);
174
+ const reaperInterval = setInterval(() => {
175
+ for (const info of registry.listConnections()) {
176
+ const conn = registry.get(info.profile);
177
+ if (conn) {
178
+ conn.reapExpiredSessions();
337
179
  }
338
- connectionManager = new SSHConnectionManager(sshConfig);
339
180
  }
340
- // Ensure connection is active (reconnect if needed)
341
- await connectionManager.ensureConnected();
342
- // If a suPassword was provided, explicitly wait for elevation before executing.
343
- // This is critical: ensureElevated is idempotent and will return immediately if
344
- // already elevated, so this ensures we have a su shell before we try to use it.
345
- if (connectionManager.getSuPassword && connectionManager.getSuPassword()) {
346
- try {
347
- const elevationPromise = connectionManager.ensureElevated();
348
- // Add a short timeout for elevation to complete
349
- await Promise.race([
350
- elevationPromise,
351
- new Promise((_, reject) => setTimeout(() => reject(new Error('Elevation timeout')), 5000))
352
- ]);
353
- }
354
- catch (err) {
355
- // Log but don't fail; fall back to non-elevated execution if elevation times out
356
- }
357
- }
358
- // Append description as comment if provided
359
- const commandWithDescription = description
360
- ? `${sanitizedCommand} # ${description.replace(/#/g, '\\#')}`
361
- : sanitizedCommand;
362
- const result = await execSshCommandWithConnection(connectionManager, commandWithDescription);
363
- return result;
364
- }
365
- catch (err) {
366
- // Wrap unexpected errors
367
- if (err instanceof McpError)
368
- throw err;
369
- throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
370
- }
371
- });
372
- // Expose sudo-exec tool unless explicitly disabled
373
- if (!DISABLE_SUDO) {
374
- 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.", {
375
- command: z.string().describe("Shell command to execute with sudo on the remote SSH server"),
376
- description: z.string().optional().describe("Optional description of what this command will do"),
377
- }, async ({ command, description }) => {
378
- const sanitizedCommand = sanitizeCommand(command);
181
+ registry.reapIdleConnections();
182
+ }, 60_000);
183
+ let isShuttingDown = false;
184
+ const cleanup = async () => {
185
+ if (isShuttingDown)
186
+ return;
187
+ isShuttingDown = true;
188
+ console.error('Shutting down SSH MCP Server...');
189
+ clearInterval(reaperInterval);
379
190
  try {
380
- if (!connectionManager) {
381
- if (!HOST || !USER) {
382
- throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
383
- }
384
- const sshConfig = {
385
- host: HOST,
386
- port: PORT || 22,
387
- username: USER,
388
- };
389
- if (PASSWORD) {
390
- sshConfig.password = PASSWORD;
391
- }
392
- else if (KEY) {
393
- const fs = await import('fs/promises');
394
- sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
395
- }
396
- if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
397
- sshConfig.suPassword = sanitizePassword(SUPASSWORD);
398
- }
399
- if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
400
- sshConfig.sudoPassword = sanitizePassword(SUDOPASSWORD);
401
- }
402
- connectionManager = new SSHConnectionManager(sshConfig);
403
- }
404
- await connectionManager.ensureConnected();
405
- // If suPassword or sudoPassword were provided on this call but the
406
- // existing connection manager was created earlier without them,
407
- // update the manager's values so the subsequent sudo-exec call uses
408
- // the latest passwords.
409
- if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
410
- await connectionManager.setSuPassword(sanitizePassword(SUPASSWORD));
411
- }
412
- if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
413
- // update sudoPassword on the manager instance
414
- connectionManager.sshConfig = { ...connectionManager.sshConfig, sudoPassword: sanitizePassword(SUDOPASSWORD) };
415
- }
416
- let wrapped;
417
- const sudoPassword = connectionManager.getSudoPassword();
418
- // Append description as comment if provided
419
- const commandWithDescription = description
420
- ? `${sanitizedCommand} # ${description.replace(/#/g, '\\#')}`
421
- : sanitizedCommand;
422
- if (!sudoPassword) {
423
- // No password provided, use -n to fail if sudo requires a password
424
- wrapped = `sudo -n sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
425
- }
426
- else {
427
- // Password provided — pipe it into sudo using printf. This avoids complex
428
- // PTY/stdin handling on the SSH channel and is simpler and more reliable.
429
- const pwdEscaped = sudoPassword.replace(/'/g, "'\\''");
430
- wrapped = `printf '%s\\n' '${pwdEscaped}' | sudo -p "" -S sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
431
- }
432
- return await execSshCommandWithConnection(connectionManager, wrapped);
191
+ await audit.close();
433
192
  }
434
- catch (err) {
435
- if (err instanceof McpError)
436
- throw err;
437
- throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
193
+ catch (e) {
194
+ console.error('audit.close failed:', e);
438
195
  }
439
- });
440
- }
441
- // New function that uses persistent connection
442
- export async function execSshCommandWithConnection(manager, command, stdin) {
443
- return new Promise((resolve, reject) => {
444
- let timeoutId;
445
- let isResolved = false;
446
- const conn = manager.getConnection();
447
- const shell = manager.suShell; // Use su shell if available
448
- // Set up timeout
449
- timeoutId = setTimeout(() => {
450
- if (!isResolved) {
451
- isResolved = true;
452
- reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
453
- }
454
- }, DEFAULT_TIMEOUT);
455
- // If we have an active su shell, use it directly (commands run as root in session)
456
- if (shell) {
457
- let buffer = '';
458
- const dataHandler = (data) => {
459
- const text = data.toString();
460
- buffer += text;
461
- // Wait for root prompt (#) to know command is complete
462
- // Match # which indicates root prompt (may be followed by spaces, escape codes, etc)
463
- if (/#/.test(buffer)) {
464
- if (!isResolved) {
465
- isResolved = true;
466
- clearTimeout(timeoutId);
467
- // Extract output: remove the command echo and final prompt
468
- const lines = buffer.split('\n');
469
- // First line is often the echoed command; last line is the prompt
470
- let output = lines.slice(1, -1).join('\n');
471
- resolve({
472
- content: [{
473
- type: 'text',
474
- text: output + (output ? '\n' : ''),
475
- }],
476
- });
477
- }
478
- shell.removeListener('data', dataHandler);
479
- }
480
- };
481
- shell.on('data', dataHandler);
482
- // Send command immediately; shell is ready after elevation
483
- shell.write(command + '\n');
484
- return;
196
+ try {
197
+ await registry.closeAll();
485
198
  }
486
- // No persistent su shell; use normal exec with optional password piping
487
- conn.exec(command, (err, stream) => {
488
- if (err) {
489
- if (!isResolved) {
490
- isResolved = true;
491
- clearTimeout(timeoutId);
492
- reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
493
- }
494
- return;
495
- }
496
- let stdout = '';
497
- let stderr = '';
498
- // If stdin provided (e.g., sudo password), write it
499
- if (stdin && stdin.length > 0) {
500
- try {
501
- stream.write(stdin);
502
- }
503
- catch (e) {
504
- console.error('Error writing to stdin:', e);
505
- }
506
- }
507
- try {
508
- stream.end();
509
- }
510
- catch (e) { /* ignore */ }
511
- stream.on('data', (data) => {
512
- stdout += data.toString();
513
- });
514
- stream.stderr.on('data', (data) => {
515
- stderr += data.toString();
516
- });
517
- stream.on('close', (code, signal) => {
518
- if (!isResolved) {
519
- isResolved = true;
520
- clearTimeout(timeoutId);
521
- if (stderr) {
522
- reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
523
- }
524
- else {
525
- resolve({
526
- content: [{
527
- type: 'text',
528
- text: stdout,
529
- }],
530
- });
531
- }
532
- }
533
- });
534
- });
535
- });
536
- }
537
- // Keep the old function for backward compatibility (used in tests)
538
- export async function execSshCommand(sshConfig, command, stdin) {
539
- return new Promise((resolve, reject) => {
540
- const conn = new Client();
541
- let timeoutId;
542
- let isResolved = false;
543
- // Set up timeout
544
- timeoutId = setTimeout(() => {
545
- if (!isResolved) {
546
- isResolved = true;
547
- // Try to abort the running command before closing connection
548
- const abortTimeout = setTimeout(() => {
549
- // If abort command itself times out, force close connection
550
- conn.end();
551
- }, 5000); // 5 second timeout for abort command
552
- conn.exec('timeout 3s pkill -f \'' + escapeCommandForShell(command) + '\' 2>/dev/null || true', (err, abortStream) => {
553
- if (abortStream) {
554
- abortStream.on('close', () => {
555
- clearTimeout(abortTimeout);
556
- conn.end();
557
- });
558
- }
559
- else {
560
- clearTimeout(abortTimeout);
561
- conn.end();
562
- }
563
- });
564
- reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
565
- }
566
- }, DEFAULT_TIMEOUT);
567
- conn.on('ready', () => {
568
- conn.exec(command, (err, stream) => {
569
- if (err) {
570
- if (!isResolved) {
571
- isResolved = true;
572
- clearTimeout(timeoutId);
573
- reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
574
- }
575
- conn.end();
576
- return;
577
- }
578
- // If stdin provided, write it to the stream and end stdin
579
- if (stdin && stdin.length > 0) {
580
- try {
581
- stream.write(stdin);
582
- }
583
- catch (e) {
584
- // ignore
585
- }
586
- }
587
- try {
588
- stream.end();
589
- }
590
- catch (e) { /* ignore */ }
591
- let stdout = '';
592
- let stderr = '';
593
- stream.on('close', (code, signal) => {
594
- if (!isResolved) {
595
- isResolved = true;
596
- clearTimeout(timeoutId);
597
- conn.end();
598
- if (stderr) {
599
- reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
600
- }
601
- else {
602
- resolve({
603
- content: [{
604
- type: 'text',
605
- text: stdout,
606
- }],
607
- });
608
- }
609
- }
610
- });
611
- stream.on('data', (data) => {
612
- stdout += data.toString();
613
- });
614
- stream.stderr.on('data', (data) => {
615
- stderr += data.toString();
616
- });
617
- });
618
- });
619
- conn.on('error', (err) => {
620
- if (!isResolved) {
621
- isResolved = true;
622
- clearTimeout(timeoutId);
623
- reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
624
- }
625
- });
626
- conn.connect(sshConfig);
627
- });
628
- }
629
- async function main() {
630
- const transport = new StdioServerTransport();
631
- await server.connect(transport);
632
- console.error("SSH MCP Server running on stdio");
633
- // Handle graceful shutdown
634
- const cleanup = () => {
635
- console.error("Shutting down SSH MCP Server...");
636
- if (connectionManager) {
637
- connectionManager.close();
638
- connectionManager = null;
199
+ catch (e) {
200
+ console.error('closeAll failed:', e);
639
201
  }
640
202
  process.exit(0);
641
203
  };
204
+ if (transportMode !== 'http') {
205
+ // The SDK's stdio transport listens only for 'data' and 'error', so a
206
+ // client that exits without signalling leaves this process running with
207
+ // its SSH connections open. Treat EOF on stdin as the client going away.
208
+ const onStdinClosed = () => { void cleanup(); };
209
+ process.stdin.on('end', onStdinClosed);
210
+ process.stdin.on('close', onStdinClosed);
211
+ }
642
212
  process.on('SIGINT', cleanup);
643
213
  process.on('SIGTERM', cleanup);
644
- process.on('exit', () => {
645
- if (connectionManager) {
646
- connectionManager.close();
647
- }
648
- });
649
214
  }
650
- // Initialize server in test mode for automated tests
651
- if (isTestMode) {
652
- const transport = new StdioServerTransport();
653
- server.connect(transport).catch(error => {
654
- console.error("Fatal error connecting server:", error);
655
- process.exit(1);
656
- });
657
- }
658
- // Start server in CLI mode
659
- else if (isCliEnabled) {
215
+ const isTestMode = process.env.SSH_MCP_TEST === '1';
216
+ const isCliEnabled = process.env.SSH_MCP_DISABLE_MAIN !== '1';
217
+ if (isCliEnabled || isTestMode) {
660
218
  main().catch((error) => {
661
- console.error("Fatal error in main():", error);
662
- if (connectionManager) {
663
- connectionManager.close();
664
- }
219
+ console.error('Fatal error:', error);
665
220
  process.exit(1);
666
221
  });
667
222
  }
668
- export { parseArgv, validateConfig };
223
+ export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars };