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 +21 -0
- package/README.md +92 -0
- package/dist/config.js +166 -0
- package/dist/index.js +709 -0
- package/dist/ssh-manager.js +302 -0
- package/dist/types.js +1 -0
- package/example-config.json +20 -0
- package/package.json +51 -0
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
|
+
}
|