mcp-hydrocoder-ssh 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mcpHydroSSH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # mcp-hydrocoder-ssh
2
+
3
+ SSH MCP Server for Claude Code - connect to remote servers directly from Claude Code.
4
+
5
+ ## Quick Start
6
+
7
+ ### 1. Install from npm
8
+
9
+ ```bash
10
+ npm install -g mcp-hydrocoder-ssh
11
+ ```
12
+
13
+ ### 2. Configure Servers
14
+
15
+ On first run, the server will auto-create `~/.hydrossh/config.json` from the example template.
16
+
17
+ Edit the config file with your SSH servers:
18
+ ```bash
19
+ # Windows
20
+ notepad C:\Users\ynzys\.hydrossh\config.json
21
+
22
+ # macOS/Linux
23
+ nano ~/.hydrossh/config.json
24
+ ```
25
+
26
+ See [CONFIG-GUIDE.md](CONFIG-GUIDE.md) for detailed configuration options.
27
+
28
+ ### 3. Add to Claude Code
29
+
30
+ Add to your Claude Code settings (`~/.claude.json`):
31
+
32
+ **Windows:**
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "hydrossh": {
37
+ "command": "npx",
38
+ "args": ["mcp-hydrocoder-ssh"]
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ **macOS/Linux:**
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "hydrossh": {
49
+ "command": "npx",
50
+ "args": ["mcp-hydrocoder-ssh"]
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ > **Note:** The server name `hydrossh` is used to avoid conflicts with other SSH-related MCP servers.
57
+ > For global install: use `"command": "mcp-hydrocoder-ssh"` (no npx needed).
58
+
59
+ ### 4. Usage
60
+
61
+ In Claude Code, simply say:
62
+ - "List available servers"
63
+ - "Connect to my-server"
64
+ - "Run command: uptime"
65
+ - "Show connection status"
66
+ - "Disconnect"
67
+
68
+ ## MCP Tools
69
+
70
+ ### SSH Connection Tools
71
+
72
+ | Tool | Description |
73
+ |------|-------------|
74
+ | `ssh_list_servers` | List all configured servers |
75
+ | `ssh_connect` | Connect to a server |
76
+ | `ssh_exec` | Execute a command |
77
+ | `ssh_get_status` | Get connection status (or all statuses) |
78
+ | `ssh_disconnect` | Disconnect from server |
79
+
80
+ ### Config Management Tools
81
+
82
+ | Tool | Description |
83
+ |------|-------------|
84
+ | `ssh_add_server` | Add a new server to config |
85
+ | `ssh_remove_server` | Remove a server from config |
86
+ | `ssh_update_server` | Update an existing server config |
87
+ | `ssh_view_config` | View full configuration (sanitized) |
88
+ | `ssh_help` | Show help and usage examples |
89
+
90
+ ## License
91
+
92
+ MIT
package/dist/config.js ADDED
@@ -0,0 +1,166 @@
1
+ import { z } from 'zod';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { homedir } from 'os';
5
+ import { fileURLToPath } from 'url';
6
+ // Zod schemas for validation
7
+ const ServerConfigSchema = z.object({
8
+ id: z.string().min(1),
9
+ name: z.string().min(1),
10
+ host: z.string().min(1),
11
+ port: z.number().int().min(1).max(65535).default(22),
12
+ username: z.string().min(1),
13
+ authMethod: z.enum(['agent', 'key', 'password']).default('agent'),
14
+ privateKeyPath: z.string().optional(),
15
+ password: z.string().optional(),
16
+ connectTimeout: z.number().int().min(1000).optional(),
17
+ keepaliveInterval: z.number().int().min(1000).optional(),
18
+ });
19
+ const ConfigSchema = z.object({
20
+ servers: z.array(ServerConfigSchema),
21
+ settings: z.object({
22
+ defaultConnectTimeout: z.number().int().min(1000).default(30000),
23
+ defaultKeepaliveInterval: z.number().int().min(0).default(60000), // 60 秒,0 表示禁用
24
+ commandTimeout: z.number().int().min(1000).default(60000),
25
+ maxConnections: z.number().int().min(1).default(5),
26
+ autoReconnect: z.boolean().default(false),
27
+ logCommands: z.boolean().default(true),
28
+ }),
29
+ });
30
+ const DEFAULT_CONFIG_PATH = path.join(homedir(), '.hydrossh', 'config.json');
31
+ function getConfigPath() {
32
+ return DEFAULT_CONFIG_PATH;
33
+ }
34
+ function getExamplePath() {
35
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
36
+ return path.join(moduleDir, '..', 'example-config.json');
37
+ }
38
+ /**
39
+ * Initialize config file if not exists.
40
+ * Copies example-config.json to ~/.hydrossh/config.json
41
+ * @returns The path to the config file
42
+ * @throws Error if example-config.json is not found
43
+ */
44
+ export function initializeConfig() {
45
+ const userPath = getConfigPath();
46
+ // Config already exists
47
+ if (fs.existsSync(userPath)) {
48
+ return userPath;
49
+ }
50
+ const examplePath = getExamplePath();
51
+ // Check if example exists
52
+ if (!fs.existsSync(examplePath)) {
53
+ throw new Error(`Example config not found at ${examplePath}`);
54
+ }
55
+ // Create ~/.hydrossh directory
56
+ const configDir = path.dirname(userPath);
57
+ if (!fs.existsSync(configDir)) {
58
+ fs.mkdirSync(configDir, { recursive: true });
59
+ }
60
+ // Copy example config
61
+ fs.copyFileSync(examplePath, userPath);
62
+ console.error(`[mcpHydroSSH] Created default config at ${userPath}`);
63
+ console.error(`[mcpHydroSSH] Please edit the config file with your SSH servers.`);
64
+ return userPath;
65
+ }
66
+ /**
67
+ * Load and validate the config file.
68
+ * @returns The validated config object
69
+ * @throws Error if config file is not found or contains invalid JSON
70
+ */
71
+ export function loadConfig() {
72
+ const configPath = getConfigPath();
73
+ if (!fs.existsSync(configPath)) {
74
+ throw new Error(`Config file not found: ${configPath}\n` +
75
+ `Run the server again to auto-create, or manually:\n` +
76
+ ` mkdir -p ~/.hydrossh && cp <mcp-dir>/example-config.json ~/.hydrossh/config.json`);
77
+ }
78
+ const raw = fs.readFileSync(configPath, 'utf-8');
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ }
83
+ catch (err) {
84
+ throw new Error(`Invalid JSON in config file: ${configPath}\n` +
85
+ `Details: ${err instanceof Error ? err.message : String(err)}`);
86
+ }
87
+ const validated = ConfigSchema.parse(parsed);
88
+ return validated;
89
+ }
90
+ /**
91
+ * Get a server configuration by ID.
92
+ * @param config - The config object
93
+ * @param serverId - The server ID to look up
94
+ * @returns The server config or undefined if not found
95
+ */
96
+ export function getServerConfig(config, serverId) {
97
+ return config.servers.find(s => s.id === serverId);
98
+ }
99
+ /**
100
+ * Get the settings section of the config.
101
+ * @param config - The config object
102
+ * @returns The settings object
103
+ */
104
+ export function getConfigSettings(config) {
105
+ return config.settings;
106
+ }
107
+ /**
108
+ * Save config to file.
109
+ * @param config - The config object to save
110
+ */
111
+ export function saveConfig(config) {
112
+ const configPath = getConfigPath();
113
+ const configDir = path.dirname(configPath);
114
+ // Create directory if not exists
115
+ if (!fs.existsSync(configDir)) {
116
+ fs.mkdirSync(configDir, { recursive: true });
117
+ }
118
+ // Write config file
119
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
120
+ }
121
+ /**
122
+ * Add a server to the config.
123
+ * @param server - The server configuration to add
124
+ * @throws Error if server ID already exists
125
+ */
126
+ export function addServer(server) {
127
+ const config = loadConfig();
128
+ // Check if server ID already exists
129
+ if (config.servers.some(s => s.id === server.id)) {
130
+ throw new Error(`Server with ID "${server.id}" already exists`);
131
+ }
132
+ config.servers.push(server);
133
+ saveConfig(config);
134
+ }
135
+ /**
136
+ * Remove a server from the config.
137
+ * @param serverId - The server ID to remove
138
+ * @throws Error if server ID not found
139
+ */
140
+ export function removeServer(serverId) {
141
+ const config = loadConfig();
142
+ const index = config.servers.findIndex(s => s.id === serverId);
143
+ if (index === -1) {
144
+ throw new Error(`Server with ID "${serverId}" not found`);
145
+ }
146
+ config.servers.splice(index, 1);
147
+ saveConfig(config);
148
+ }
149
+ /**
150
+ * Update a server in the config.
151
+ * @param serverId - The server ID to update
152
+ * @param updates - Partial server configuration to merge
153
+ * @throws Error if server ID not found
154
+ */
155
+ export function updateServer(serverId, updates) {
156
+ const config = loadConfig();
157
+ const server = config.servers.find(s => s.id === serverId);
158
+ if (!server) {
159
+ throw new Error(`Server with ID "${serverId}" not found`);
160
+ }
161
+ // Apply updates
162
+ Object.assign(server, updates);
163
+ // Re-validate
164
+ ServerConfigSchema.parse(server);
165
+ saveConfig(config);
166
+ }
package/dist/index.js ADDED
@@ -0,0 +1,709 @@
1
+ #! /usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { initializeConfig, loadConfig, getConfigSettings, addServer, removeServer, updateServer } from './config.js';
6
+ import { SSHManager } from './ssh-manager.js';
7
+ async function main() {
8
+ const server = new Server({
9
+ name: 'mcp-hydro-ssh',
10
+ version: '0.1.0',
11
+ }, {
12
+ capabilities: {
13
+ tools: {},
14
+ },
15
+ });
16
+ // Initialize config (auto-create if not exists)
17
+ initializeConfig();
18
+ // Load config
19
+ const config = loadConfig();
20
+ const settings = getConfigSettings(config);
21
+ // Initialize SSH manager
22
+ const sshManager = new SSHManager({
23
+ commandTimeout: settings.commandTimeout,
24
+ keepaliveInterval: settings.defaultKeepaliveInterval,
25
+ maxConnections: settings.maxConnections,
26
+ autoReconnect: settings.autoReconnect,
27
+ logCommands: settings.logCommands,
28
+ });
29
+ // ===== Tool handlers =====
30
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
31
+ return {
32
+ tools: [
33
+ {
34
+ name: 'ssh_list_servers',
35
+ description: 'List all configured SSH servers',
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {},
39
+ required: [],
40
+ },
41
+ },
42
+ {
43
+ name: 'ssh_view_config',
44
+ description: 'View the full SSH configuration including servers and settings',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {},
48
+ required: [],
49
+ },
50
+ },
51
+ {
52
+ name: 'ssh_connect',
53
+ description: 'Connect to an SSH server',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ serverId: {
58
+ type: 'string',
59
+ description: 'Server ID from ssh_list_servers',
60
+ },
61
+ timeout: {
62
+ type: 'number',
63
+ description: 'Connection timeout in milliseconds (optional)',
64
+ },
65
+ },
66
+ required: ['serverId'],
67
+ },
68
+ },
69
+ {
70
+ name: 'ssh_exec',
71
+ description: 'Execute a command on an SSH server',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ command: {
76
+ type: 'string',
77
+ description: 'Command to execute',
78
+ },
79
+ connectionId: {
80
+ type: 'string',
81
+ description: 'Connection ID (optional, uses most recent if not provided)',
82
+ },
83
+ timeout: {
84
+ type: 'number',
85
+ description: 'Command timeout in milliseconds (optional)',
86
+ },
87
+ cwd: {
88
+ type: 'string',
89
+ description: 'Working directory (optional)',
90
+ },
91
+ },
92
+ required: ['command'],
93
+ },
94
+ },
95
+ {
96
+ name: 'ssh_get_status',
97
+ description: 'Get SSH connection status',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ connectionId: {
102
+ type: 'string',
103
+ description: 'Connection ID (optional, shows all connections if not provided)',
104
+ },
105
+ },
106
+ required: [],
107
+ },
108
+ },
109
+ {
110
+ name: 'ssh_disconnect',
111
+ description: 'Disconnect from an SSH server',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ connectionId: {
116
+ type: 'string',
117
+ description: 'Connection ID (optional, disconnects most recent if not provided)',
118
+ },
119
+ },
120
+ required: [],
121
+ },
122
+ },
123
+ {
124
+ name: 'ssh_help',
125
+ description: 'Show help and usage examples for mcpHydroSSH',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: {
129
+ topic: {
130
+ type: 'string',
131
+ description: 'Specific topic to get help on (optional)',
132
+ enum: ['config', 'connect', 'exec', 'auth', 'examples'],
133
+ },
134
+ },
135
+ required: [],
136
+ },
137
+ },
138
+ {
139
+ name: 'ssh_add_server',
140
+ description: 'Add a new SSH server to config',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ id: {
145
+ type: 'string',
146
+ description: 'Unique server ID',
147
+ },
148
+ name: {
149
+ type: 'string',
150
+ description: 'Server display name',
151
+ },
152
+ host: {
153
+ type: 'string',
154
+ description: 'Server hostname or IP',
155
+ },
156
+ port: {
157
+ type: 'number',
158
+ description: 'SSH port (default: 22)',
159
+ },
160
+ username: {
161
+ type: 'string',
162
+ description: 'SSH username',
163
+ },
164
+ authMethod: {
165
+ type: 'string',
166
+ enum: ['agent', 'key', 'password'],
167
+ description: 'Authentication method (default: "key")',
168
+ },
169
+ privateKeyPath: {
170
+ type: 'string',
171
+ description: 'Path to private key (required for "key" auth)',
172
+ },
173
+ password: {
174
+ type: 'string',
175
+ description: 'Password (required for "password" auth)',
176
+ },
177
+ },
178
+ required: ['id', 'name', 'host', 'username'],
179
+ },
180
+ },
181
+ {
182
+ name: 'ssh_remove_server',
183
+ description: 'Remove a server from config',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: {
187
+ serverId: {
188
+ type: 'string',
189
+ description: 'Server ID to remove',
190
+ },
191
+ },
192
+ required: ['serverId'],
193
+ },
194
+ },
195
+ {
196
+ name: 'ssh_update_server',
197
+ description: 'Update an existing server config',
198
+ inputSchema: {
199
+ type: 'object',
200
+ properties: {
201
+ serverId: {
202
+ type: 'string',
203
+ description: 'Server ID to update',
204
+ },
205
+ name: {
206
+ type: 'string',
207
+ description: 'Server display name',
208
+ },
209
+ host: {
210
+ type: 'string',
211
+ description: 'Server hostname or IP',
212
+ },
213
+ port: {
214
+ type: 'number',
215
+ description: 'SSH port (default: 22)',
216
+ },
217
+ username: {
218
+ type: 'string',
219
+ description: 'SSH username',
220
+ },
221
+ authMethod: {
222
+ type: 'string',
223
+ enum: ['agent', 'key', 'password'],
224
+ description: 'Authentication method (default: "key")',
225
+ },
226
+ privateKeyPath: {
227
+ type: 'string',
228
+ description: 'Path to private key (required for "key" auth)',
229
+ },
230
+ password: {
231
+ type: 'string',
232
+ description: 'Password (required for "password" auth)',
233
+ },
234
+ },
235
+ required: ['serverId'],
236
+ },
237
+ },
238
+ ],
239
+ };
240
+ });
241
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
242
+ const toolName = request.params.name;
243
+ const args = request.params.arguments;
244
+ switch (toolName) {
245
+ case 'ssh_list_servers': {
246
+ const servers = config.servers.map(s => ({
247
+ id: s.id,
248
+ name: s.name,
249
+ host: s.host,
250
+ port: s.port,
251
+ }));
252
+ return {
253
+ content: [
254
+ {
255
+ type: 'text',
256
+ text: JSON.stringify(servers, null, 2),
257
+ },
258
+ ],
259
+ };
260
+ }
261
+ case 'ssh_view_config': {
262
+ // Filter out sensitive information (passwords and private key paths)
263
+ const sanitizedConfig = {
264
+ servers: config.servers.map(s => ({
265
+ id: s.id,
266
+ name: s.name,
267
+ host: s.host,
268
+ port: s.port,
269
+ username: s.username,
270
+ authMethod: s.authMethod,
271
+ // Exclude: password, privateKeyPath for security
272
+ })),
273
+ settings: config.settings,
274
+ };
275
+ return {
276
+ content: [
277
+ {
278
+ type: 'text',
279
+ text: JSON.stringify(sanitizedConfig, null, 2),
280
+ },
281
+ ],
282
+ };
283
+ }
284
+ case 'ssh_help': {
285
+ const topic = args.topic;
286
+ const helpContent = getHelpContent(topic);
287
+ return {
288
+ content: [
289
+ {
290
+ type: 'text',
291
+ text: helpContent,
292
+ },
293
+ ],
294
+ };
295
+ }
296
+ case 'ssh_connect': {
297
+ const serverId = args.serverId;
298
+ const serverConfig = config.servers.find(s => s.id === serverId);
299
+ if (!serverConfig) {
300
+ return {
301
+ content: [
302
+ {
303
+ type: 'text',
304
+ text: JSON.stringify({ error: `Server ${serverId} not found` }, null, 2),
305
+ },
306
+ ],
307
+ isError: true,
308
+ };
309
+ }
310
+ try {
311
+ const connectionId = await sshManager.connect(serverConfig);
312
+ return {
313
+ content: [
314
+ {
315
+ type: 'text',
316
+ text: JSON.stringify({ connectionId, status: 'connected' }, null, 2),
317
+ },
318
+ ],
319
+ };
320
+ }
321
+ catch (err) {
322
+ return {
323
+ content: [
324
+ {
325
+ type: 'text',
326
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2),
327
+ },
328
+ ],
329
+ isError: true,
330
+ };
331
+ }
332
+ }
333
+ case 'ssh_exec': {
334
+ const command = args.command;
335
+ const connectionId = args.connectionId;
336
+ const timeout = args.timeout;
337
+ const cwd = args.cwd;
338
+ try {
339
+ const result = await sshManager.exec(command, connectionId, { timeout, cwd });
340
+ return {
341
+ content: [
342
+ {
343
+ type: 'text',
344
+ text: JSON.stringify(result, null, 2),
345
+ },
346
+ ],
347
+ };
348
+ }
349
+ catch (err) {
350
+ return {
351
+ content: [
352
+ {
353
+ type: 'text',
354
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2),
355
+ },
356
+ ],
357
+ isError: true,
358
+ };
359
+ }
360
+ }
361
+ case 'ssh_get_status': {
362
+ const connectionId = args.connectionId;
363
+ if (connectionId) {
364
+ const status = sshManager.getStatus(connectionId);
365
+ return {
366
+ content: [
367
+ {
368
+ type: 'text',
369
+ text: JSON.stringify(status, null, 2),
370
+ },
371
+ ],
372
+ };
373
+ }
374
+ else {
375
+ const statuses = sshManager.getAllStatuses();
376
+ return {
377
+ content: [
378
+ {
379
+ type: 'text',
380
+ text: JSON.stringify(statuses, null, 2),
381
+ },
382
+ ],
383
+ };
384
+ }
385
+ }
386
+ case 'ssh_disconnect': {
387
+ const connectionId = args.connectionId;
388
+ try {
389
+ sshManager.disconnect(connectionId);
390
+ return {
391
+ content: [
392
+ {
393
+ type: 'text',
394
+ text: JSON.stringify({ success: true }, null, 2),
395
+ },
396
+ ],
397
+ };
398
+ }
399
+ catch (err) {
400
+ return {
401
+ content: [
402
+ {
403
+ type: 'text',
404
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2),
405
+ },
406
+ ],
407
+ isError: true,
408
+ };
409
+ }
410
+ }
411
+ case 'ssh_add_server': {
412
+ const server = {
413
+ id: args.id,
414
+ name: args.name,
415
+ host: args.host,
416
+ port: args.port || 22,
417
+ username: args.username,
418
+ authMethod: args.authMethod || 'key',
419
+ privateKeyPath: args.privateKeyPath,
420
+ password: args.password,
421
+ };
422
+ try {
423
+ addServer(server);
424
+ // Update in-memory config
425
+ config.servers.push(server);
426
+ return {
427
+ content: [
428
+ {
429
+ type: 'text',
430
+ text: JSON.stringify({ success: true, message: `Server "${server.id}" added` }, null, 2),
431
+ },
432
+ ],
433
+ };
434
+ }
435
+ catch (err) {
436
+ return {
437
+ content: [
438
+ {
439
+ type: 'text',
440
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2),
441
+ },
442
+ ],
443
+ isError: true,
444
+ };
445
+ }
446
+ }
447
+ case 'ssh_remove_server': {
448
+ const serverId = args.serverId;
449
+ try {
450
+ // Disconnect active connections first
451
+ sshManager.disconnectByServerId(serverId);
452
+ removeServer(serverId);
453
+ // Update in-memory config
454
+ const index = config.servers.findIndex(s => s.id === serverId);
455
+ if (index !== -1) {
456
+ config.servers.splice(index, 1);
457
+ }
458
+ return {
459
+ content: [
460
+ {
461
+ type: 'text',
462
+ text: JSON.stringify({ success: true, message: `Server "${serverId}" removed` }, null, 2),
463
+ },
464
+ ],
465
+ };
466
+ }
467
+ catch (err) {
468
+ return {
469
+ content: [
470
+ {
471
+ type: 'text',
472
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2),
473
+ },
474
+ ],
475
+ isError: true,
476
+ };
477
+ }
478
+ }
479
+ case 'ssh_update_server': {
480
+ const serverId = args.serverId;
481
+ const updates = {};
482
+ if (args.name !== undefined) {
483
+ updates.name = args.name;
484
+ }
485
+ if (args.host !== undefined) {
486
+ updates.host = args.host;
487
+ }
488
+ if (args.port !== undefined) {
489
+ updates.port = args.port;
490
+ }
491
+ if (args.username !== undefined) {
492
+ updates.username = args.username;
493
+ }
494
+ if (args.authMethod !== undefined) {
495
+ updates.authMethod = args.authMethod;
496
+ }
497
+ if (args.privateKeyPath !== undefined) {
498
+ updates.privateKeyPath = args.privateKeyPath;
499
+ }
500
+ if (args.password !== undefined) {
501
+ updates.password = args.password;
502
+ }
503
+ try {
504
+ updateServer(serverId, updates);
505
+ // Update in-memory config
506
+ const server = config.servers.find(s => s.id === serverId);
507
+ if (server) {
508
+ Object.assign(server, updates);
509
+ }
510
+ return {
511
+ content: [
512
+ {
513
+ type: 'text',
514
+ text: JSON.stringify({ success: true, message: `Server "${serverId}" updated` }, null, 2),
515
+ },
516
+ ],
517
+ };
518
+ }
519
+ catch (err) {
520
+ return {
521
+ content: [
522
+ {
523
+ type: 'text',
524
+ text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2),
525
+ },
526
+ ],
527
+ isError: true,
528
+ };
529
+ }
530
+ }
531
+ default:
532
+ return {
533
+ content: [],
534
+ isError: true,
535
+ };
536
+ }
537
+ });
538
+ // Cleanup on exit
539
+ process.on('SIGINT', () => {
540
+ sshManager.cleanup();
541
+ process.exit(0);
542
+ });
543
+ process.on('SIGTERM', () => {
544
+ sshManager.cleanup();
545
+ process.exit(0);
546
+ });
547
+ // Start server
548
+ const transport = new StdioServerTransport();
549
+ await server.connect(transport);
550
+ console.error('mcpHydroSSH MCP Server running on stdio');
551
+ }
552
+ // ===== Help content =====
553
+ function getHelpContent(topic) {
554
+ if (topic === 'config') {
555
+ return `# Config Help
556
+
557
+ **Config file location:** \`~/.hydrossh/config.json\`
558
+
559
+ **Server fields:**
560
+ - \`id\` (required): Unique server identifier
561
+ - \`name\` (required): Display name
562
+ - \`host\` (required): Server hostname or IP
563
+ - \`port\`: SSH port (default: 22)
564
+ - \`username\`: SSH username
565
+ - \`authMethod\`: "agent" | "key" | "password" (default: "agent")
566
+ - \`privateKeyPath\`: Path to private key (for "key" auth)
567
+ - \`password\`: Password (for "password" auth)
568
+
569
+ **Example:**
570
+ \`\`\`json
571
+ {
572
+ "id": "my-server",
573
+ "name": "My Server",
574
+ "host": "1.2.3.4",
575
+ "username": "root",
576
+ "authMethod": "key",
577
+ "privateKeyPath": "~/.ssh/id_rsa"
578
+ }
579
+ \`\`\``;
580
+ }
581
+ if (topic === 'connect') {
582
+ return `# Connection Help
583
+
584
+ **Tools:**
585
+ - \`ssh_list_servers\` - List configured servers
586
+ - \`ssh_connect\` - Connect to a server (params: serverId, timeout?)
587
+ - \`ssh_get_status\` - Check connection status
588
+ - \`ssh_disconnect\` - Disconnect from server
589
+
590
+ **Note:** \`connectionId\` is optional for most tools - uses most recent connection if not provided.`;
591
+ }
592
+ if (topic === 'exec') {
593
+ return `# Command Execution Help
594
+
595
+ **Tool:** \`ssh_exec\`
596
+
597
+ **Params:**
598
+ - \`command\` (required): Command to execute
599
+ - \`connectionId\` (optional): Which connection to use
600
+ - \`timeout\` (optional): Command timeout in ms
601
+ - \`cwd\` (optional): Working directory
602
+
603
+ **Example:**
604
+ \`\`\`json
605
+ {
606
+ "command": "ls -la",
607
+ "cwd": "/var/www"
608
+ }
609
+ \`\`\``;
610
+ }
611
+ if (topic === 'auth') {
612
+ return `# Authentication Help
613
+
614
+ **Methods:**
615
+
616
+ 1. **agent** (recommended for security)
617
+ - Uses system SSH agent
618
+ - Requires: \`ssh-agent\` service running
619
+ - Requires: \`ssh-add your-key.pem\`
620
+
621
+ 2. **key** (simplest)
622
+ - Direct key file access
623
+ - Config: \`"authMethod": "key", "privateKeyPath": "~/.ssh/id_rsa"\`
624
+
625
+ 3. **password** (not recommended)
626
+ - Plain password auth
627
+ - Config: \`"authMethod": "password", "password": "xxx"\`
628
+ - ⚠️ Password stored in config file!`;
629
+ }
630
+ if (topic === 'examples') {
631
+ return `# Usage Examples
632
+
633
+ **List servers:**
634
+ \`\`\`
635
+ ssh_list_servers
636
+ \`\`\`
637
+
638
+ **Connect:**
639
+ \`\`\`
640
+ ssh_connect { "serverId": "my-server" }
641
+ \`\`\`
642
+
643
+ **Execute command:**
644
+ \`\`\`
645
+ ssh_exec { "command": "uptime" }
646
+ \`\`\`
647
+
648
+ **Add server:**
649
+ \`\`\`
650
+ ssh_add_server {
651
+ "id": "new-server",
652
+ "name": "New Server",
653
+ "host": "1.2.3.4",
654
+ "username": "root",
655
+ "authMethod": "key",
656
+ "privateKeyPath": "~/.ssh/id_rsa"
657
+ }
658
+ \`\`\`
659
+
660
+ **Update server:**
661
+ \`\`\`
662
+ ssh_update_server {
663
+ "serverId": "my-server",
664
+ "host": "new-ip.com"
665
+ }
666
+ \`\`\`
667
+
668
+ **Remove server:**
669
+ \`\`\`
670
+ ssh_remove_server { "serverId": "my-server" }
671
+ \`\`\``;
672
+ }
673
+ // Default - full help
674
+ return `# mcpHydroSSH Help
675
+
676
+ **Quick Start:**
677
+ 1. Say "list servers" to see configured servers
678
+ 2. Say "connect to [server-name]" to connect
679
+ 3. Say "run [command]" to execute commands
680
+
681
+ ## Tools
682
+
683
+ ### Connection
684
+ - \`ssh_list_servers\` - List servers
685
+ - \`ssh_connect\` - Connect (params: serverId, timeout?)
686
+ - \`ssh_exec\` - Run command (params: command, connectionId?, timeout?, cwd?)
687
+ - \`ssh_get_status\` - Check status
688
+ - \`ssh_disconnect\` - Disconnect
689
+
690
+ ### Config Management
691
+ - \`ssh_add_server\` - Add server (params: id, name, host, username, authMethod?, privateKeyPath?, password?)
692
+ - \`ssh_update_server\` - Update server (params: serverId, +optional fields)
693
+ - \`ssh_remove_server\` - Remove server (params: serverId)
694
+ - \`ssh_view_config\` - View full configuration
695
+
696
+ ### Help
697
+ - \`ssh_help\` - Show this help
698
+ - \`ssh_help { topic: "config" }\` - Config help
699
+ - \`ssh_help { topic: "connect" }\` - Connection help
700
+ - \`ssh_help { topic: "auth" }\` - Authentication help
701
+ - \`ssh_help { topic: "examples" }\` - Usage examples
702
+
703
+ **Config file:** \`~/.hydrossh/config.json\`
704
+ `;
705
+ }
706
+ main().catch((err) => {
707
+ console.error('Server error:', err);
708
+ process.exit(1);
709
+ });
@@ -0,0 +1,302 @@
1
+ import { Client } from 'ssh2';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import { homedir } from 'os';
6
+ function expandUser(filePath) {
7
+ if (filePath.startsWith('~')) {
8
+ return path.join(homedir(), filePath.slice(1));
9
+ }
10
+ return filePath;
11
+ }
12
+ /**
13
+ * Escape a shell command argument to prevent injection attacks.
14
+ * Wraps the value in single quotes and escapes any single quotes within.
15
+ */
16
+ function shellEscape(value) {
17
+ // Use single quotes and escape any single quotes inside
18
+ return "'" + value.replace(/'/g, "'\\''") + "'";
19
+ }
20
+ export class SSHManager {
21
+ connections = new Map();
22
+ lastConnectionId = null;
23
+ commandTimeout;
24
+ keepaliveInterval;
25
+ maxConnections;
26
+ autoReconnect;
27
+ logCommands;
28
+ constructor(options) {
29
+ this.commandTimeout = options.commandTimeout;
30
+ this.keepaliveInterval = options.keepaliveInterval;
31
+ this.maxConnections = options.maxConnections || 5;
32
+ this.autoReconnect = options.autoReconnect || false;
33
+ this.logCommands = options.logCommands || true;
34
+ }
35
+ /**
36
+ * Connect to an SSH server.
37
+ * @param serverConfig - The server configuration containing connection details
38
+ * @returns A promise that resolves to the connection ID
39
+ * @throws Error if max connections limit is reached or connection fails
40
+ */
41
+ async connect(serverConfig) {
42
+ // Check max connections limit
43
+ if (this.connections.size >= this.maxConnections) {
44
+ throw new Error(`Max connections limit reached (${this.maxConnections})`);
45
+ }
46
+ const connectionId = uuidv4();
47
+ const client = new Client();
48
+ return new Promise((resolve, reject) => {
49
+ const timeoutMs = serverConfig.connectTimeout || 30000;
50
+ let isResolved = false;
51
+ const timeout = setTimeout(() => {
52
+ if (!isResolved) {
53
+ client.end(); // Clean up resources on timeout
54
+ reject(new Error('Connection timeout'));
55
+ }
56
+ }, timeoutMs);
57
+ client.on('ready', () => {
58
+ clearTimeout(timeout);
59
+ isResolved = true;
60
+ const connection = {
61
+ id: connectionId,
62
+ serverId: serverConfig.id,
63
+ client,
64
+ connectedAt: new Date(),
65
+ lastActivity: new Date(),
66
+ isBusy: false,
67
+ serverConfig: { ...serverConfig }, // Store for auto-reconnect
68
+ };
69
+ this.connections.set(connectionId, connection);
70
+ this.lastConnectionId = connectionId;
71
+ resolve(connectionId);
72
+ });
73
+ client.on('error', (err) => {
74
+ clearTimeout(timeout);
75
+ if (!isResolved) {
76
+ reject(err);
77
+ }
78
+ });
79
+ // Handle connection end/close
80
+ const handleConnectionClose = () => {
81
+ if (this.logCommands) {
82
+ console.error(`[SSH] Connection ${connectionId} closed`);
83
+ }
84
+ this.connections.delete(connectionId);
85
+ if (this.lastConnectionId === connectionId) {
86
+ const remaining = Array.from(this.connections.keys());
87
+ this.lastConnectionId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
88
+ }
89
+ };
90
+ client.on('end', handleConnectionClose);
91
+ client.on('close', handleConnectionClose);
92
+ // Build connect options
93
+ const connectOptions = {
94
+ host: serverConfig.host,
95
+ port: serverConfig.port,
96
+ username: serverConfig.username,
97
+ };
98
+ // Auth method
99
+ if (serverConfig.authMethod === 'agent') {
100
+ connectOptions.agent = this.getAgentPath();
101
+ connectOptions.agentForward = true;
102
+ }
103
+ else if (serverConfig.authMethod === 'key' && serverConfig.privateKeyPath) {
104
+ const keyPath = expandUser(serverConfig.privateKeyPath);
105
+ connectOptions.privateKey = fs.readFileSync(keyPath);
106
+ }
107
+ else if (serverConfig.authMethod === 'password' && serverConfig.password) {
108
+ connectOptions.password = serverConfig.password;
109
+ }
110
+ if (serverConfig.keepaliveInterval !== undefined) {
111
+ connectOptions.keepaliveInterval = serverConfig.keepaliveInterval;
112
+ }
113
+ else if (this.keepaliveInterval > 0) {
114
+ connectOptions.keepaliveInterval = this.keepaliveInterval;
115
+ }
116
+ client.connect(connectOptions);
117
+ });
118
+ }
119
+ /**
120
+ * Execute a command on the connected SSH server.
121
+ * @param command - The command to execute
122
+ * @param connectionId - Optional connection ID (uses most recent if not provided)
123
+ * @param options - Optional execution options
124
+ * @param options.timeout - Command timeout in milliseconds
125
+ * @param options.cwd - Working directory for command execution
126
+ * @returns A promise that resolves to the execution result (stdout, stderr, exitCode, duration)
127
+ * @throws Error if no connection is available or connection is busy
128
+ */
129
+ async exec(command, connectionId, options) {
130
+ const conn = this.getConnection(connectionId);
131
+ if (!conn) {
132
+ throw new Error('No connection available');
133
+ }
134
+ if (conn.isBusy) {
135
+ throw new Error('Connection is busy');
136
+ }
137
+ conn.isBusy = true;
138
+ conn.lastActivity = new Date();
139
+ const startTime = Date.now();
140
+ // Log command execution if enabled
141
+ if (this.logCommands) {
142
+ console.error(`[SSH] Executing: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
143
+ }
144
+ try {
145
+ // Use shell escape to prevent command injection
146
+ const fullCommand = options?.cwd
147
+ ? `cd ${shellEscape(options.cwd)} && ${command}`
148
+ : command;
149
+ return await new Promise((resolve, reject) => {
150
+ const timeoutMs = options?.timeout || this.commandTimeout;
151
+ const timeout = setTimeout(() => {
152
+ reject(new Error('Command timeout'));
153
+ }, timeoutMs);
154
+ conn.client.exec(fullCommand, (err, stream) => {
155
+ if (err) {
156
+ clearTimeout(timeout);
157
+ reject(err);
158
+ return;
159
+ }
160
+ let stdout = '';
161
+ let stderr = '';
162
+ let exitCode = 0;
163
+ stream.on('data', (data) => {
164
+ stdout += data.toString();
165
+ });
166
+ stream.stderr.on('data', (data) => {
167
+ stderr += data.toString();
168
+ });
169
+ stream.on('close', (code) => {
170
+ clearTimeout(timeout);
171
+ exitCode = code ?? 0;
172
+ resolve({
173
+ stdout,
174
+ stderr,
175
+ exitCode,
176
+ duration: Date.now() - startTime,
177
+ });
178
+ });
179
+ });
180
+ });
181
+ }
182
+ finally {
183
+ conn.isBusy = false;
184
+ conn.lastActivity = new Date();
185
+ // Log completion if enabled
186
+ if (this.logCommands) {
187
+ console.error(`[SSH] Command completed in ${Date.now() - startTime}ms`);
188
+ }
189
+ }
190
+ }
191
+ /**
192
+ * Disconnect from an SSH server.
193
+ * @param connectionId - Optional connection ID (disconnects most recent if not provided)
194
+ */
195
+ disconnect(connectionId) {
196
+ const conn = this.getConnection(connectionId, false);
197
+ if (!conn) {
198
+ return;
199
+ }
200
+ conn.client.end();
201
+ this.connections.delete(conn.id);
202
+ if (this.lastConnectionId === conn.id) {
203
+ const remaining = Array.from(this.connections.keys());
204
+ this.lastConnectionId = remaining.length > 0 ? remaining[remaining.length - 1] : null;
205
+ }
206
+ }
207
+ /**
208
+ * Get the status of a specific connection.
209
+ * @param connectionId - Optional connection ID (returns null if not provided and no connections)
210
+ * @returns Connection status or null if not found
211
+ */
212
+ getStatus(connectionId) {
213
+ const conn = this.getConnection(connectionId, false);
214
+ if (!conn) {
215
+ return null;
216
+ }
217
+ return {
218
+ connectionId: conn.id,
219
+ serverId: conn.serverId,
220
+ status: 'connected',
221
+ connectedAt: conn.connectedAt.toISOString(),
222
+ lastActivity: conn.lastActivity.toISOString(),
223
+ isBusy: conn.isBusy,
224
+ };
225
+ }
226
+ /**
227
+ * Get the status of all active connections.
228
+ * @returns Array of connection statuses
229
+ */
230
+ getAllStatuses() {
231
+ return Array.from(this.connections.values()).map(conn => ({
232
+ connectionId: conn.id,
233
+ serverId: conn.serverId,
234
+ status: 'connected',
235
+ connectedAt: conn.connectedAt.toISOString(),
236
+ lastActivity: conn.lastActivity.toISOString(),
237
+ isBusy: conn.isBusy,
238
+ }));
239
+ }
240
+ /**
241
+ * Disconnect all connections associated with a specific server.
242
+ * Used when removing a server from config to ensure clean cleanup.
243
+ * @param serverId - The server ID to disconnect
244
+ */
245
+ disconnectByServerId(serverId) {
246
+ const toRemove = [];
247
+ for (const conn of this.connections.values()) {
248
+ if (conn.serverId === serverId) {
249
+ toRemove.push(conn.id);
250
+ }
251
+ }
252
+ for (const id of toRemove) {
253
+ this.disconnect(id);
254
+ }
255
+ }
256
+ /**
257
+ * Clean up all connections and release resources.
258
+ * Called on process exit to ensure proper cleanup.
259
+ */
260
+ cleanup() {
261
+ for (const conn of this.connections.values()) {
262
+ conn.client.end();
263
+ }
264
+ this.connections.clear();
265
+ this.lastConnectionId = null;
266
+ }
267
+ // ===== Private methods =====
268
+ /**
269
+ * Get a connection by ID or the last used connection.
270
+ * @param connectionId - Optional connection ID
271
+ * @param throwIfMissing - Whether to throw an error if connection not found (default: true)
272
+ * @returns The connection or null if not found
273
+ * @throws Error if throwIfMissing is true and no connection is available
274
+ */
275
+ getConnection(connectionId, throwIfMissing = true) {
276
+ const id = connectionId || this.lastConnectionId;
277
+ if (!id) {
278
+ if (throwIfMissing) {
279
+ throw new Error('No connection available');
280
+ }
281
+ return null;
282
+ }
283
+ const conn = this.connections.get(id);
284
+ if (!conn) {
285
+ if (throwIfMissing) {
286
+ throw new Error(`Connection ${id} not found`);
287
+ }
288
+ return null;
289
+ }
290
+ return conn;
291
+ }
292
+ /**
293
+ * Get the SSH agent path for the current platform.
294
+ * @returns The SSH agent pipe path (Windows) or SSH_AUTH_SOCK environment variable (Unix)
295
+ */
296
+ getAgentPath() {
297
+ if (process.platform === 'win32') {
298
+ return '\\\\.\\pipe\\openssh-ssh-agent';
299
+ }
300
+ return process.env.SSH_AUTH_SOCK;
301
+ }
302
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ {
2
+ "servers": [
3
+ {
4
+ "id": "example-server",
5
+ "name": "Example Server",
6
+ "host": "example.com",
7
+ "port": 22,
8
+ "username": "deploy",
9
+ "authMethod": "key"
10
+ }
11
+ ],
12
+ "settings": {
13
+ "defaultConnectTimeout": 30000,
14
+ "defaultKeepaliveInterval": 60000,
15
+ "commandTimeout": 60000,
16
+ "maxConnections": 5,
17
+ "autoReconnect": false,
18
+ "logCommands": true
19
+ }
20
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "mcp-hydrocoder-ssh",
3
+ "version": "0.1.0",
4
+ "description": "SSH MCP Server for Claude Code",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "mcp-hydrocoder-ssh": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/index.js",
12
+ "dist/ssh-manager.js",
13
+ "dist/config.js",
14
+ "dist/types.js",
15
+ "example-config.json",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "dev": "tsx watch src/index.ts",
22
+ "start": "node dist/index.js",
23
+ "lint": "eslint src/",
24
+ "lint:fix": "eslint src/ --fix",
25
+ "format": "prettier --write src/",
26
+ "format:check": "prettier --check src/",
27
+ "test": "vitest run",
28
+ "test:watch": "vitest"
29
+ },
30
+ "keywords": ["mcp", "ssh", "claude"],
31
+ "author": "",
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.0.1",
35
+ "ssh2": "^1.15.0",
36
+ "uuid": "^9.0.1",
37
+ "zod": "^3.22.4"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.11.0",
41
+ "@types/ssh2": "^1.11.19",
42
+ "@types/uuid": "^9.0.7",
43
+ "@typescript-eslint/eslint-plugin": "^6.19.0",
44
+ "@typescript-eslint/parser": "^6.19.0",
45
+ "eslint": "^8.56.0",
46
+ "prettier": "^3.2.4",
47
+ "tsx": "^4.7.0",
48
+ "typescript": "^5.3.3",
49
+ "vitest": "^1.2.1"
50
+ }
51
+ }