ssh-mcp 1.4.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/README.md +488 -141
- package/build/audit/store.js +178 -0
- package/build/config/credential-resolver.js +107 -0
- package/build/config/loader.js +100 -0
- package/build/config/schema.js +53 -0
- package/build/guard/approval-grants.js +0 -0
- package/build/guard/elicitation.js +38 -0
- package/build/guard/redactor.js +67 -0
- package/build/guard/sanitizer.js +24 -0
- package/build/index.js +183 -618
- package/build/observability/tracer.js +27 -0
- package/build/policy/classifier.js +99 -0
- package/build/policy/engine.js +205 -0
- package/build/policy/quota.js +59 -0
- package/build/ssh/algorithms.js +33 -0
- package/build/ssh/channel-retry.js +15 -0
- package/build/ssh/connection-registry.js +102 -0
- package/build/ssh/connection.js +375 -0
- package/build/ssh/host-key.js +23 -0
- package/build/ssh/session-manager.js +164 -0
- package/build/ssh/session.js +313 -0
- package/build/ssh/sftp.js +130 -0
- package/build/tools/command-tools.js +82 -0
- package/build/tools/descriptions.js +21 -0
- package/build/tools/file-tools.js +36 -0
- package/build/tools/pipeline.js +185 -0
- package/build/tools/registry.js +23 -0
- package/build/tools/resources.js +73 -0
- package/build/tools/results.js +31 -0
- package/build/tools/session-tools.js +97 -0
- package/build/transport/http.js +237 -0
- package/build/types.js +2 -0
- package/build/version.js +9 -0
- package/package.json +28 -7
package/build/index.js
CHANGED
|
@@ -1,658 +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
|
-
|
|
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
|
|
14
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
}, async ({ command }) => {
|
|
314
|
-
// Sanitize command input
|
|
315
|
-
const sanitizedCommand = sanitizeCommand(command);
|
|
316
|
-
try {
|
|
317
|
-
// Initialize connection manager if not already done
|
|
318
|
-
if (!connectionManager) {
|
|
319
|
-
if (!HOST || !USER) {
|
|
320
|
-
throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
|
|
321
|
-
}
|
|
322
|
-
const sshConfig = {
|
|
323
|
-
host: HOST,
|
|
324
|
-
port: PORT,
|
|
325
|
-
username: USER,
|
|
326
|
-
};
|
|
327
|
-
if (PASSWORD) {
|
|
328
|
-
sshConfig.password = PASSWORD;
|
|
329
|
-
}
|
|
330
|
-
else if (KEY) {
|
|
331
|
-
const fs = await import('fs/promises');
|
|
332
|
-
sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
|
|
333
|
-
}
|
|
334
|
-
if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
|
|
335
|
-
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();
|
|
336
179
|
}
|
|
337
|
-
connectionManager = new SSHConnectionManager(sshConfig);
|
|
338
180
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
await Promise.race([
|
|
349
|
-
elevationPromise,
|
|
350
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('Elevation timeout')), 5000))
|
|
351
|
-
]);
|
|
352
|
-
}
|
|
353
|
-
catch (err) {
|
|
354
|
-
// Log but don't fail; fall back to non-elevated execution if elevation times out
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
const result = await execSshCommandWithConnection(connectionManager, sanitizedCommand);
|
|
358
|
-
return result;
|
|
359
|
-
}
|
|
360
|
-
catch (err) {
|
|
361
|
-
// Wrap unexpected errors
|
|
362
|
-
if (err instanceof McpError)
|
|
363
|
-
throw err;
|
|
364
|
-
throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
|
|
365
|
-
}
|
|
366
|
-
});
|
|
367
|
-
// Expose sudo-exec tool unless explicitly disabled
|
|
368
|
-
if (!DISABLE_SUDO) {
|
|
369
|
-
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.", {
|
|
370
|
-
command: z.string().describe("Shell command to execute with sudo on the remote SSH server"),
|
|
371
|
-
}, async ({ command }) => {
|
|
372
|
-
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);
|
|
373
190
|
try {
|
|
374
|
-
|
|
375
|
-
if (!HOST || !USER) {
|
|
376
|
-
throw new McpError(ErrorCode.InvalidParams, 'Missing required host or username');
|
|
377
|
-
}
|
|
378
|
-
const sshConfig = {
|
|
379
|
-
host: HOST,
|
|
380
|
-
port: PORT || 22,
|
|
381
|
-
username: USER,
|
|
382
|
-
};
|
|
383
|
-
if (PASSWORD) {
|
|
384
|
-
sshConfig.password = PASSWORD;
|
|
385
|
-
}
|
|
386
|
-
else if (KEY) {
|
|
387
|
-
const fs = await import('fs/promises');
|
|
388
|
-
sshConfig.privateKey = await fs.readFile(KEY, 'utf8');
|
|
389
|
-
}
|
|
390
|
-
if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
|
|
391
|
-
sshConfig.suPassword = sanitizePassword(SUPASSWORD);
|
|
392
|
-
}
|
|
393
|
-
if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
|
|
394
|
-
sshConfig.sudoPassword = sanitizePassword(SUDOPASSWORD);
|
|
395
|
-
}
|
|
396
|
-
connectionManager = new SSHConnectionManager(sshConfig);
|
|
397
|
-
}
|
|
398
|
-
await connectionManager.ensureConnected();
|
|
399
|
-
// If suPassword or sudoPassword were provided on this call but the
|
|
400
|
-
// existing connection manager was created earlier without them,
|
|
401
|
-
// update the manager's values so the subsequent sudo-exec call uses
|
|
402
|
-
// the latest passwords.
|
|
403
|
-
if (SUPASSWORD !== null && SUPASSWORD !== undefined) {
|
|
404
|
-
await connectionManager.setSuPassword(sanitizePassword(SUPASSWORD));
|
|
405
|
-
}
|
|
406
|
-
if (SUDOPASSWORD !== null && SUDOPASSWORD !== undefined) {
|
|
407
|
-
// update sudoPassword on the manager instance
|
|
408
|
-
connectionManager.sshConfig = { ...connectionManager.sshConfig, sudoPassword: sanitizePassword(SUDOPASSWORD) };
|
|
409
|
-
}
|
|
410
|
-
let wrapped;
|
|
411
|
-
const sudoPassword = connectionManager.getSudoPassword();
|
|
412
|
-
if (!sudoPassword) {
|
|
413
|
-
// No password provided, use -n to fail if sudo requires a password
|
|
414
|
-
wrapped = `sudo -n sh -c '${sanitizedCommand.replace(/'/g, "'\\''")}'`;
|
|
415
|
-
}
|
|
416
|
-
else {
|
|
417
|
-
// Password provided — pipe it into sudo using printf. This avoids complex
|
|
418
|
-
// PTY/stdin handling on the SSH channel and is simpler and more reliable.
|
|
419
|
-
const pwdEscaped = sudoPassword.replace(/'/g, "'\\''");
|
|
420
|
-
wrapped = `printf '%s\\n' '${pwdEscaped}' | sudo -p "" -S sh -c '${sanitizedCommand.replace(/'/g, "'\\''")}'`;
|
|
421
|
-
}
|
|
422
|
-
return await execSshCommandWithConnection(connectionManager, wrapped);
|
|
191
|
+
await audit.close();
|
|
423
192
|
}
|
|
424
|
-
catch (
|
|
425
|
-
|
|
426
|
-
throw err;
|
|
427
|
-
throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message || err}`);
|
|
193
|
+
catch (e) {
|
|
194
|
+
console.error('audit.close failed:', e);
|
|
428
195
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
// New function that uses persistent connection
|
|
432
|
-
export async function execSshCommandWithConnection(manager, command, stdin) {
|
|
433
|
-
return new Promise((resolve, reject) => {
|
|
434
|
-
let timeoutId;
|
|
435
|
-
let isResolved = false;
|
|
436
|
-
const conn = manager.getConnection();
|
|
437
|
-
const shell = manager.suShell; // Use su shell if available
|
|
438
|
-
// Set up timeout
|
|
439
|
-
timeoutId = setTimeout(() => {
|
|
440
|
-
if (!isResolved) {
|
|
441
|
-
isResolved = true;
|
|
442
|
-
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
|
|
443
|
-
}
|
|
444
|
-
}, DEFAULT_TIMEOUT);
|
|
445
|
-
// If we have an active su shell, use it directly (commands run as root in session)
|
|
446
|
-
if (shell) {
|
|
447
|
-
let buffer = '';
|
|
448
|
-
const dataHandler = (data) => {
|
|
449
|
-
const text = data.toString();
|
|
450
|
-
buffer += text;
|
|
451
|
-
// Wait for root prompt (#) to know command is complete
|
|
452
|
-
// Match # which indicates root prompt (may be followed by spaces, escape codes, etc)
|
|
453
|
-
if (/#/.test(buffer)) {
|
|
454
|
-
if (!isResolved) {
|
|
455
|
-
isResolved = true;
|
|
456
|
-
clearTimeout(timeoutId);
|
|
457
|
-
// Extract output: remove the command echo and final prompt
|
|
458
|
-
const lines = buffer.split('\n');
|
|
459
|
-
// First line is often the echoed command; last line is the prompt
|
|
460
|
-
let output = lines.slice(1, -1).join('\n');
|
|
461
|
-
resolve({
|
|
462
|
-
content: [{
|
|
463
|
-
type: 'text',
|
|
464
|
-
text: output + (output ? '\n' : ''),
|
|
465
|
-
}],
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
shell.removeListener('data', dataHandler);
|
|
469
|
-
}
|
|
470
|
-
};
|
|
471
|
-
shell.on('data', dataHandler);
|
|
472
|
-
// Send command immediately; shell is ready after elevation
|
|
473
|
-
shell.write(command + '\n');
|
|
474
|
-
return;
|
|
196
|
+
try {
|
|
197
|
+
await registry.closeAll();
|
|
475
198
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
if (err) {
|
|
479
|
-
if (!isResolved) {
|
|
480
|
-
isResolved = true;
|
|
481
|
-
clearTimeout(timeoutId);
|
|
482
|
-
reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
|
|
483
|
-
}
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
let stdout = '';
|
|
487
|
-
let stderr = '';
|
|
488
|
-
// If stdin provided (e.g., sudo password), write it
|
|
489
|
-
if (stdin && stdin.length > 0) {
|
|
490
|
-
try {
|
|
491
|
-
stream.write(stdin);
|
|
492
|
-
}
|
|
493
|
-
catch (e) {
|
|
494
|
-
console.error('Error writing to stdin:', e);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
try {
|
|
498
|
-
stream.end();
|
|
499
|
-
}
|
|
500
|
-
catch (e) { /* ignore */ }
|
|
501
|
-
stream.on('data', (data) => {
|
|
502
|
-
stdout += data.toString();
|
|
503
|
-
});
|
|
504
|
-
stream.stderr.on('data', (data) => {
|
|
505
|
-
stderr += data.toString();
|
|
506
|
-
});
|
|
507
|
-
stream.on('close', (code, signal) => {
|
|
508
|
-
if (!isResolved) {
|
|
509
|
-
isResolved = true;
|
|
510
|
-
clearTimeout(timeoutId);
|
|
511
|
-
if (stderr) {
|
|
512
|
-
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
|
|
513
|
-
}
|
|
514
|
-
else {
|
|
515
|
-
resolve({
|
|
516
|
-
content: [{
|
|
517
|
-
type: 'text',
|
|
518
|
-
text: stdout,
|
|
519
|
-
}],
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
});
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
// Keep the old function for backward compatibility (used in tests)
|
|
528
|
-
export async function execSshCommand(sshConfig, command, stdin) {
|
|
529
|
-
return new Promise((resolve, reject) => {
|
|
530
|
-
const conn = new Client();
|
|
531
|
-
let timeoutId;
|
|
532
|
-
let isResolved = false;
|
|
533
|
-
// Set up timeout
|
|
534
|
-
timeoutId = setTimeout(() => {
|
|
535
|
-
if (!isResolved) {
|
|
536
|
-
isResolved = true;
|
|
537
|
-
// Try to abort the running command before closing connection
|
|
538
|
-
const abortTimeout = setTimeout(() => {
|
|
539
|
-
// If abort command itself times out, force close connection
|
|
540
|
-
conn.end();
|
|
541
|
-
}, 5000); // 5 second timeout for abort command
|
|
542
|
-
conn.exec('timeout 3s pkill -f \'' + escapeCommandForShell(command) + '\' 2>/dev/null || true', (err, abortStream) => {
|
|
543
|
-
if (abortStream) {
|
|
544
|
-
abortStream.on('close', () => {
|
|
545
|
-
clearTimeout(abortTimeout);
|
|
546
|
-
conn.end();
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
else {
|
|
550
|
-
clearTimeout(abortTimeout);
|
|
551
|
-
conn.end();
|
|
552
|
-
}
|
|
553
|
-
});
|
|
554
|
-
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${DEFAULT_TIMEOUT}ms`));
|
|
555
|
-
}
|
|
556
|
-
}, DEFAULT_TIMEOUT);
|
|
557
|
-
conn.on('ready', () => {
|
|
558
|
-
conn.exec(command, (err, stream) => {
|
|
559
|
-
if (err) {
|
|
560
|
-
if (!isResolved) {
|
|
561
|
-
isResolved = true;
|
|
562
|
-
clearTimeout(timeoutId);
|
|
563
|
-
reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
|
|
564
|
-
}
|
|
565
|
-
conn.end();
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
// If stdin provided, write it to the stream and end stdin
|
|
569
|
-
if (stdin && stdin.length > 0) {
|
|
570
|
-
try {
|
|
571
|
-
stream.write(stdin);
|
|
572
|
-
}
|
|
573
|
-
catch (e) {
|
|
574
|
-
// ignore
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
try {
|
|
578
|
-
stream.end();
|
|
579
|
-
}
|
|
580
|
-
catch (e) { /* ignore */ }
|
|
581
|
-
let stdout = '';
|
|
582
|
-
let stderr = '';
|
|
583
|
-
stream.on('close', (code, signal) => {
|
|
584
|
-
if (!isResolved) {
|
|
585
|
-
isResolved = true;
|
|
586
|
-
clearTimeout(timeoutId);
|
|
587
|
-
conn.end();
|
|
588
|
-
if (stderr) {
|
|
589
|
-
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
|
|
590
|
-
}
|
|
591
|
-
else {
|
|
592
|
-
resolve({
|
|
593
|
-
content: [{
|
|
594
|
-
type: 'text',
|
|
595
|
-
text: stdout,
|
|
596
|
-
}],
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
});
|
|
601
|
-
stream.on('data', (data) => {
|
|
602
|
-
stdout += data.toString();
|
|
603
|
-
});
|
|
604
|
-
stream.stderr.on('data', (data) => {
|
|
605
|
-
stderr += data.toString();
|
|
606
|
-
});
|
|
607
|
-
});
|
|
608
|
-
});
|
|
609
|
-
conn.on('error', (err) => {
|
|
610
|
-
if (!isResolved) {
|
|
611
|
-
isResolved = true;
|
|
612
|
-
clearTimeout(timeoutId);
|
|
613
|
-
reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
|
|
614
|
-
}
|
|
615
|
-
});
|
|
616
|
-
conn.connect(sshConfig);
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
async function main() {
|
|
620
|
-
const transport = new StdioServerTransport();
|
|
621
|
-
await server.connect(transport);
|
|
622
|
-
console.error("SSH MCP Server running on stdio");
|
|
623
|
-
// Handle graceful shutdown
|
|
624
|
-
const cleanup = () => {
|
|
625
|
-
console.error("Shutting down SSH MCP Server...");
|
|
626
|
-
if (connectionManager) {
|
|
627
|
-
connectionManager.close();
|
|
628
|
-
connectionManager = null;
|
|
199
|
+
catch (e) {
|
|
200
|
+
console.error('closeAll failed:', e);
|
|
629
201
|
}
|
|
630
202
|
process.exit(0);
|
|
631
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
|
+
}
|
|
632
212
|
process.on('SIGINT', cleanup);
|
|
633
213
|
process.on('SIGTERM', cleanup);
|
|
634
|
-
process.on('exit', () => {
|
|
635
|
-
if (connectionManager) {
|
|
636
|
-
connectionManager.close();
|
|
637
|
-
}
|
|
638
|
-
});
|
|
639
214
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
server.connect(transport).catch(error => {
|
|
644
|
-
console.error("Fatal error connecting server:", error);
|
|
645
|
-
process.exit(1);
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
// Start server in CLI mode
|
|
649
|
-
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) {
|
|
650
218
|
main().catch((error) => {
|
|
651
|
-
console.error(
|
|
652
|
-
if (connectionManager) {
|
|
653
|
-
connectionManager.close();
|
|
654
|
-
}
|
|
219
|
+
console.error('Fatal error:', error);
|
|
655
220
|
process.exit(1);
|
|
656
221
|
});
|
|
657
222
|
}
|
|
658
|
-
export { parseArgv,
|
|
223
|
+
export { parseArgv, buildAppConfig, checkRemovedFlags, parseMaxChars };
|