mcp-ssh-server-tool 1.0.7 → 2.0.1
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/index.js +144 -477
- package/package.json +5 -4
- package/ssh_manager.js +62 -99
package/index.js
CHANGED
|
@@ -1,350 +1,100 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
|
|
5
|
-
class SSHSession {
|
|
6
|
-
constructor(sessionId, client, host, username) {
|
|
7
|
-
this.sessionId = sessionId;
|
|
8
|
-
this.client = client;
|
|
9
|
-
this.host = host;
|
|
10
|
-
this.username = username;
|
|
11
|
-
this.connected = true;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
class SSHConnectionManager {
|
|
16
|
-
constructor() {
|
|
17
|
-
this.sessions = new Map();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
connect(options) {
|
|
21
|
-
return new Promise((resolve) => {
|
|
22
|
-
const client = new Client();
|
|
23
|
-
const sessionId = uuidv4();
|
|
24
|
-
const port = options.port || 22;
|
|
25
|
-
const timeout = (options.timeout || 10) * 1000;
|
|
26
|
-
|
|
27
|
-
const config = {
|
|
28
|
-
host: options.host,
|
|
29
|
-
port: port,
|
|
30
|
-
username: options.username,
|
|
31
|
-
timeout: timeout,
|
|
32
|
-
readyTimeout: timeout,
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
if (options.authType === 'password') {
|
|
36
|
-
if (!options.password) {
|
|
37
|
-
resolve({
|
|
38
|
-
success: false,
|
|
39
|
-
error: 'Password is required for password authentication',
|
|
40
|
-
errorType: 'MissingPassword',
|
|
41
|
-
});
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
config.password = options.password;
|
|
45
|
-
} else if (options.authType === 'public_key') {
|
|
46
|
-
if (!options.privateKeyPath) {
|
|
47
|
-
resolve({
|
|
48
|
-
success: false,
|
|
49
|
-
error: 'Private key path is required for public key authentication',
|
|
50
|
-
errorType: 'MissingPrivateKey',
|
|
51
|
-
});
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
config.privateKey = fs.readFileSync(options.privateKeyPath);
|
|
56
|
-
if (options.passphrase) {
|
|
57
|
-
config.passphrase = options.passphrase;
|
|
58
|
-
}
|
|
59
|
-
} catch (err) {
|
|
60
|
-
resolve({
|
|
61
|
-
success: false,
|
|
62
|
-
error: `Failed to read private key: ${err.message}`,
|
|
63
|
-
errorType: 'PrivateKeyError',
|
|
64
|
-
});
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
client.on('ready', () => {
|
|
70
|
-
const session = new SSHSession(sessionId, client, options.host, options.username);
|
|
71
|
-
this.sessions.set(sessionId, session);
|
|
72
|
-
|
|
73
|
-
resolve({
|
|
74
|
-
success: true,
|
|
75
|
-
sessionId,
|
|
76
|
-
host: options.host,
|
|
77
|
-
port,
|
|
78
|
-
username: options.username,
|
|
79
|
-
message: `Successfully connected to ${options.host}:${port}`,
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
client.on('error', (err) => {
|
|
84
|
-
resolve({
|
|
85
|
-
success: false,
|
|
86
|
-
error: err.message,
|
|
87
|
-
errorType: 'ConnectionError',
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
client.connect(config);
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
execCommand(sessionId, command) {
|
|
96
|
-
const session = this.sessions.get(sessionId);
|
|
97
|
-
|
|
98
|
-
if (!session) {
|
|
99
|
-
return {
|
|
100
|
-
success: false,
|
|
101
|
-
error: `Session ${sessionId} not found`,
|
|
102
|
-
errorType: 'SessionNotFound',
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!session.connected) {
|
|
107
|
-
return {
|
|
108
|
-
success: false,
|
|
109
|
-
error: `Session ${sessionId} is not connected`,
|
|
110
|
-
errorType: 'SessionNotConnected',
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return new Promise((resolve) => {
|
|
115
|
-
session.client.exec(command, (err, stream) => {
|
|
116
|
-
if (err) {
|
|
117
|
-
resolve({
|
|
118
|
-
success: false,
|
|
119
|
-
error: err.message,
|
|
120
|
-
errorType: 'ExecError',
|
|
121
|
-
sessionId,
|
|
122
|
-
});
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
let stdout = '';
|
|
127
|
-
let stderr = '';
|
|
128
|
-
|
|
129
|
-
stream.on('close', (code) => {
|
|
130
|
-
resolve({
|
|
131
|
-
success: true,
|
|
132
|
-
sessionId,
|
|
133
|
-
command,
|
|
134
|
-
stdout,
|
|
135
|
-
stderr,
|
|
136
|
-
exitCode: code,
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
stream.on('data', (data) => {
|
|
141
|
-
stdout += data.toString();
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
stream.stderr.on('data', (data) => {
|
|
145
|
-
stderr += data.toString();
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
disconnect(sessionId) {
|
|
152
|
-
const session = this.sessions.get(sessionId);
|
|
153
|
-
|
|
154
|
-
if (!session) {
|
|
155
|
-
return {
|
|
156
|
-
success: false,
|
|
157
|
-
error: `Session ${sessionId} not found`,
|
|
158
|
-
errorType: 'SessionNotFound',
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
session.client.end();
|
|
164
|
-
session.connected = false;
|
|
165
|
-
this.sessions.delete(sessionId);
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
success: true,
|
|
169
|
-
sessionId,
|
|
170
|
-
message: 'Disconnected successfully',
|
|
171
|
-
};
|
|
172
|
-
} catch (err) {
|
|
173
|
-
return {
|
|
174
|
-
success: false,
|
|
175
|
-
error: err.message,
|
|
176
|
-
errorType: 'DisconnectError',
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
listSessions() {
|
|
182
|
-
const sessions = [];
|
|
183
|
-
|
|
184
|
-
this.sessions.forEach((session) => {
|
|
185
|
-
sessions.push({
|
|
186
|
-
sessionId: session.sessionId,
|
|
187
|
-
host: session.host,
|
|
188
|
-
username: session.username,
|
|
189
|
-
connected: session.connected,
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
success: true,
|
|
195
|
-
sessions,
|
|
196
|
-
count: sessions.length,
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
}
|
|
1
|
+
import { SSHConnectionManager } from './ssh_manager.js';
|
|
200
2
|
|
|
201
3
|
const sshManager = new SSHConnectionManager();
|
|
202
4
|
|
|
203
5
|
const tools = [
|
|
204
6
|
{
|
|
205
7
|
name: 'ssh_connect',
|
|
206
|
-
description: '
|
|
8
|
+
description: '建立 SSH 连接(仅在需要连续执行多个命令时使用,单次执行请使用 ssh_exec)',
|
|
207
9
|
inputSchema: {
|
|
208
10
|
type: 'object',
|
|
209
11
|
properties: {
|
|
210
|
-
host: {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
},
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
},
|
|
219
|
-
username: {
|
|
220
|
-
type: 'string',
|
|
221
|
-
description: 'SSH username',
|
|
222
|
-
},
|
|
223
|
-
auth_type: {
|
|
224
|
-
type: 'string',
|
|
225
|
-
enum: ['password', 'public_key'],
|
|
226
|
-
description: 'Authentication type',
|
|
227
|
-
},
|
|
228
|
-
password: {
|
|
229
|
-
type: 'string',
|
|
230
|
-
description: 'Password (required if auth_type is password)',
|
|
231
|
-
},
|
|
232
|
-
private_key_path: {
|
|
233
|
-
type: 'string',
|
|
234
|
-
description: 'Path to private key file (required if auth_type is public_key)',
|
|
235
|
-
},
|
|
236
|
-
passphrase: {
|
|
237
|
-
type: 'string',
|
|
238
|
-
description: 'Passphrase for private key (optional)',
|
|
239
|
-
},
|
|
240
|
-
timeout: {
|
|
241
|
-
type: 'number',
|
|
242
|
-
description: 'Connection timeout in seconds',
|
|
243
|
-
default: 10,
|
|
244
|
-
},
|
|
12
|
+
host: { type: 'string', description: '服务器地址或 IP' },
|
|
13
|
+
port: { type: 'number', description: 'SSH 端口(默认: 22)', default: 22 },
|
|
14
|
+
username: { type: 'string', description: 'SSH 用户名' },
|
|
15
|
+
password: { type: 'string', description: '密码(密码认证时必填)' },
|
|
16
|
+
auth_type: { type: 'string', enum: ['password', 'public_key'], description: '认证方式' },
|
|
17
|
+
private_key_path: { type: 'string', description: '私钥路径(公钥认证时使用,二选一)' },
|
|
18
|
+
private_key_content: { type: 'string', description: '私钥文本内容(公钥认证时使用,二选一)' },
|
|
19
|
+
passphrase: { type: 'string', description: '私钥密码(可选)' },
|
|
20
|
+
timeout: { type: 'number', description: '超时时间(秒,默认: 10)', default: 10 },
|
|
245
21
|
},
|
|
246
|
-
required: ['host', 'username'
|
|
22
|
+
required: ['host', 'username'],
|
|
247
23
|
},
|
|
248
24
|
},
|
|
249
25
|
{
|
|
250
26
|
name: 'ssh_exec',
|
|
251
|
-
description: '
|
|
27
|
+
description: '【推荐】执行 SSH 命令。单次执行直接传入 host/username/password 会自动连接、执行、断开。多次连续执行时才需要先用 ssh_connect 获取 session_id。',
|
|
252
28
|
inputSchema: {
|
|
253
29
|
type: 'object',
|
|
254
30
|
properties: {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
},
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
},
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
},
|
|
267
|
-
port: {
|
|
268
|
-
type: 'number',
|
|
269
|
-
description: 'SSH port (default: 22)',
|
|
270
|
-
default: 22,
|
|
271
|
-
},
|
|
272
|
-
username: {
|
|
273
|
-
type: 'string',
|
|
274
|
-
description: 'SSH username (optional)',
|
|
275
|
-
},
|
|
276
|
-
password: {
|
|
277
|
-
type: 'string',
|
|
278
|
-
description: 'SSH password (optional)',
|
|
279
|
-
},
|
|
280
|
-
auth_type: {
|
|
281
|
-
type: 'string',
|
|
282
|
-
enum: ['password', 'public_key'],
|
|
283
|
-
description: 'Authentication type (default: password)',
|
|
284
|
-
default: 'password',
|
|
285
|
-
},
|
|
286
|
-
private_key_path: {
|
|
287
|
-
type: 'string',
|
|
288
|
-
description: 'Private key path (for public_key auth)',
|
|
289
|
-
},
|
|
290
|
-
passphrase: {
|
|
291
|
-
type: 'string',
|
|
292
|
-
description: 'Passphrase for private key',
|
|
293
|
-
},
|
|
31
|
+
host: { type: 'string', description: '服务器地址(单次执行时必填)' },
|
|
32
|
+
port: { type: 'number', description: 'SSH 端口(默认: 22)', default: 22 },
|
|
33
|
+
username: { type: 'string', description: 'SSH 用户名(单次执行时必填)' },
|
|
34
|
+
password: { type: 'string', description: '密码(单次执行时必填)' },
|
|
35
|
+
auth_type: { type: 'string', enum: ['password', 'public_key'], description: '认证方式(默认: password)', default: 'password' },
|
|
36
|
+
private_key_path: { type: 'string', description: '私钥路径(公钥认证时使用,二选一)' },
|
|
37
|
+
private_key_content: { type: 'string', description: '私钥文本内容(公钥认证时使用,二选一)' },
|
|
38
|
+
passphrase: { type: 'string', description: '私钥密码' },
|
|
39
|
+
timeout: { type: 'number', description: '超时时间(秒)', default: 10 },
|
|
40
|
+
session_id: { type: 'string', description: '会话 ID(连续执行多命令时使用,先调用 ssh_connect 获取)' },
|
|
41
|
+
command: { type: 'string', description: '要执行的命令(单个命令)' },
|
|
42
|
+
commands: { type: 'array', items: { type: 'string' }, description: '要执行的命令列表(多个命令按顺序执行)' },
|
|
294
43
|
},
|
|
295
|
-
required: ['command'],
|
|
296
44
|
},
|
|
297
45
|
},
|
|
298
46
|
{
|
|
299
47
|
name: 'ssh_disconnect',
|
|
300
|
-
description: '
|
|
48
|
+
description: '关闭 SSH 会话(使用 ssh_connect 后需要手动断开)',
|
|
301
49
|
inputSchema: {
|
|
302
50
|
type: 'object',
|
|
303
51
|
properties: {
|
|
304
|
-
session_id: {
|
|
305
|
-
type: 'string',
|
|
306
|
-
description: 'The SSH session ID to disconnect',
|
|
307
|
-
},
|
|
52
|
+
session_id: { type: 'string', description: '要断开的会话 ID'},
|
|
308
53
|
},
|
|
309
54
|
required: ['session_id'],
|
|
310
55
|
},
|
|
311
56
|
},
|
|
312
57
|
{
|
|
313
|
-
name: '
|
|
314
|
-
description: '
|
|
58
|
+
name: 'ssh_test',
|
|
59
|
+
description: '快速测试 SSH 连接。连接成功后执行测试命令,然后自动断开。',
|
|
315
60
|
inputSchema: {
|
|
316
61
|
type: 'object',
|
|
317
|
-
properties: {
|
|
62
|
+
properties: {
|
|
63
|
+
host: { type: 'string', description: '服务器地址' },
|
|
64
|
+
port: { type: 'number', description: 'SSH 端口', default: 22 },
|
|
65
|
+
username: { type: 'string', description: 'SSH 用户名' },
|
|
66
|
+
password: { type: 'string', description: '密码' },
|
|
67
|
+
auth_type: { type: 'string', enum: ['password', 'public_key'], description: '认证方式' },
|
|
68
|
+
private_key_path: { type: 'string', description: '私钥路径(公钥认证时使用,二选一)' },
|
|
69
|
+
private_key_content: { type: 'string', description: '私钥文本内容(公钥认证时使用,二选一)' },
|
|
70
|
+
passphrase: { type: 'string', description: '私钥密码' },
|
|
71
|
+
timeout: { type: 'number', description: '超时时间(秒)', default: 10 },
|
|
72
|
+
test_command: { type: 'string', description: '测试命令(默认: echo test)', default: 'echo test' },
|
|
73
|
+
},
|
|
74
|
+
required: ['host', 'username'],
|
|
318
75
|
},
|
|
319
76
|
},
|
|
77
|
+
{
|
|
78
|
+
name: 'ssh_list_sessions',
|
|
79
|
+
description: '列出当前活跃的 SSH 会话',
|
|
80
|
+
inputSchema: { type: 'object', properties: {} },
|
|
81
|
+
},
|
|
320
82
|
];
|
|
321
83
|
|
|
322
84
|
function send(message) {
|
|
323
85
|
process.stdout.write(JSON.stringify(message) + '\n');
|
|
324
86
|
}
|
|
325
87
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
process.stdin.on('data', async (chunk) => {
|
|
331
|
-
buffer += chunk;
|
|
332
|
-
let newlineIndex;
|
|
333
|
-
|
|
334
|
-
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
335
|
-
const line = buffer.slice(0, newlineIndex);
|
|
336
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
337
|
-
|
|
338
|
-
if (line.trim()) {
|
|
339
|
-
try {
|
|
340
|
-
const request = JSON.parse(line);
|
|
341
|
-
await handleRequest(request);
|
|
342
|
-
} catch (err) {
|
|
343
|
-
console.error('Parse error:', err);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
88
|
+
function parseArgs(args) {
|
|
89
|
+
if (!args) return {};
|
|
90
|
+
if (typeof args === 'string') {
|
|
91
|
+
try { return JSON.parse(args); } catch { return {}; }
|
|
346
92
|
}
|
|
347
|
-
|
|
93
|
+
if (typeof args.arguments === 'string') {
|
|
94
|
+
try { return JSON.parse(args.arguments); } catch { return args; }
|
|
95
|
+
}
|
|
96
|
+
return args;
|
|
97
|
+
}
|
|
348
98
|
|
|
349
99
|
async function handleRequest(request) {
|
|
350
100
|
const { id, method, params } = request;
|
|
@@ -356,198 +106,125 @@ async function handleRequest(request) {
|
|
|
356
106
|
result: {
|
|
357
107
|
protocolVersion: '2024-11-05',
|
|
358
108
|
capabilities: { tools: {} },
|
|
359
|
-
serverInfo: {
|
|
360
|
-
name: 'ssh-mcp-server',
|
|
361
|
-
version: '1.0.0',
|
|
362
|
-
},
|
|
109
|
+
serverInfo: { name: 'ssh-mcp-server', version: '1.0.0' },
|
|
363
110
|
},
|
|
364
111
|
});
|
|
365
112
|
return;
|
|
366
113
|
}
|
|
367
114
|
|
|
368
|
-
if (method === 'notifications/initialized')
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
115
|
+
if (method === 'notifications/initialized') return;
|
|
371
116
|
|
|
372
117
|
if (method === 'tools/list') {
|
|
373
|
-
send({
|
|
374
|
-
jsonrpc: '2.0',
|
|
375
|
-
id,
|
|
376
|
-
result: { tools },
|
|
377
|
-
});
|
|
118
|
+
send({ jsonrpc: '2.0', id, result: { tools } });
|
|
378
119
|
return;
|
|
379
120
|
}
|
|
380
121
|
|
|
381
122
|
if (method === 'tools/call') {
|
|
382
123
|
const { name, arguments: args } = params;
|
|
383
|
-
|
|
384
|
-
// Handle nested arguments (stringified JSON)
|
|
385
|
-
let toolArgs = args;
|
|
386
|
-
if (typeof args === 'string') {
|
|
387
|
-
try {
|
|
388
|
-
toolArgs = JSON.parse(args);
|
|
389
|
-
} catch (e) {
|
|
390
|
-
toolArgs = {};
|
|
391
|
-
}
|
|
392
|
-
} else if (args && typeof args.arguments === 'string') {
|
|
393
|
-
try {
|
|
394
|
-
toolArgs = JSON.parse(args.arguments);
|
|
395
|
-
} catch (e) {
|
|
396
|
-
toolArgs = args;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
124
|
+
const toolArgs = parseArgs(args);
|
|
399
125
|
|
|
400
126
|
if (name === 'ssh_connect') {
|
|
401
|
-
sshManager.connect({
|
|
127
|
+
const result = await sshManager.connect({
|
|
402
128
|
host: toolArgs.host,
|
|
403
129
|
port: toolArgs.port,
|
|
404
130
|
username: toolArgs.username,
|
|
405
|
-
authType: toolArgs.auth_type,
|
|
406
131
|
password: toolArgs.password,
|
|
407
132
|
privateKeyPath: toolArgs.private_key_path,
|
|
133
|
+
privateKeyContent: toolArgs.private_key_content,
|
|
408
134
|
passphrase: toolArgs.passphrase,
|
|
409
135
|
timeout: toolArgs.timeout,
|
|
410
|
-
}).then((result) => {
|
|
411
|
-
send({
|
|
412
|
-
jsonrpc: '2.0',
|
|
413
|
-
id,
|
|
414
|
-
result: {
|
|
415
|
-
content: [
|
|
416
|
-
{
|
|
417
|
-
type: 'text',
|
|
418
|
-
text: JSON.stringify(result),
|
|
419
|
-
},
|
|
420
|
-
],
|
|
421
|
-
},
|
|
422
|
-
});
|
|
423
136
|
});
|
|
137
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
424
138
|
return;
|
|
425
139
|
}
|
|
426
140
|
|
|
427
141
|
if (name === 'ssh_exec') {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
send({
|
|
434
|
-
jsonrpc: '2.0',
|
|
435
|
-
id,
|
|
436
|
-
result: {
|
|
437
|
-
content: [
|
|
438
|
-
{
|
|
439
|
-
type: 'text',
|
|
440
|
-
text: JSON.stringify(result),
|
|
441
|
-
},
|
|
442
|
-
],
|
|
443
|
-
},
|
|
444
|
-
});
|
|
445
|
-
}).catch((err) => {
|
|
446
|
-
send({
|
|
447
|
-
jsonrpc: '2.0',
|
|
448
|
-
id,
|
|
449
|
-
result: {
|
|
450
|
-
content: [
|
|
451
|
-
{
|
|
452
|
-
type: 'text',
|
|
453
|
-
text: JSON.stringify({ success: false, error: err.message }),
|
|
454
|
-
},
|
|
455
|
-
],
|
|
456
|
-
},
|
|
457
|
-
});
|
|
458
|
-
});
|
|
459
|
-
} else {
|
|
460
|
-
send({
|
|
461
|
-
jsonrpc: '2.0',
|
|
462
|
-
id,
|
|
463
|
-
result: {
|
|
464
|
-
content: [
|
|
465
|
-
{
|
|
466
|
-
type: 'text',
|
|
467
|
-
text: JSON.stringify(execResult),
|
|
468
|
-
},
|
|
469
|
-
],
|
|
470
|
-
},
|
|
471
|
-
});
|
|
472
|
-
}
|
|
142
|
+
const hasSession = toolArgs.session_id;
|
|
143
|
+
const hasAuth = toolArgs.host && toolArgs.username;
|
|
144
|
+
|
|
145
|
+
if (!hasSession && !hasAuth) {
|
|
146
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Either session_id or host/username required' }) }] } });
|
|
473
147
|
return;
|
|
474
148
|
}
|
|
475
149
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const connResult = await sshManager.connect({
|
|
150
|
+
if (hasAuth && !hasSession) {
|
|
151
|
+
const conn = await sshManager.connect({
|
|
479
152
|
host: toolArgs.host,
|
|
480
153
|
port: toolArgs.port,
|
|
481
154
|
username: toolArgs.username,
|
|
482
|
-
authType: toolArgs.auth_type || 'password',
|
|
483
155
|
password: toolArgs.password,
|
|
484
156
|
privateKeyPath: toolArgs.private_key_path,
|
|
157
|
+
privateKeyContent: toolArgs.private_key_content,
|
|
485
158
|
passphrase: toolArgs.passphrase,
|
|
486
159
|
timeout: toolArgs.timeout,
|
|
487
160
|
});
|
|
488
161
|
|
|
489
|
-
if (!
|
|
490
|
-
send({
|
|
491
|
-
jsonrpc: '2.0',
|
|
492
|
-
id,
|
|
493
|
-
result: {
|
|
494
|
-
content: [
|
|
495
|
-
{
|
|
496
|
-
type: 'text',
|
|
497
|
-
text: JSON.stringify(connResult),
|
|
498
|
-
},
|
|
499
|
-
],
|
|
500
|
-
},
|
|
501
|
-
});
|
|
162
|
+
if (!conn.success) {
|
|
163
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(conn) }] } });
|
|
502
164
|
return;
|
|
503
165
|
}
|
|
504
166
|
|
|
505
|
-
|
|
506
|
-
|
|
167
|
+
let result;
|
|
168
|
+
if (toolArgs.commands) {
|
|
169
|
+
result = await sshManager.execCommands(conn.sessionId, toolArgs.commands);
|
|
170
|
+
} else if (toolArgs.command) {
|
|
171
|
+
result = await sshManager.execCommand(conn.sessionId, toolArgs.command);
|
|
172
|
+
}
|
|
507
173
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
id,
|
|
511
|
-
result: {
|
|
512
|
-
content: [
|
|
513
|
-
{
|
|
514
|
-
type: 'text',
|
|
515
|
-
text: JSON.stringify(execResult),
|
|
516
|
-
},
|
|
517
|
-
],
|
|
518
|
-
},
|
|
519
|
-
});
|
|
174
|
+
sshManager.disconnect(conn.sessionId);
|
|
175
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
520
176
|
return;
|
|
521
177
|
}
|
|
522
178
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
],
|
|
534
|
-
},
|
|
535
|
-
});
|
|
536
|
-
return;
|
|
179
|
+
if (hasSession) {
|
|
180
|
+
let result;
|
|
181
|
+
if (toolArgs.commands) {
|
|
182
|
+
result = await sshManager.execCommands(toolArgs.session_id, toolArgs.commands);
|
|
183
|
+
} else if (toolArgs.command) {
|
|
184
|
+
result = await sshManager.execCommand(toolArgs.session_id, toolArgs.command);
|
|
185
|
+
}
|
|
186
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
537
189
|
}
|
|
538
190
|
|
|
539
191
|
if (name === 'ssh_disconnect') {
|
|
540
192
|
const result = sshManager.disconnect(toolArgs.session_id);
|
|
193
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (name === 'ssh_test') {
|
|
198
|
+
const conn = await sshManager.connect({
|
|
199
|
+
host: toolArgs.host,
|
|
200
|
+
port: toolArgs.port,
|
|
201
|
+
username: toolArgs.username,
|
|
202
|
+
password: toolArgs.password,
|
|
203
|
+
privateKeyPath: toolArgs.private_key_path,
|
|
204
|
+
privateKeyContent: toolArgs.private_key_content,
|
|
205
|
+
passphrase: toolArgs.passphrase,
|
|
206
|
+
timeout: toolArgs.timeout,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!conn.success) {
|
|
210
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(conn) }] } });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const execResult = await sshManager.execCommand(conn.sessionId, toolArgs.test_command || 'echo test');
|
|
215
|
+
sshManager.disconnect(conn.sessionId);
|
|
216
|
+
|
|
541
217
|
send({
|
|
542
218
|
jsonrpc: '2.0',
|
|
543
219
|
id,
|
|
544
220
|
result: {
|
|
545
|
-
content: [
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
221
|
+
content: [{
|
|
222
|
+
type: 'text',
|
|
223
|
+
text: JSON.stringify({
|
|
224
|
+
connection: conn,
|
|
225
|
+
test: execResult,
|
|
226
|
+
}),
|
|
227
|
+
}],
|
|
551
228
|
},
|
|
552
229
|
});
|
|
553
230
|
return;
|
|
@@ -555,42 +232,32 @@ async function handleRequest(request) {
|
|
|
555
232
|
|
|
556
233
|
if (name === 'ssh_list_sessions') {
|
|
557
234
|
const result = sshManager.listSessions();
|
|
558
|
-
send({
|
|
559
|
-
jsonrpc: '2.0',
|
|
560
|
-
id,
|
|
561
|
-
result: {
|
|
562
|
-
content: [
|
|
563
|
-
{
|
|
564
|
-
type: 'text',
|
|
565
|
-
text: JSON.stringify(result),
|
|
566
|
-
},
|
|
567
|
-
],
|
|
568
|
-
},
|
|
569
|
-
});
|
|
235
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
570
236
|
return;
|
|
571
237
|
}
|
|
572
238
|
|
|
573
|
-
send({
|
|
574
|
-
jsonrpc: '2.0',
|
|
575
|
-
id,
|
|
576
|
-
result: {
|
|
577
|
-
content: [
|
|
578
|
-
{
|
|
579
|
-
type: 'text',
|
|
580
|
-
text: JSON.stringify({ success: false, error: `Unknown tool: ${name}` }),
|
|
581
|
-
},
|
|
582
|
-
],
|
|
583
|
-
},
|
|
584
|
-
});
|
|
239
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify({ success: false, error: `Unknown tool: ${name}` }) }] } });
|
|
585
240
|
return;
|
|
586
241
|
}
|
|
587
242
|
|
|
588
|
-
send({
|
|
589
|
-
jsonrpc: '2.0',
|
|
590
|
-
id,
|
|
591
|
-
error: {
|
|
592
|
-
code: -32601,
|
|
593
|
-
message: 'Method not found',
|
|
594
|
-
},
|
|
595
|
-
});
|
|
243
|
+
send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } });
|
|
596
244
|
}
|
|
245
|
+
|
|
246
|
+
let buffer = '';
|
|
247
|
+
process.stdin.setEncoding('utf8');
|
|
248
|
+
|
|
249
|
+
process.stdin.on('data', async (chunk) => {
|
|
250
|
+
buffer += chunk;
|
|
251
|
+
let newlineIndex;
|
|
252
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
253
|
+
const line = buffer.slice(0, newlineIndex);
|
|
254
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
255
|
+
if (line.trim()) {
|
|
256
|
+
try {
|
|
257
|
+
await handleRequest(JSON.parse(line));
|
|
258
|
+
} catch (err) {
|
|
259
|
+
console.error('Parse error:', err.message);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-ssh-server-tool",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "MCP Server for SSH connections and remote command execution",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"bin":
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-ssh-server-tool": "bin/cli.js"
|
|
9
|
+
},
|
|
8
10
|
"scripts": {
|
|
9
11
|
"start": "node index.js"
|
|
10
12
|
},
|
|
@@ -16,8 +18,7 @@
|
|
|
16
18
|
"url": ""
|
|
17
19
|
},
|
|
18
20
|
"dependencies": {
|
|
19
|
-
"ssh2": "^1.15.0"
|
|
20
|
-
"uuid": "^9.0.0"
|
|
21
|
+
"ssh2": "^1.15.0"
|
|
21
22
|
},
|
|
22
23
|
"engines": {
|
|
23
24
|
"node": ">=18.0.0"
|
package/ssh_manager.js
CHANGED
|
@@ -1,86 +1,62 @@
|
|
|
1
1
|
import { Client } from 'ssh2';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
3
|
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
4
5
|
|
|
5
6
|
class SSHSession {
|
|
6
|
-
constructor(sessionId, client,
|
|
7
|
+
constructor(sessionId, client, config) {
|
|
7
8
|
this.sessionId = sessionId;
|
|
8
9
|
this.client = client;
|
|
9
|
-
this.
|
|
10
|
-
this.
|
|
11
|
-
this.connected = true;
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.createdAt = Date.now();
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
class SSHConnectionManager {
|
|
16
16
|
constructor() {
|
|
17
17
|
this.sessions = new Map();
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
connect(options) {
|
|
20
|
+
async connect(options) {
|
|
21
21
|
return new Promise((resolve) => {
|
|
22
22
|
const client = new Client();
|
|
23
|
-
const sessionId =
|
|
24
|
-
const port = options.port || 22;
|
|
25
|
-
const timeout = (options.timeout || 10) * 1000;
|
|
26
|
-
|
|
23
|
+
const sessionId = randomUUID();
|
|
27
24
|
const config = {
|
|
28
25
|
host: options.host,
|
|
29
|
-
port: port,
|
|
26
|
+
port: options.port || 22,
|
|
30
27
|
username: options.username,
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
password: options.password,
|
|
29
|
+
privateKey: options.privateKeyContent || (options.privateKeyPath ? this._readPrivateKey(options.privateKeyPath) : undefined),
|
|
30
|
+
passphrase: options.passphrase,
|
|
31
|
+
readyTimeout: (options.timeout || 10) * 1000,
|
|
32
|
+
keepaliveInterval: 0,
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
config.password = options.password;
|
|
45
|
-
} else if (options.authType === 'public_key') {
|
|
46
|
-
if (!options.privateKeyPath) {
|
|
47
|
-
resolve({
|
|
48
|
-
success: false,
|
|
49
|
-
error: 'Private key path is required for public key authentication',
|
|
50
|
-
errorType: 'MissingPrivateKey',
|
|
51
|
-
});
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
config.privateKey = fs.readFileSync(options.privateKeyPath);
|
|
56
|
-
if (options.passphrase) {
|
|
57
|
-
config.passphrase = options.passphrase;
|
|
58
|
-
}
|
|
59
|
-
} catch (err) {
|
|
60
|
-
resolve({
|
|
61
|
-
success: false,
|
|
62
|
-
error: `Failed to read private key: ${err.message}`,
|
|
63
|
-
errorType: 'PrivateKeyError',
|
|
64
|
-
});
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
35
|
+
const timeout = setTimeout(() => {
|
|
36
|
+
client.end();
|
|
37
|
+
resolve({
|
|
38
|
+
success: false,
|
|
39
|
+
error: 'Connection timeout',
|
|
40
|
+
errorType: 'Timeout',
|
|
41
|
+
});
|
|
42
|
+
}, (options.timeout || 10) * 1000);
|
|
68
43
|
|
|
69
44
|
client.on('ready', () => {
|
|
70
|
-
|
|
45
|
+
clearTimeout(timeout);
|
|
46
|
+
const session = new SSHSession(sessionId, client, { host: config.host, port: config.port, username: config.username });
|
|
71
47
|
this.sessions.set(sessionId, session);
|
|
72
|
-
|
|
73
48
|
resolve({
|
|
74
49
|
success: true,
|
|
75
50
|
sessionId,
|
|
76
|
-
host:
|
|
77
|
-
port,
|
|
78
|
-
username:
|
|
79
|
-
message: `
|
|
51
|
+
host: config.host,
|
|
52
|
+
port: config.port,
|
|
53
|
+
username: config.username,
|
|
54
|
+
message: `Connected to ${config.host}:${config.port}`,
|
|
80
55
|
});
|
|
81
56
|
});
|
|
82
57
|
|
|
83
58
|
client.on('error', (err) => {
|
|
59
|
+
clearTimeout(timeout);
|
|
84
60
|
resolve({
|
|
85
61
|
success: false,
|
|
86
62
|
error: err.message,
|
|
@@ -92,9 +68,17 @@ export class SSHConnectionManager {
|
|
|
92
68
|
});
|
|
93
69
|
}
|
|
94
70
|
|
|
95
|
-
|
|
96
|
-
|
|
71
|
+
_readPrivateKey(filePath) {
|
|
72
|
+
try {
|
|
73
|
+
const resolvedPath = path.resolve(filePath.replace(/^~/, process.env.HOME || process.env.USERPROFILE || ''));
|
|
74
|
+
return fs.readFileSync(resolvedPath);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new Error(`Failed to read private key: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
97
79
|
|
|
80
|
+
async execCommand(sessionId, command) {
|
|
81
|
+
const session = this.sessions.get(sessionId);
|
|
98
82
|
if (!session) {
|
|
99
83
|
return {
|
|
100
84
|
success: false,
|
|
@@ -103,14 +87,6 @@ export class SSHConnectionManager {
|
|
|
103
87
|
};
|
|
104
88
|
}
|
|
105
89
|
|
|
106
|
-
if (!session.connected) {
|
|
107
|
-
return {
|
|
108
|
-
success: false,
|
|
109
|
-
error: `Session ${sessionId} is not connected`,
|
|
110
|
-
errorType: 'SessionNotConnected',
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
90
|
return new Promise((resolve) => {
|
|
115
91
|
session.client.exec(command, (err, stream) => {
|
|
116
92
|
if (err) {
|
|
@@ -137,20 +113,24 @@ export class SSHConnectionManager {
|
|
|
137
113
|
});
|
|
138
114
|
});
|
|
139
115
|
|
|
140
|
-
stream.on('data', (data) => {
|
|
141
|
-
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
stream.stderr.on('data', (data) => {
|
|
145
|
-
stderr += data.toString();
|
|
146
|
-
});
|
|
116
|
+
stream.on('data', (data) => { stdout += data.toString(); });
|
|
117
|
+
stream.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
147
118
|
});
|
|
148
119
|
});
|
|
149
120
|
}
|
|
150
121
|
|
|
122
|
+
async execCommands(sessionId, commands) {
|
|
123
|
+
const results = [];
|
|
124
|
+
for (const cmd of commands) {
|
|
125
|
+
const result = await this.execCommand(sessionId, cmd);
|
|
126
|
+
results.push(result);
|
|
127
|
+
if (!result.success) break;
|
|
128
|
+
}
|
|
129
|
+
return results;
|
|
130
|
+
}
|
|
131
|
+
|
|
151
132
|
disconnect(sessionId) {
|
|
152
133
|
const session = this.sessions.get(sessionId);
|
|
153
|
-
|
|
154
134
|
if (!session) {
|
|
155
135
|
return {
|
|
156
136
|
success: false,
|
|
@@ -161,39 +141,22 @@ export class SSHConnectionManager {
|
|
|
161
141
|
|
|
162
142
|
try {
|
|
163
143
|
session.client.end();
|
|
164
|
-
session.connected = false;
|
|
165
144
|
this.sessions.delete(sessionId);
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
success: true,
|
|
169
|
-
sessionId,
|
|
170
|
-
message: 'Disconnected successfully',
|
|
171
|
-
};
|
|
145
|
+
return { success: true, sessionId, message: 'Disconnected' };
|
|
172
146
|
} catch (err) {
|
|
173
|
-
return {
|
|
174
|
-
success: false,
|
|
175
|
-
error: err.message,
|
|
176
|
-
errorType: 'DisconnectError',
|
|
177
|
-
};
|
|
147
|
+
return { success: false, error: err.message, errorType: 'DisconnectError' };
|
|
178
148
|
}
|
|
179
149
|
}
|
|
180
150
|
|
|
181
151
|
listSessions() {
|
|
182
|
-
const sessions =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
connected: session.connected,
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
return {
|
|
194
|
-
success: true,
|
|
195
|
-
sessions,
|
|
196
|
-
count: sessions.length,
|
|
197
|
-
};
|
|
152
|
+
const sessions = Array.from(this.sessions.values()).map(s => ({
|
|
153
|
+
sessionId: s.sessionId,
|
|
154
|
+
host: s.config.host,
|
|
155
|
+
username: s.config.username,
|
|
156
|
+
createdAt: s.createdAt,
|
|
157
|
+
}));
|
|
158
|
+
return { success: true, sessions, count: sessions.length };
|
|
198
159
|
}
|
|
199
160
|
}
|
|
161
|
+
|
|
162
|
+
export { SSHConnectionManager };
|