@xiaoyankonling/ssh-mcp 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/LICENSE +21 -0
- package/README.md +256 -0
- package/build/cli/args.js +75 -0
- package/build/config/loader.js +138 -0
- package/build/config/types.js +75 -0
- package/build/index.js +145 -0
- package/build/profile/profile-manager.js +385 -0
- package/build/ssh/command-utils.js +49 -0
- package/build/ssh/connection-manager.js +371 -0
- package/build/tools/exec.js +39 -0
- package/build/tools/profiles.js +159 -0
- package/build/tools/result.js +8 -0
- package/build/tools/sudo-exec.js +38 -0
- package/package.json +65 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { Client } from 'ssh2';
|
|
3
|
+
import { DEFAULT_TIMEOUT_MS, escapeCommandForShell } from './command-utils.js';
|
|
4
|
+
export class SSHConnectionManager {
|
|
5
|
+
conn = null;
|
|
6
|
+
sshConfig;
|
|
7
|
+
isConnecting = false;
|
|
8
|
+
connectionPromise = null;
|
|
9
|
+
suShell = null;
|
|
10
|
+
suPromise = null;
|
|
11
|
+
isElevated = false;
|
|
12
|
+
constructor(config) {
|
|
13
|
+
this.sshConfig = config;
|
|
14
|
+
}
|
|
15
|
+
async connect() {
|
|
16
|
+
if (this.conn && this.isConnected()) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (this.isConnecting && this.connectionPromise) {
|
|
20
|
+
return this.connectionPromise;
|
|
21
|
+
}
|
|
22
|
+
this.isConnecting = true;
|
|
23
|
+
this.connectionPromise = new Promise((resolve, reject) => {
|
|
24
|
+
this.conn = new Client();
|
|
25
|
+
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'));
|
|
31
|
+
}, 30000);
|
|
32
|
+
this.conn.on('ready', async () => {
|
|
33
|
+
clearTimeout(timeoutId);
|
|
34
|
+
this.isConnecting = false;
|
|
35
|
+
this.connectionPromise = null;
|
|
36
|
+
if (this.sshConfig.suPassword && !process.env.SSH_MCP_TEST) {
|
|
37
|
+
try {
|
|
38
|
+
await this.ensureElevated();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Intentionally swallow: non-elevated fallback is still valid.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
resolve();
|
|
45
|
+
});
|
|
46
|
+
this.conn.on('error', (err) => {
|
|
47
|
+
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}`));
|
|
52
|
+
});
|
|
53
|
+
this.conn.on('end', () => {
|
|
54
|
+
this.conn = null;
|
|
55
|
+
this.isConnecting = false;
|
|
56
|
+
this.connectionPromise = null;
|
|
57
|
+
});
|
|
58
|
+
this.conn.on('close', () => {
|
|
59
|
+
this.conn = null;
|
|
60
|
+
this.isConnecting = false;
|
|
61
|
+
this.connectionPromise = null;
|
|
62
|
+
});
|
|
63
|
+
this.conn.connect(this.sshConfig);
|
|
64
|
+
});
|
|
65
|
+
return this.connectionPromise;
|
|
66
|
+
}
|
|
67
|
+
isConnected() {
|
|
68
|
+
return this.conn !== null && Boolean(this.conn._sock) && !this.conn._sock.destroyed;
|
|
69
|
+
}
|
|
70
|
+
getSudoPassword() {
|
|
71
|
+
return this.sshConfig.sudoPassword;
|
|
72
|
+
}
|
|
73
|
+
setSudoPassword(password) {
|
|
74
|
+
this.sshConfig.sudoPassword = password;
|
|
75
|
+
}
|
|
76
|
+
getSuPassword() {
|
|
77
|
+
return this.sshConfig.suPassword;
|
|
78
|
+
}
|
|
79
|
+
async setSuPassword(pwd) {
|
|
80
|
+
this.sshConfig.suPassword = pwd;
|
|
81
|
+
if (!pwd) {
|
|
82
|
+
if (this.suShell) {
|
|
83
|
+
try {
|
|
84
|
+
this.suShell.end();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// no-op
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.suShell = null;
|
|
91
|
+
this.isElevated = false;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await this.ensureElevated();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// no-op; command execution can fall back without elevation.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async ensureElevated() {
|
|
102
|
+
if (this.isElevated && this.suShell)
|
|
103
|
+
return;
|
|
104
|
+
if (!this.sshConfig.suPassword)
|
|
105
|
+
return;
|
|
106
|
+
if (this.suPromise)
|
|
107
|
+
return this.suPromise;
|
|
108
|
+
this.suPromise = new Promise((resolve, reject) => {
|
|
109
|
+
const conn = this.getConnection();
|
|
110
|
+
const timeoutId = setTimeout(() => {
|
|
111
|
+
this.suPromise = null;
|
|
112
|
+
reject(new McpError(ErrorCode.InternalError, 'su elevation timed out'));
|
|
113
|
+
}, 10000);
|
|
114
|
+
conn.shell({ term: 'xterm', cols: 80, rows: 24 }, (err, stream) => {
|
|
115
|
+
if (err) {
|
|
116
|
+
clearTimeout(timeoutId);
|
|
117
|
+
this.suPromise = null;
|
|
118
|
+
reject(new McpError(ErrorCode.InternalError, `Failed to start interactive shell for su: ${err.message}`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let buffer = '';
|
|
122
|
+
let passwordSent = false;
|
|
123
|
+
const cleanup = () => {
|
|
124
|
+
try {
|
|
125
|
+
stream.removeAllListeners('data');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// no-op
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const onData = (data) => {
|
|
132
|
+
const text = data.toString();
|
|
133
|
+
buffer += text;
|
|
134
|
+
if (!passwordSent && /password[: ]/i.test(buffer)) {
|
|
135
|
+
passwordSent = true;
|
|
136
|
+
stream.write(this.sshConfig.suPassword + '\n');
|
|
137
|
+
}
|
|
138
|
+
if (passwordSent && /#/.test(buffer)) {
|
|
139
|
+
clearTimeout(timeoutId);
|
|
140
|
+
cleanup();
|
|
141
|
+
this.suShell = stream;
|
|
142
|
+
this.isElevated = true;
|
|
143
|
+
this.suPromise = null;
|
|
144
|
+
resolve();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (/authentication failure|incorrect password|su: .*failed|su: failure/i.test(buffer)) {
|
|
148
|
+
clearTimeout(timeoutId);
|
|
149
|
+
cleanup();
|
|
150
|
+
this.suPromise = null;
|
|
151
|
+
reject(new McpError(ErrorCode.InternalError, 'su authentication failed'));
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
stream.on('data', onData);
|
|
155
|
+
stream.on('close', () => {
|
|
156
|
+
clearTimeout(timeoutId);
|
|
157
|
+
if (!this.isElevated) {
|
|
158
|
+
this.suPromise = null;
|
|
159
|
+
reject(new McpError(ErrorCode.InternalError, 'su shell closed before elevation completed'));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
stream.write('su -\n');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return this.suPromise;
|
|
166
|
+
}
|
|
167
|
+
async ensureConnected() {
|
|
168
|
+
if (!this.isConnected()) {
|
|
169
|
+
await this.connect();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
getConnection() {
|
|
173
|
+
if (!this.conn) {
|
|
174
|
+
throw new McpError(ErrorCode.InternalError, 'SSH connection not established');
|
|
175
|
+
}
|
|
176
|
+
return this.conn;
|
|
177
|
+
}
|
|
178
|
+
getSuShell() {
|
|
179
|
+
return this.suShell;
|
|
180
|
+
}
|
|
181
|
+
close() {
|
|
182
|
+
if (!this.conn)
|
|
183
|
+
return;
|
|
184
|
+
if (this.suShell) {
|
|
185
|
+
try {
|
|
186
|
+
this.suShell.end();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// no-op
|
|
190
|
+
}
|
|
191
|
+
this.suShell = null;
|
|
192
|
+
this.isElevated = false;
|
|
193
|
+
}
|
|
194
|
+
this.conn.end();
|
|
195
|
+
this.conn = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export async function execSshCommandWithConnection(manager, command, timeoutMs = DEFAULT_TIMEOUT_MS, stdin) {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
let timeoutId;
|
|
201
|
+
let isResolved = false;
|
|
202
|
+
const conn = manager.getConnection();
|
|
203
|
+
const shell = manager.getSuShell();
|
|
204
|
+
timeoutId = setTimeout(() => {
|
|
205
|
+
if (!isResolved) {
|
|
206
|
+
isResolved = true;
|
|
207
|
+
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${timeoutMs}ms`));
|
|
208
|
+
}
|
|
209
|
+
}, timeoutMs);
|
|
210
|
+
if (shell) {
|
|
211
|
+
let buffer = '';
|
|
212
|
+
const dataHandler = (data) => {
|
|
213
|
+
const text = data.toString();
|
|
214
|
+
buffer += text;
|
|
215
|
+
if (!/#/.test(buffer))
|
|
216
|
+
return;
|
|
217
|
+
if (!isResolved) {
|
|
218
|
+
isResolved = true;
|
|
219
|
+
clearTimeout(timeoutId);
|
|
220
|
+
const lines = buffer.split('\n');
|
|
221
|
+
const output = lines.slice(1, -1).join('\n');
|
|
222
|
+
resolve({
|
|
223
|
+
content: [{
|
|
224
|
+
type: 'text',
|
|
225
|
+
text: output + (output ? '\n' : ''),
|
|
226
|
+
}],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
shell.removeListener('data', dataHandler);
|
|
230
|
+
};
|
|
231
|
+
shell.on('data', dataHandler);
|
|
232
|
+
shell.write(command + '\n');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
conn.exec(command, (err, stream) => {
|
|
236
|
+
if (err) {
|
|
237
|
+
if (!isResolved) {
|
|
238
|
+
isResolved = true;
|
|
239
|
+
clearTimeout(timeoutId);
|
|
240
|
+
reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
let stdout = '';
|
|
245
|
+
let stderr = '';
|
|
246
|
+
if (stdin && stdin.length > 0) {
|
|
247
|
+
try {
|
|
248
|
+
stream.write(stdin);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// no-op
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
stream.end();
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// no-op
|
|
259
|
+
}
|
|
260
|
+
stream.on('data', (data) => {
|
|
261
|
+
stdout += data.toString();
|
|
262
|
+
});
|
|
263
|
+
stream.stderr.on('data', (data) => {
|
|
264
|
+
stderr += data.toString();
|
|
265
|
+
});
|
|
266
|
+
stream.on('close', (code) => {
|
|
267
|
+
if (isResolved)
|
|
268
|
+
return;
|
|
269
|
+
isResolved = true;
|
|
270
|
+
clearTimeout(timeoutId);
|
|
271
|
+
if (stderr) {
|
|
272
|
+
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
resolve({
|
|
276
|
+
content: [{
|
|
277
|
+
type: 'text',
|
|
278
|
+
text: stdout,
|
|
279
|
+
}],
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
export async function execSshCommand(sshConfig, command, stdin, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
286
|
+
return new Promise((resolve, reject) => {
|
|
287
|
+
const conn = new Client();
|
|
288
|
+
let timeoutId;
|
|
289
|
+
let isResolved = false;
|
|
290
|
+
timeoutId = setTimeout(() => {
|
|
291
|
+
if (isResolved)
|
|
292
|
+
return;
|
|
293
|
+
isResolved = true;
|
|
294
|
+
const abortTimeout = setTimeout(() => {
|
|
295
|
+
conn.end();
|
|
296
|
+
}, 5000);
|
|
297
|
+
conn.exec(`timeout 3s pkill -f '${escapeCommandForShell(command)}' 2>/dev/null || true`, (_err, abortStream) => {
|
|
298
|
+
if (abortStream) {
|
|
299
|
+
abortStream.on('close', () => {
|
|
300
|
+
clearTimeout(abortTimeout);
|
|
301
|
+
conn.end();
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
clearTimeout(abortTimeout);
|
|
306
|
+
conn.end();
|
|
307
|
+
});
|
|
308
|
+
reject(new McpError(ErrorCode.InternalError, `Command execution timed out after ${timeoutMs}ms`));
|
|
309
|
+
}, timeoutMs);
|
|
310
|
+
conn.on('ready', () => {
|
|
311
|
+
conn.exec(command, (err, stream) => {
|
|
312
|
+
if (err) {
|
|
313
|
+
if (!isResolved) {
|
|
314
|
+
isResolved = true;
|
|
315
|
+
clearTimeout(timeoutId);
|
|
316
|
+
reject(new McpError(ErrorCode.InternalError, `SSH exec error: ${err.message}`));
|
|
317
|
+
}
|
|
318
|
+
conn.end();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (stdin && stdin.length > 0) {
|
|
322
|
+
try {
|
|
323
|
+
stream.write(stdin);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// no-op
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
stream.end();
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// no-op
|
|
334
|
+
}
|
|
335
|
+
let stdout = '';
|
|
336
|
+
let stderr = '';
|
|
337
|
+
stream.on('close', (code) => {
|
|
338
|
+
if (isResolved)
|
|
339
|
+
return;
|
|
340
|
+
isResolved = true;
|
|
341
|
+
clearTimeout(timeoutId);
|
|
342
|
+
conn.end();
|
|
343
|
+
if (stderr) {
|
|
344
|
+
reject(new McpError(ErrorCode.InternalError, `Error (code ${code}):\n${stderr}`));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
resolve({
|
|
348
|
+
content: [{
|
|
349
|
+
type: 'text',
|
|
350
|
+
text: stdout,
|
|
351
|
+
}],
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
stream.on('data', (data) => {
|
|
355
|
+
stdout += data.toString();
|
|
356
|
+
});
|
|
357
|
+
stream.stderr.on('data', (data) => {
|
|
358
|
+
stderr += data.toString();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
conn.on('error', (err) => {
|
|
363
|
+
if (isResolved)
|
|
364
|
+
return;
|
|
365
|
+
isResolved = true;
|
|
366
|
+
clearTimeout(timeoutId);
|
|
367
|
+
reject(new McpError(ErrorCode.InternalError, `SSH connection error: ${err.message}`));
|
|
368
|
+
});
|
|
369
|
+
conn.connect(sshConfig);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { execSshCommandWithConnection, } from '../ssh/connection-manager.js';
|
|
4
|
+
import { sanitizeCommand } from '../ssh/command-utils.js';
|
|
5
|
+
function appendDescription(command, description) {
|
|
6
|
+
if (!description)
|
|
7
|
+
return command;
|
|
8
|
+
return `${command} # ${description.replace(/#/g, '\\#')}`;
|
|
9
|
+
}
|
|
10
|
+
export function registerExecTool(server, deps) {
|
|
11
|
+
server.tool('exec', 'Execute a shell command on the remote SSH server and return the output.', {
|
|
12
|
+
command: z.string().describe('Shell command to execute on the remote SSH server'),
|
|
13
|
+
description: z.string().optional().describe('Optional description of what this command will do'),
|
|
14
|
+
}, async ({ command, description }) => {
|
|
15
|
+
const runtime = deps.getRuntimeOptions();
|
|
16
|
+
const sanitizedCommand = sanitizeCommand(command, runtime.maxChars);
|
|
17
|
+
try {
|
|
18
|
+
const manager = await deps.getConnectionManager();
|
|
19
|
+
await manager.ensureConnected();
|
|
20
|
+
if (manager.getSuPassword()) {
|
|
21
|
+
try {
|
|
22
|
+
await Promise.race([
|
|
23
|
+
manager.ensureElevated(),
|
|
24
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Elevation timeout')), 5000)),
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Intentionally swallow and fall back to normal execution.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return await execSshCommandWithConnection(manager, appendDescription(sanitizedCommand, description), runtime.timeoutMs);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (err instanceof McpError)
|
|
35
|
+
throw err;
|
|
36
|
+
throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message ?? err}`);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { asTextResult } from './result.js';
|
|
4
|
+
function toMcpError(err) {
|
|
5
|
+
if (err instanceof McpError)
|
|
6
|
+
return err;
|
|
7
|
+
const message = err?.message ?? String(err);
|
|
8
|
+
return new McpError(ErrorCode.InvalidParams, message);
|
|
9
|
+
}
|
|
10
|
+
export function registerProfileTools(server, deps) {
|
|
11
|
+
server.tool('profiles-list', 'List available SSH profiles with summary metadata. Use this first to identify target profile by id/host/note/tags.', {}, async () => {
|
|
12
|
+
try {
|
|
13
|
+
return asTextResult({
|
|
14
|
+
configPath: deps.profileManager.getConfigPath(),
|
|
15
|
+
activeProfile: deps.profileManager.getActiveProfileId(),
|
|
16
|
+
profiles: deps.profileManager.listProfiles(),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
throw toMcpError(err);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
server.tool('profiles-use', 'Switch active SSH profile for subsequent command execution. The selected profile is persisted as activeProfile in config.', {
|
|
24
|
+
profileId: z.string().min(1).describe('Profile id to activate'),
|
|
25
|
+
}, async ({ profileId }) => {
|
|
26
|
+
try {
|
|
27
|
+
const profile = await deps.profileManager.setActiveProfile(profileId, true);
|
|
28
|
+
await deps.onTargetChanged();
|
|
29
|
+
return asTextResult({
|
|
30
|
+
activeProfile: deps.profileManager.getActiveProfileId(),
|
|
31
|
+
profile,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
throw toMcpError(err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
server.tool('profiles-reload', 'Reload profile configuration from local file and re-validate active profile.', {}, async () => {
|
|
39
|
+
try {
|
|
40
|
+
const result = await deps.profileManager.reload();
|
|
41
|
+
await deps.onTargetChanged();
|
|
42
|
+
return asTextResult(result);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw toMcpError(err);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
server.tool('profiles-note-update', 'Update note field for a profile and persist it to local config file. Keep note short and precise so future matching stays accurate.', {
|
|
49
|
+
profileId: z.string().min(1).describe('Profile id to update'),
|
|
50
|
+
note: z.string().describe('New concise note text (recommended <= 120 chars)'),
|
|
51
|
+
}, async ({ profileId, note }) => {
|
|
52
|
+
try {
|
|
53
|
+
const profile = await deps.profileManager.updateNote(profileId, note);
|
|
54
|
+
return asTextResult({
|
|
55
|
+
updatedProfileId: profileId,
|
|
56
|
+
profile,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
throw toMcpError(err);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
server.tool('profiles-find', 'Find profile candidates by keyword across id/name/host/user/note/tags. Use this to precisely locate the correct SSH target before switching or deleting.', {
|
|
64
|
+
query: z.string().describe('Search keyword, such as host fragment, role tag, or note keyword'),
|
|
65
|
+
}, async ({ query }) => {
|
|
66
|
+
try {
|
|
67
|
+
return asTextResult({
|
|
68
|
+
query,
|
|
69
|
+
activeProfile: deps.profileManager.getActiveProfileId(),
|
|
70
|
+
matches: deps.profileManager.findProfiles(query),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
throw toMcpError(err);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
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
|
+
id: z.string().min(1).describe('Unique profile id (stable key)'),
|
|
79
|
+
name: z.string().min(1).describe('Human-readable profile name'),
|
|
80
|
+
host: z.string().min(1).describe('SSH host or IP'),
|
|
81
|
+
port: z.number().int().min(1).max(65535).optional().describe('SSH port, default 22'),
|
|
82
|
+
user: z.string().min(1).describe('SSH username'),
|
|
83
|
+
authType: z.enum(['password', 'key']).describe('Authentication method'),
|
|
84
|
+
password: z.string().optional().describe('Required when authType=password; plain value or ${ENV_VAR}'),
|
|
85
|
+
keyPath: z.string().optional().describe('Required when authType=key; absolute or config-relative path'),
|
|
86
|
+
suPassword: z.string().optional().describe('Optional su password'),
|
|
87
|
+
sudoPassword: z.string().optional().describe('Optional sudo password'),
|
|
88
|
+
note: z.string().optional().describe('Optional concise note; recommended to describe role/purpose'),
|
|
89
|
+
contextSummary: z.string().optional().describe('Optional context used to auto-generate note when note is omitted'),
|
|
90
|
+
tags: z.array(z.string()).optional().describe('Optional tags for later matching'),
|
|
91
|
+
activate: z.boolean().optional().describe('Whether to activate this profile immediately, default true'),
|
|
92
|
+
}, async (input) => {
|
|
93
|
+
try {
|
|
94
|
+
let auth;
|
|
95
|
+
if (input.authType === 'password') {
|
|
96
|
+
if (!input.password || input.password.trim().length === 0) {
|
|
97
|
+
throw new Error('password is required when authType=password');
|
|
98
|
+
}
|
|
99
|
+
auth = { type: 'password', password: input.password };
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
if (!input.keyPath || input.keyPath.trim().length === 0) {
|
|
103
|
+
throw new Error('keyPath is required when authType=key');
|
|
104
|
+
}
|
|
105
|
+
auth = { type: 'key', keyPath: input.keyPath };
|
|
106
|
+
}
|
|
107
|
+
const profile = await deps.profileManager.createProfile({
|
|
108
|
+
id: input.id,
|
|
109
|
+
name: input.name,
|
|
110
|
+
host: input.host,
|
|
111
|
+
port: input.port,
|
|
112
|
+
user: input.user,
|
|
113
|
+
auth,
|
|
114
|
+
suPassword: input.suPassword,
|
|
115
|
+
sudoPassword: input.sudoPassword,
|
|
116
|
+
note: input.note,
|
|
117
|
+
contextSummary: input.contextSummary,
|
|
118
|
+
tags: input.tags,
|
|
119
|
+
activate: input.activate,
|
|
120
|
+
});
|
|
121
|
+
if (input.activate ?? true) {
|
|
122
|
+
await deps.onTargetChanged();
|
|
123
|
+
}
|
|
124
|
+
return asTextResult({
|
|
125
|
+
createdProfileId: input.id,
|
|
126
|
+
activeProfile: deps.profileManager.getActiveProfileId(),
|
|
127
|
+
profile,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
throw toMcpError(err);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
server.tool('profiles-delete-prepare', 'Prepare deletion for a profile. This step creates a backup and returns a deleteRequestId + confirmationText. Always show result to the user and ask explicit confirmation before calling profiles-delete-confirm.', {
|
|
135
|
+
profileId: z.string().min(1).describe('Exact profile id to delete'),
|
|
136
|
+
}, async ({ profileId }) => {
|
|
137
|
+
try {
|
|
138
|
+
const result = await deps.profileManager.prepareDeleteProfile(profileId);
|
|
139
|
+
return asTextResult(result);
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
throw toMcpError(err);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
server.tool('profiles-delete-confirm', 'Execute profile deletion after explicit user confirmation. Requires deleteRequestId and exact confirmationText from profiles-delete-prepare response.', {
|
|
146
|
+
deleteRequestId: z.string().min(1).describe('Delete request id returned by profiles-delete-prepare'),
|
|
147
|
+
profileId: z.string().min(1).describe('Exact profile id to delete'),
|
|
148
|
+
confirmationText: z.string().min(1).describe('Must exactly match the confirmationText returned by prepare step'),
|
|
149
|
+
}, async ({ deleteRequestId, profileId, confirmationText }) => {
|
|
150
|
+
try {
|
|
151
|
+
const result = await deps.profileManager.confirmDeleteProfile(deleteRequestId, profileId, confirmationText);
|
|
152
|
+
await deps.onTargetChanged();
|
|
153
|
+
return asTextResult(result);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
throw toMcpError(err);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { execSshCommandWithConnection, } from '../ssh/connection-manager.js';
|
|
4
|
+
import { sanitizeCommand } from '../ssh/command-utils.js';
|
|
5
|
+
function appendDescription(command, description) {
|
|
6
|
+
if (!description)
|
|
7
|
+
return command;
|
|
8
|
+
return `${command} # ${description.replace(/#/g, '\\#')}`;
|
|
9
|
+
}
|
|
10
|
+
export function registerSudoExecTool(server, deps) {
|
|
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
|
+
command: z.string().describe('Shell command to execute with sudo on the remote SSH server'),
|
|
13
|
+
description: z.string().optional().describe('Optional description of what this command will do'),
|
|
14
|
+
}, async ({ command, description }) => {
|
|
15
|
+
const runtime = deps.getRuntimeOptions();
|
|
16
|
+
const sanitizedCommand = sanitizeCommand(command, runtime.maxChars);
|
|
17
|
+
try {
|
|
18
|
+
const manager = await deps.getConnectionManager();
|
|
19
|
+
await manager.ensureConnected();
|
|
20
|
+
const commandWithDescription = appendDescription(sanitizedCommand, description);
|
|
21
|
+
const sudoPassword = manager.getSudoPassword();
|
|
22
|
+
let wrapped;
|
|
23
|
+
if (!sudoPassword) {
|
|
24
|
+
wrapped = `sudo -n sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const escapedPwd = sudoPassword.replace(/'/g, "'\\''");
|
|
28
|
+
wrapped = `printf '%s\\n' '${escapedPwd}' | sudo -p "" -S sh -c '${commandWithDescription.replace(/'/g, "'\\''")}'`;
|
|
29
|
+
}
|
|
30
|
+
return await execSshCommandWithConnection(manager, wrapped, runtime.timeoutMs);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (err instanceof McpError)
|
|
34
|
+
throw err;
|
|
35
|
+
throw new McpError(ErrorCode.InternalError, `Unexpected error: ${err?.message ?? err}`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xiaoyankonling/ssh-mcp",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
"description": "MCP server exposing SSH control for Linux and Windows systems via Model Context Protocol.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ssh-mcp": "build/index.js"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"prepare": "npm run build",
|
|
15
|
+
"build": "tsc && shx chmod +x build/*.js",
|
|
16
|
+
"inspect": "npx @modelcontextprotocol/inspector node build/index.js",
|
|
17
|
+
"test": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run",
|
|
18
|
+
"test:watch": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest",
|
|
19
|
+
"coverage": "cross-env SSH_MCP_DISABLE_MAIN=1 vitest --run --coverage"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"build"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.17.5",
|
|
26
|
+
"ssh2": "^1.17.0",
|
|
27
|
+
"yaml": "^2.8.2",
|
|
28
|
+
"zod": "3.23.8"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^24.5.2",
|
|
32
|
+
"@types/ssh2": "^1.15.5",
|
|
33
|
+
"cross-env": "^7.0.3",
|
|
34
|
+
"shx": "^0.4.0",
|
|
35
|
+
"testcontainers": "^11.7.0",
|
|
36
|
+
"ts-node": "^10.9.2",
|
|
37
|
+
"tsx": "^4.20.6",
|
|
38
|
+
"typescript": "^5.9.2",
|
|
39
|
+
"vitest": "^3.2.4"
|
|
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
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/Bianshumeng/ssh-mcp/issues"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"ssh",
|
|
51
|
+
"mcp",
|
|
52
|
+
"model-context-protocol",
|
|
53
|
+
"server",
|
|
54
|
+
"windows",
|
|
55
|
+
"linux",
|
|
56
|
+
"automation",
|
|
57
|
+
"remote",
|
|
58
|
+
"cli",
|
|
59
|
+
"typescript"
|
|
60
|
+
],
|
|
61
|
+
"author": "xiaoyankonling",
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=18"
|
|
64
|
+
}
|
|
65
|
+
}
|