@pyrokine/mcp-ssh 1.0.0 → 1.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/README.md +84 -14
- package/README_zh.md +84 -14
- package/dist/index.js +175 -16
- package/dist/ssh-config.d.ts +39 -0
- package/dist/ssh-config.js +216 -0
- package/package.json +2 -2
- package/src/index.ts +184 -16
- package/src/ssh-config.ts +264 -0
package/README.md
CHANGED
|
@@ -8,9 +8,14 @@ A comprehensive SSH MCP Server for AI assistants (Claude, Cursor, Windsurf, etc.
|
|
|
8
8
|
[](https://nodejs.org/)
|
|
9
9
|
[](https://modelcontextprotocol.io/)
|
|
10
10
|
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+
|
|
11
15
|
## Features
|
|
12
16
|
|
|
13
17
|
- **Multiple Authentication**: Password, SSH key, SSH agent
|
|
18
|
+
- **SSH Config Support**: Read `~/.ssh/config` with Host aliases, `Host *` inheritance, ProxyJump (`user@host:port`)
|
|
14
19
|
- **Connection Management**: Connection pooling, keepalive, auto-reconnect
|
|
15
20
|
- **Session Persistence**: Sessions info saved for reconnection
|
|
16
21
|
- **Command Execution**:
|
|
@@ -19,6 +24,7 @@ A comprehensive SSH MCP Server for AI assistants (Claude, Cursor, Windsurf, etc.
|
|
|
19
24
|
- `sudo` execution
|
|
20
25
|
- `su` (switch user) execution - *run commands as different user*
|
|
21
26
|
- Batch execution
|
|
27
|
+
- Parallel execution on multiple hosts
|
|
22
28
|
- **Persistent PTY Sessions**: For long-running interactive commands (top, htop, tmux, vim, etc.)
|
|
23
29
|
- Output buffering with polling read
|
|
24
30
|
- Send keystrokes and commands
|
|
@@ -42,9 +48,17 @@ A comprehensive SSH MCP Server for AI assistants (Claude, Cursor, Windsurf, etc.
|
|
|
42
48
|
|
|
43
49
|
## Installation
|
|
44
50
|
|
|
51
|
+
### npm (Recommended)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install -g @pyrokine/mcp-ssh
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### From Source
|
|
58
|
+
|
|
45
59
|
```bash
|
|
46
60
|
git clone https://github.com/Pyrokine/claude-mcp-tools.git
|
|
47
|
-
cd claude-mcp-tools/ssh
|
|
61
|
+
cd claude-mcp-tools/mcp-ssh
|
|
48
62
|
npm install
|
|
49
63
|
npm run build
|
|
50
64
|
```
|
|
@@ -72,16 +86,17 @@ Add to your MCP settings (e.g., `~/.claude/settings.json` or client-specific con
|
|
|
72
86
|
}
|
|
73
87
|
```
|
|
74
88
|
|
|
75
|
-
## Available Tools (
|
|
89
|
+
## Available Tools (29 tools)
|
|
76
90
|
|
|
77
91
|
### Connection Management
|
|
78
92
|
|
|
79
93
|
| Tool | Description |
|
|
80
94
|
|------|-------------|
|
|
81
|
-
| `ssh_connect` | Establish SSH connection
|
|
95
|
+
| `ssh_connect` | Establish SSH connection (supports ~/.ssh/config) |
|
|
82
96
|
| `ssh_disconnect` | Close connection |
|
|
83
97
|
| `ssh_list_sessions` | List active sessions |
|
|
84
98
|
| `ssh_reconnect` | Reconnect a disconnected session |
|
|
99
|
+
| `ssh_config_list` | List hosts from ~/.ssh/config |
|
|
85
100
|
|
|
86
101
|
### Command Execution
|
|
87
102
|
|
|
@@ -91,6 +106,7 @@ Add to your MCP settings (e.g., `~/.claude/settings.json` or client-specific con
|
|
|
91
106
|
| `ssh_exec_as_user` | Execute as different user (via `su`) |
|
|
92
107
|
| `ssh_exec_sudo` | Execute with `sudo` |
|
|
93
108
|
| `ssh_exec_batch` | Execute multiple commands sequentially |
|
|
109
|
+
| `ssh_exec_parallel` | Execute command on multiple hosts in parallel |
|
|
94
110
|
| `ssh_quick_exec` | One-shot: connect, execute, disconnect |
|
|
95
111
|
|
|
96
112
|
### File Operations
|
|
@@ -128,12 +144,74 @@ Add to your MCP settings (e.g., `~/.claude/settings.json` or client-specific con
|
|
|
128
144
|
|
|
129
145
|
## Usage Examples
|
|
130
146
|
|
|
147
|
+
### Using SSH Config (Recommended)
|
|
148
|
+
|
|
149
|
+
If you have hosts configured in `~/.ssh/config`:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
# List available hosts
|
|
153
|
+
ssh_config_list()
|
|
154
|
+
|
|
155
|
+
# Connect using config host name
|
|
156
|
+
ssh_connect(configHost="myserver")
|
|
157
|
+
ssh_exec(alias="myserver", command="uptime")
|
|
158
|
+
|
|
159
|
+
# Use custom config file path
|
|
160
|
+
ssh_connect(configHost="myserver", configPath="/custom/path/config")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Supported SSH config features:
|
|
164
|
+
- `Host` with multiple aliases (e.g., `Host a b c`)
|
|
165
|
+
- `Host *` global defaults inheritance (first `Host *` block only)
|
|
166
|
+
- `HostName`, `User`, `Port`, `IdentityFile`
|
|
167
|
+
- `ProxyJump` with `user@host:port` format (first hop only)
|
|
168
|
+
- Explicit parameters override config values (e.g., `ssh_connect(configHost="x", user="override")`)
|
|
169
|
+
|
|
170
|
+
**Not supported** (skipped):
|
|
171
|
+
- `Include` directive
|
|
172
|
+
- `Match` blocks (entire block skipped until next `Host`)
|
|
173
|
+
- Wildcard patterns (e.g., `Host *.example.com`)
|
|
174
|
+
|
|
175
|
+
**Behavior notes**:
|
|
176
|
+
- Multiple `Host *` blocks: only first is used
|
|
177
|
+
- Duplicate Host definitions: `ssh_config_list` shows all, `ssh_connect` uses first
|
|
178
|
+
- IPv6 in ProxyJump: use bracket notation `[2001:db8::1]:22`
|
|
179
|
+
|
|
180
|
+
### Parallel Execution on Multiple Hosts
|
|
181
|
+
|
|
182
|
+
Execute the same command on multiple connected hosts:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
1. ssh_connect(configHost="server1")
|
|
186
|
+
2. ssh_connect(configHost="server2")
|
|
187
|
+
3. ssh_connect(configHost="server3")
|
|
188
|
+
4. ssh_exec_parallel(aliases=["server1", "server2", "server3"], command="uptime")
|
|
189
|
+
```
|
|
190
|
+
|
|
131
191
|
### Basic: Connect and Execute
|
|
132
192
|
|
|
133
193
|
```
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
194
|
+
ssh_connect(host="192.168.1.100", user="root", keyPath="/home/.ssh/id_rsa", alias="myserver")
|
|
195
|
+
ssh_exec(alias="myserver", command="ls -la /home")
|
|
196
|
+
ssh_disconnect(alias="myserver")
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Jump Host (Bastion)
|
|
200
|
+
|
|
201
|
+
Connect to internal server via jump host:
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
ssh_connect(
|
|
205
|
+
host="10.0.0.5",
|
|
206
|
+
user="root",
|
|
207
|
+
keyPath="/home/.ssh/id_rsa",
|
|
208
|
+
alias="internal",
|
|
209
|
+
jumpHost={
|
|
210
|
+
host: "bastion.example.com",
|
|
211
|
+
user: "admin",
|
|
212
|
+
keyPath: "/home/.ssh/bastion_key"
|
|
213
|
+
}
|
|
214
|
+
)
|
|
137
215
|
```
|
|
138
216
|
|
|
139
217
|
### Switch User Execution (su)
|
|
@@ -331,14 +409,6 @@ mcp-ssh/
|
|
|
331
409
|
└── README.md
|
|
332
410
|
```
|
|
333
411
|
|
|
334
|
-
## Roadmap
|
|
335
|
-
|
|
336
|
-
- [ ] Dynamic port forwarding (SOCKS proxy)
|
|
337
|
-
- [ ] SSH Agent forwarding
|
|
338
|
-
- [ ] Command history and audit logging
|
|
339
|
-
- [ ] Multi-host parallel execution
|
|
340
|
-
- [ ] SSH config file (~/.ssh/config) auto-discovery
|
|
341
|
-
|
|
342
412
|
## Contributing
|
|
343
413
|
|
|
344
414
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
package/README_zh.md
CHANGED
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
[](https://nodejs.org/)
|
|
9
9
|
[](https://modelcontextprotocol.io/)
|
|
10
10
|
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+
|
|
11
15
|
## 功能特性
|
|
12
16
|
|
|
13
17
|
- **多种认证方式**:密码、SSH 密钥、SSH Agent
|
|
18
|
+
- **SSH Config 支持**:读取 `~/.ssh/config`,支持 Host 多别名、`Host *` 继承、ProxyJump(`user@host:port` 格式)
|
|
14
19
|
- **连接管理**:连接池复用、心跳保持、自动重连
|
|
15
20
|
- **会话持久化**:会话信息保存,支持重连
|
|
16
21
|
- **命令执行**:
|
|
@@ -19,6 +24,7 @@
|
|
|
19
24
|
- `sudo` 执行
|
|
20
25
|
- `su` 切换用户执行
|
|
21
26
|
- 批量执行
|
|
27
|
+
- 多主机并行执行
|
|
22
28
|
- **持久化 PTY 会话**:用于长时间运行的交互式命令(top、htop、tmux、vim 等)
|
|
23
29
|
- 输出缓冲区,支持轮询读取
|
|
24
30
|
- 发送按键和命令
|
|
@@ -42,9 +48,17 @@
|
|
|
42
48
|
|
|
43
49
|
## 安装
|
|
44
50
|
|
|
51
|
+
### npm(推荐)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install -g @pyrokine/mcp-ssh
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 从源码安装
|
|
58
|
+
|
|
45
59
|
```bash
|
|
46
60
|
git clone https://github.com/Pyrokine/claude-mcp-tools.git
|
|
47
|
-
cd claude-mcp-tools/ssh
|
|
61
|
+
cd claude-mcp-tools/mcp-ssh
|
|
48
62
|
npm install
|
|
49
63
|
npm run build
|
|
50
64
|
```
|
|
@@ -72,16 +86,17 @@ claude mcp add ssh -- node /path/to/mcp-ssh/dist/index.js
|
|
|
72
86
|
}
|
|
73
87
|
```
|
|
74
88
|
|
|
75
|
-
## 可用工具(
|
|
89
|
+
## 可用工具(29 个)
|
|
76
90
|
|
|
77
91
|
### 连接管理
|
|
78
92
|
|
|
79
93
|
| 工具 | 描述 |
|
|
80
94
|
|------|------|
|
|
81
|
-
| `ssh_connect` | 建立 SSH
|
|
95
|
+
| `ssh_connect` | 建立 SSH 连接(支持 ~/.ssh/config) |
|
|
82
96
|
| `ssh_disconnect` | 关闭连接 |
|
|
83
97
|
| `ssh_list_sessions` | 列出活跃会话 |
|
|
84
98
|
| `ssh_reconnect` | 重新连接断开的会话 |
|
|
99
|
+
| `ssh_config_list` | 列出 ~/.ssh/config 中的 Host |
|
|
85
100
|
|
|
86
101
|
### 命令执行
|
|
87
102
|
|
|
@@ -91,6 +106,7 @@ claude mcp add ssh -- node /path/to/mcp-ssh/dist/index.js
|
|
|
91
106
|
| `ssh_exec_as_user` | 以其他用户身份执行(通过 `su`) |
|
|
92
107
|
| `ssh_exec_sudo` | 使用 `sudo` 执行 |
|
|
93
108
|
| `ssh_exec_batch` | 批量执行多条命令 |
|
|
109
|
+
| `ssh_exec_parallel` | 在多台主机上并行执行命令 |
|
|
94
110
|
| `ssh_quick_exec` | 一次性执行:连接、执行、断开 |
|
|
95
111
|
|
|
96
112
|
### 文件操作
|
|
@@ -128,12 +144,74 @@ claude mcp add ssh -- node /path/to/mcp-ssh/dist/index.js
|
|
|
128
144
|
|
|
129
145
|
## 使用示例
|
|
130
146
|
|
|
147
|
+
### 使用 SSH Config(推荐)
|
|
148
|
+
|
|
149
|
+
如果已在 `~/.ssh/config` 中配置了主机:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
# 列出可用主机
|
|
153
|
+
ssh_config_list()
|
|
154
|
+
|
|
155
|
+
# 使用配置的主机名连接
|
|
156
|
+
ssh_connect(configHost="myserver")
|
|
157
|
+
ssh_exec(alias="myserver", command="uptime")
|
|
158
|
+
|
|
159
|
+
# 使用自定义配置文件路径
|
|
160
|
+
ssh_connect(configHost="myserver", configPath="/custom/path/config")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
支持的 SSH config 特性:
|
|
164
|
+
- `Host` 多别名(如 `Host a b c`)
|
|
165
|
+
- `Host *` 全局默认继承(仅第一个 `Host *` 块)
|
|
166
|
+
- `HostName`、`User`、`Port`、`IdentityFile`
|
|
167
|
+
- `ProxyJump`,支持 `user@host:port` 格式(仅第一跳)
|
|
168
|
+
- 显式参数优先于 config 值(如 `ssh_connect(configHost="x", user="覆盖值")`)
|
|
169
|
+
|
|
170
|
+
**不支持**(跳过):
|
|
171
|
+
- `Include` 指令
|
|
172
|
+
- `Match` 块(整个块跳过直到下一个 `Host`)
|
|
173
|
+
- 通配符模式(如 `Host *.example.com`)
|
|
174
|
+
|
|
175
|
+
**行为说明**:
|
|
176
|
+
- 多个 `Host *` 块:仅使用第一个
|
|
177
|
+
- 重复的 Host 定义:`ssh_config_list` 显示全部,`ssh_connect` 使用第一个
|
|
178
|
+
- ProxyJump 中的 IPv6:使用方括号格式 `[2001:db8::1]:22`
|
|
179
|
+
|
|
180
|
+
### 多主机并行执行
|
|
181
|
+
|
|
182
|
+
在多个已连接的主机上并行执行同一命令:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
1. ssh_connect(configHost="server1")
|
|
186
|
+
2. ssh_connect(configHost="server2")
|
|
187
|
+
3. ssh_connect(configHost="server3")
|
|
188
|
+
4. ssh_exec_parallel(aliases=["server1", "server2", "server3"], command="uptime")
|
|
189
|
+
```
|
|
190
|
+
|
|
131
191
|
### 基础:连接和执行
|
|
132
192
|
|
|
133
193
|
```
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
194
|
+
ssh_connect(host="192.168.1.100", user="root", keyPath="/home/.ssh/id_rsa", alias="myserver")
|
|
195
|
+
ssh_exec(alias="myserver", command="ls -la /home")
|
|
196
|
+
ssh_disconnect(alias="myserver")
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### 跳板机
|
|
200
|
+
|
|
201
|
+
通过跳板机连接内网服务器:
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
ssh_connect(
|
|
205
|
+
host="10.0.0.5",
|
|
206
|
+
user="root",
|
|
207
|
+
keyPath="/home/.ssh/id_rsa",
|
|
208
|
+
alias="internal",
|
|
209
|
+
jumpHost={
|
|
210
|
+
host: "bastion.example.com",
|
|
211
|
+
user: "admin",
|
|
212
|
+
keyPath: "/home/.ssh/bastion_key"
|
|
213
|
+
}
|
|
214
|
+
)
|
|
137
215
|
```
|
|
138
216
|
|
|
139
217
|
### 切换用户执行(su)
|
|
@@ -331,14 +409,6 @@ mcp-ssh/
|
|
|
331
409
|
└── README.md
|
|
332
410
|
```
|
|
333
411
|
|
|
334
|
-
## 路线图
|
|
335
|
-
|
|
336
|
-
- [ ] 动态端口转发(SOCKS 代理)
|
|
337
|
-
- [ ] SSH Agent 转发
|
|
338
|
-
- [ ] 命令历史和审计日志
|
|
339
|
-
- [ ] 多主机并行执行
|
|
340
|
-
- [ ] SSH 配置文件(~/.ssh/config)自动发现
|
|
341
|
-
|
|
342
412
|
## 贡献
|
|
343
413
|
|
|
344
414
|
欢迎贡献!请随时提交 Pull Request。
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
18
18
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
19
19
|
import { sessionManager } from './session-manager.js';
|
|
20
20
|
import * as fileOps from './file-ops.js';
|
|
21
|
+
import { parseSSHConfig, parseProxyJump } from './ssh-config.js';
|
|
21
22
|
// 创建 MCP Server
|
|
22
23
|
const server = new Server({
|
|
23
24
|
name: 'ssh-mcp-pro',
|
|
@@ -34,28 +35,43 @@ const tools = [
|
|
|
34
35
|
name: 'ssh_connect',
|
|
35
36
|
description: `建立 SSH 连接并保持会话。支持密码、密钥认证,支持跳板机。
|
|
36
37
|
|
|
38
|
+
可通过 configHost 参数使用 ~/.ssh/config 中的配置,无需重复填写连接信息。
|
|
39
|
+
支持 Host 多别名、Host * 全局默认继承、ProxyJump(user@host:port 格式)。
|
|
40
|
+
|
|
37
41
|
示例:
|
|
38
|
-
-
|
|
42
|
+
- 使用 ssh config: ssh_connect(configHost="myserver")
|
|
39
43
|
- 密钥认证: ssh_connect(host="192.168.1.1", user="root", keyPath="/home/.ssh/id_rsa")
|
|
40
|
-
-
|
|
41
|
-
- 设置环境变量: ssh_connect(..., env={"LANG": "en_US.UTF-8"})`,
|
|
44
|
+
- 跳板机: ssh_connect(host="内网IP", user="root", keyPath="...", jumpHost={host:"跳板机IP", user:"root", keyPath:"..."})`,
|
|
42
45
|
inputSchema: {
|
|
43
46
|
type: 'object',
|
|
44
47
|
properties: {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
configHost: { type: 'string', description: '使用 ~/.ssh/config 中的 Host 配置(推荐)' },
|
|
49
|
+
configPath: { type: 'string', description: 'SSH 配置文件路径(默认 ~/.ssh/config)' },
|
|
50
|
+
host: { type: 'string', description: '服务器地址(使用 configHost 时可省略)' },
|
|
51
|
+
user: { type: 'string', description: '用户名(使用 configHost 时可省略)' },
|
|
52
|
+
password: { type: 'string', description: '密码' },
|
|
48
53
|
keyPath: { type: 'string', description: 'SSH 私钥路径' },
|
|
49
|
-
port: { type: 'number', description: 'SSH 端口,默认 22'
|
|
50
|
-
alias: { type: 'string', description: '
|
|
54
|
+
port: { type: 'number', description: 'SSH 端口,默认 22' },
|
|
55
|
+
alias: { type: 'string', description: '连接别名(可选,默认使用 configHost 或 host)' },
|
|
51
56
|
env: {
|
|
52
57
|
type: 'object',
|
|
53
|
-
description: '
|
|
58
|
+
description: '环境变量',
|
|
54
59
|
additionalProperties: { type: 'string' },
|
|
55
60
|
},
|
|
56
61
|
keepaliveInterval: { type: 'number', description: '心跳间隔(毫秒),默认 30000' },
|
|
62
|
+
jumpHost: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
description: '跳板机配置',
|
|
65
|
+
properties: {
|
|
66
|
+
host: { type: 'string', description: '跳板机地址' },
|
|
67
|
+
user: { type: 'string', description: '跳板机用户名' },
|
|
68
|
+
password: { type: 'string', description: '跳板机密码' },
|
|
69
|
+
keyPath: { type: 'string', description: '跳板机私钥路径' },
|
|
70
|
+
port: { type: 'number', description: '跳板机端口,默认 22' },
|
|
71
|
+
},
|
|
72
|
+
required: ['host', 'user'],
|
|
73
|
+
},
|
|
57
74
|
},
|
|
58
|
-
required: ['host', 'user'],
|
|
59
75
|
},
|
|
60
76
|
},
|
|
61
77
|
{
|
|
@@ -480,6 +496,42 @@ rsync 可实现增量传输,对大目录同步效率更高。
|
|
|
480
496
|
properties: {},
|
|
481
497
|
},
|
|
482
498
|
},
|
|
499
|
+
// ========== SSH Config ==========
|
|
500
|
+
{
|
|
501
|
+
name: 'ssh_config_list',
|
|
502
|
+
description: `列出 ~/.ssh/config 中配置的所有 Host。
|
|
503
|
+
|
|
504
|
+
返回每个 Host 的配置信息(别名、地址、用户、端口、密钥路径等)。`,
|
|
505
|
+
inputSchema: {
|
|
506
|
+
type: 'object',
|
|
507
|
+
properties: {
|
|
508
|
+
configPath: { type: 'string', description: 'SSH 配置文件路径(默认 ~/.ssh/config)' },
|
|
509
|
+
},
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
// ========== 批量执行 ==========
|
|
513
|
+
{
|
|
514
|
+
name: 'ssh_exec_parallel',
|
|
515
|
+
description: `在多个已连接的会话上并行执行同一命令。
|
|
516
|
+
|
|
517
|
+
示例:
|
|
518
|
+
- ssh_exec_parallel(aliases=["server1", "server2"], command="uptime")
|
|
519
|
+
|
|
520
|
+
返回每个主机的执行结果。`,
|
|
521
|
+
inputSchema: {
|
|
522
|
+
type: 'object',
|
|
523
|
+
properties: {
|
|
524
|
+
aliases: {
|
|
525
|
+
type: 'array',
|
|
526
|
+
items: { type: 'string' },
|
|
527
|
+
description: '连接别名列表',
|
|
528
|
+
},
|
|
529
|
+
command: { type: 'string', description: '要执行的命令' },
|
|
530
|
+
timeout: { type: 'number', description: '每个命令的超时(毫秒),默认 30000' },
|
|
531
|
+
},
|
|
532
|
+
required: ['aliases', 'command'],
|
|
533
|
+
},
|
|
534
|
+
},
|
|
483
535
|
];
|
|
484
536
|
// 注册工具列表
|
|
485
537
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -493,20 +545,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
493
545
|
switch (name) {
|
|
494
546
|
// ========== 连接管理 ==========
|
|
495
547
|
case 'ssh_connect': {
|
|
548
|
+
// 解析 configHost
|
|
549
|
+
let host = args.host;
|
|
550
|
+
let user = args.user;
|
|
551
|
+
let port = args.port;
|
|
552
|
+
let keyPath = args.keyPath;
|
|
553
|
+
const configPath = args.configPath;
|
|
554
|
+
let jumpHostResolved;
|
|
555
|
+
if (args.configHost) {
|
|
556
|
+
const allHosts = parseSSHConfig(configPath);
|
|
557
|
+
const hostConfig = allHosts.find(h => h.host === args.configHost);
|
|
558
|
+
if (!hostConfig) {
|
|
559
|
+
throw new Error(`Host '${args.configHost}' not found in SSH config`);
|
|
560
|
+
}
|
|
561
|
+
// 显式参数优先于 config 值
|
|
562
|
+
host = host || hostConfig.hostName || hostConfig.host;
|
|
563
|
+
user = user || hostConfig.user;
|
|
564
|
+
port = port || hostConfig.port;
|
|
565
|
+
keyPath = keyPath || hostConfig.identityFile;
|
|
566
|
+
// 解析 ProxyJump(支持 user@host:port 格式)
|
|
567
|
+
if (hostConfig.proxyJump) {
|
|
568
|
+
const parsed = parseProxyJump(hostConfig.proxyJump);
|
|
569
|
+
if (parsed) {
|
|
570
|
+
// 先尝试在 config 中查找对应的 Host
|
|
571
|
+
const jumpHostConfig = allHosts.find(h => h.host === parsed.host);
|
|
572
|
+
if (jumpHostConfig) {
|
|
573
|
+
// 使用 config 中的配置,但 parsed 的 user/port 优先
|
|
574
|
+
jumpHostResolved = {
|
|
575
|
+
host: jumpHostConfig.hostName || jumpHostConfig.host,
|
|
576
|
+
port: parsed.port || jumpHostConfig.port || 22,
|
|
577
|
+
username: parsed.user || jumpHostConfig.user || 'root',
|
|
578
|
+
privateKeyPath: jumpHostConfig.identityFile,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
// 直接使用 parsed 的值
|
|
583
|
+
jumpHostResolved = {
|
|
584
|
+
host: parsed.host,
|
|
585
|
+
port: parsed.port || 22,
|
|
586
|
+
username: parsed.user || 'root',
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (!host || !user) {
|
|
593
|
+
throw new Error('host and user are required (either directly or via configHost)');
|
|
594
|
+
}
|
|
595
|
+
// 手动指定的 jumpHost 优先级高于 ProxyJump
|
|
596
|
+
const jumpHostArg = args.jumpHost;
|
|
597
|
+
const jumpHost = jumpHostArg ? {
|
|
598
|
+
host: jumpHostArg.host,
|
|
599
|
+
port: jumpHostArg.port || 22,
|
|
600
|
+
username: jumpHostArg.user,
|
|
601
|
+
password: jumpHostArg.password,
|
|
602
|
+
privateKeyPath: jumpHostArg.keyPath,
|
|
603
|
+
} : jumpHostResolved;
|
|
496
604
|
const alias = await sessionManager.connect({
|
|
497
|
-
host
|
|
498
|
-
port:
|
|
499
|
-
username:
|
|
605
|
+
host,
|
|
606
|
+
port: port || 22,
|
|
607
|
+
username: user,
|
|
500
608
|
password: args.password,
|
|
501
|
-
privateKeyPath:
|
|
502
|
-
alias: args.alias,
|
|
609
|
+
privateKeyPath: keyPath,
|
|
610
|
+
alias: args.alias || args.configHost,
|
|
503
611
|
env: args.env,
|
|
504
612
|
keepaliveInterval: args.keepaliveInterval,
|
|
613
|
+
jumpHost,
|
|
505
614
|
});
|
|
506
615
|
result = {
|
|
507
616
|
success: true,
|
|
508
617
|
alias,
|
|
509
|
-
message: `Connected to ${
|
|
618
|
+
message: `Connected to ${user}@${host}:${port || 22}${jumpHost ? ' via jump host' : ''}`,
|
|
510
619
|
};
|
|
511
620
|
break;
|
|
512
621
|
}
|
|
@@ -765,6 +874,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
765
874
|
};
|
|
766
875
|
break;
|
|
767
876
|
}
|
|
877
|
+
// ========== SSH Config ==========
|
|
878
|
+
case 'ssh_config_list': {
|
|
879
|
+
const hosts = parseSSHConfig(args.configPath);
|
|
880
|
+
result = {
|
|
881
|
+
success: true,
|
|
882
|
+
count: hosts.length,
|
|
883
|
+
hosts: hosts.map(h => ({
|
|
884
|
+
host: h.host,
|
|
885
|
+
hostName: h.hostName,
|
|
886
|
+
user: h.user,
|
|
887
|
+
port: h.port,
|
|
888
|
+
identityFile: h.identityFile,
|
|
889
|
+
proxyJump: h.proxyJump,
|
|
890
|
+
})),
|
|
891
|
+
};
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
// ========== 批量执行 ==========
|
|
895
|
+
case 'ssh_exec_parallel': {
|
|
896
|
+
const aliases = args.aliases;
|
|
897
|
+
const command = args.command;
|
|
898
|
+
const timeout = args.timeout;
|
|
899
|
+
const execPromises = aliases.map(async (alias) => {
|
|
900
|
+
try {
|
|
901
|
+
const execResult = await sessionManager.exec(alias, command, { timeout });
|
|
902
|
+
return {
|
|
903
|
+
alias,
|
|
904
|
+
success: execResult.success,
|
|
905
|
+
exitCode: execResult.exitCode,
|
|
906
|
+
stdout: execResult.stdout,
|
|
907
|
+
stderr: execResult.stderr,
|
|
908
|
+
duration: execResult.duration,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
return {
|
|
913
|
+
alias,
|
|
914
|
+
success: false,
|
|
915
|
+
error: err.message,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
const results = await Promise.all(execPromises);
|
|
920
|
+
result = {
|
|
921
|
+
success: results.every(r => r.success),
|
|
922
|
+
total: aliases.length,
|
|
923
|
+
results,
|
|
924
|
+
};
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
768
927
|
default:
|
|
769
928
|
throw new Error(`Unknown tool: ${name}`);
|
|
770
929
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Config Parser
|
|
3
|
+
*
|
|
4
|
+
* 解析 ~/.ssh/config 文件,提取 Host 配置
|
|
5
|
+
* 支持:
|
|
6
|
+
* - Host 多别名(Host a b c)
|
|
7
|
+
* - Host * 全局默认配置继承
|
|
8
|
+
* - ProxyJump 解析(支持 user@host:port 格式)
|
|
9
|
+
*/
|
|
10
|
+
export interface SSHConfigHost {
|
|
11
|
+
host: string;
|
|
12
|
+
hostName?: string;
|
|
13
|
+
user?: string;
|
|
14
|
+
port?: number;
|
|
15
|
+
identityFile?: string;
|
|
16
|
+
proxyJump?: string;
|
|
17
|
+
}
|
|
18
|
+
/** ProxyJump 解析结果 */
|
|
19
|
+
export interface ParsedProxyJump {
|
|
20
|
+
user?: string;
|
|
21
|
+
host: string;
|
|
22
|
+
port?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 解析 ProxyJump 字符串
|
|
26
|
+
* 支持格式:host, user@host, host:port, user@host:port, [ipv6]:port
|
|
27
|
+
* 注意:只解析第一跳,不支持逗号分隔的多跳链路
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseProxyJump(proxyJump: string): ParsedProxyJump | null;
|
|
30
|
+
/**
|
|
31
|
+
* 解析 SSH config 文件
|
|
32
|
+
* 支持 Host 多别名和 Host * 继承
|
|
33
|
+
* 跳过 Match 块(避免条件配置被误应用)
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseSSHConfig(configPath?: string): SSHConfigHost[];
|
|
36
|
+
/**
|
|
37
|
+
* 根据 Host 名称获取配置
|
|
38
|
+
*/
|
|
39
|
+
export declare function getHostConfig(hostName: string, configPath?: string): SSHConfigHost | null;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Config Parser
|
|
3
|
+
*
|
|
4
|
+
* 解析 ~/.ssh/config 文件,提取 Host 配置
|
|
5
|
+
* 支持:
|
|
6
|
+
* - Host 多别名(Host a b c)
|
|
7
|
+
* - Host * 全局默认配置继承
|
|
8
|
+
* - ProxyJump 解析(支持 user@host:port 格式)
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
/**
|
|
14
|
+
* 解析 host:port 或 [ipv6]:port
|
|
15
|
+
* 返回 { host, port },host 不含方括号
|
|
16
|
+
*/
|
|
17
|
+
function parseHostPort(s) {
|
|
18
|
+
// IPv6 方括号格式: [addr]:port 或 [addr]
|
|
19
|
+
if (s.startsWith('[')) {
|
|
20
|
+
const closeBracket = s.indexOf(']');
|
|
21
|
+
if (closeBracket !== -1) {
|
|
22
|
+
const host = s.slice(1, closeBracket); // 去掉方括号
|
|
23
|
+
const rest = s.slice(closeBracket + 1);
|
|
24
|
+
if (rest.startsWith(':')) {
|
|
25
|
+
const parsedPort = parseInt(rest.slice(1), 10);
|
|
26
|
+
if (!isNaN(parsedPort)) {
|
|
27
|
+
return { host, port: parsedPort };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { host };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// 检测裸 IPv6(多个冒号但无方括号):安全失败,当作 host-only
|
|
34
|
+
const colonCount = (s.match(/:/g) || []).length;
|
|
35
|
+
if (colonCount >= 2) {
|
|
36
|
+
return { host: s };
|
|
37
|
+
}
|
|
38
|
+
// 普通格式: host:port 或 host
|
|
39
|
+
const colonIndex = s.lastIndexOf(':');
|
|
40
|
+
if (colonIndex !== -1) {
|
|
41
|
+
const host = s.slice(0, colonIndex);
|
|
42
|
+
const portStr = s.slice(colonIndex + 1);
|
|
43
|
+
const parsedPort = parseInt(portStr, 10);
|
|
44
|
+
if (!isNaN(parsedPort)) {
|
|
45
|
+
return { host, port: parsedPort };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { host: s };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 解析 ProxyJump 字符串
|
|
52
|
+
* 支持格式:host, user@host, host:port, user@host:port, [ipv6]:port
|
|
53
|
+
* 注意:只解析第一跳,不支持逗号分隔的多跳链路
|
|
54
|
+
*/
|
|
55
|
+
export function parseProxyJump(proxyJump) {
|
|
56
|
+
if (!proxyJump) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// 取第一跳(如果有逗号分隔)
|
|
60
|
+
const firstJump = proxyJump.split(',')[0].trim();
|
|
61
|
+
if (!firstJump) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
let user;
|
|
65
|
+
// 解析 user@... 格式
|
|
66
|
+
const atIndex = firstJump.indexOf('@');
|
|
67
|
+
if (atIndex !== -1) {
|
|
68
|
+
user = firstJump.slice(0, atIndex);
|
|
69
|
+
const rest = firstJump.slice(atIndex + 1);
|
|
70
|
+
const { host, port } = parseHostPort(rest);
|
|
71
|
+
return { user, host, port };
|
|
72
|
+
}
|
|
73
|
+
const { host, port } = parseHostPort(firstJump);
|
|
74
|
+
return { user, host, port };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 展开 ~ 路径
|
|
78
|
+
*/
|
|
79
|
+
function expandTilde(filePath) {
|
|
80
|
+
if (filePath.startsWith('~')) {
|
|
81
|
+
return path.join(os.homedir(), filePath.slice(1));
|
|
82
|
+
}
|
|
83
|
+
return filePath;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 剥离行尾注释
|
|
87
|
+
* "value # comment" -> "value"
|
|
88
|
+
*/
|
|
89
|
+
function stripInlineComment(value) {
|
|
90
|
+
// 查找不在引号内的 #
|
|
91
|
+
let inQuote = false;
|
|
92
|
+
let quoteChar = '';
|
|
93
|
+
for (let i = 0; i < value.length; i++) {
|
|
94
|
+
const ch = value[i];
|
|
95
|
+
if (!inQuote && (ch === '"' || ch === "'")) {
|
|
96
|
+
inQuote = true;
|
|
97
|
+
quoteChar = ch;
|
|
98
|
+
}
|
|
99
|
+
else if (inQuote && ch === quoteChar) {
|
|
100
|
+
inQuote = false;
|
|
101
|
+
}
|
|
102
|
+
else if (!inQuote && ch === '#') {
|
|
103
|
+
return value.slice(0, i).trim();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return value.trim();
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 解析 SSH config 文件
|
|
110
|
+
* 支持 Host 多别名和 Host * 继承
|
|
111
|
+
* 跳过 Match 块(避免条件配置被误应用)
|
|
112
|
+
*/
|
|
113
|
+
export function parseSSHConfig(configPath) {
|
|
114
|
+
const filePath = configPath || path.join(os.homedir(), '.ssh', 'config');
|
|
115
|
+
if (!fs.existsSync(filePath)) {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
119
|
+
const lines = content.split('\n');
|
|
120
|
+
// 第一遍:收集所有配置块
|
|
121
|
+
const blocks = [];
|
|
122
|
+
let currentBlock = null;
|
|
123
|
+
let globalDefaults = {};
|
|
124
|
+
let inMatchBlock = false; // 跳过 Match 块
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
const trimmed = line.trim();
|
|
127
|
+
// 跳过空行和注释
|
|
128
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// 解析 key value(支持 = 和空格分隔)
|
|
132
|
+
const match = trimmed.match(/^(\S+)\s*[=\s]\s*(.+)$/);
|
|
133
|
+
if (!match) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const [, key, rawValue] = match;
|
|
137
|
+
const keyLower = key.toLowerCase();
|
|
138
|
+
const value = stripInlineComment(rawValue);
|
|
139
|
+
if (keyLower === 'host') {
|
|
140
|
+
// Host 块开始,结束 Match 块
|
|
141
|
+
inMatchBlock = false;
|
|
142
|
+
// 保存上一个 block
|
|
143
|
+
if (currentBlock) {
|
|
144
|
+
blocks.push(currentBlock);
|
|
145
|
+
}
|
|
146
|
+
// Host 行可能有多个别名/模式,用空格分隔
|
|
147
|
+
const patterns = value.split(/\s+/).filter(p => p.length > 0);
|
|
148
|
+
currentBlock = { patterns, config: {} };
|
|
149
|
+
}
|
|
150
|
+
else if (keyLower === 'match') {
|
|
151
|
+
// Match 块开始,跳过直到下一个 Host
|
|
152
|
+
inMatchBlock = true;
|
|
153
|
+
// 保存当前 block(如果有)
|
|
154
|
+
if (currentBlock) {
|
|
155
|
+
blocks.push(currentBlock);
|
|
156
|
+
currentBlock = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else if (!inMatchBlock && currentBlock) {
|
|
160
|
+
// 解析配置项(不在 Match 块内)
|
|
161
|
+
switch (keyLower) {
|
|
162
|
+
case 'hostname':
|
|
163
|
+
currentBlock.config.hostName = value;
|
|
164
|
+
break;
|
|
165
|
+
case 'user':
|
|
166
|
+
currentBlock.config.user = value;
|
|
167
|
+
break;
|
|
168
|
+
case 'port':
|
|
169
|
+
currentBlock.config.port = parseInt(value, 10);
|
|
170
|
+
break;
|
|
171
|
+
case 'identityfile':
|
|
172
|
+
currentBlock.config.identityFile = expandTilde(value);
|
|
173
|
+
break;
|
|
174
|
+
case 'proxyjump':
|
|
175
|
+
currentBlock.config.proxyJump = value;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// 保存最后一个 block
|
|
181
|
+
if (currentBlock && !inMatchBlock) {
|
|
182
|
+
blocks.push(currentBlock);
|
|
183
|
+
}
|
|
184
|
+
// 第二遍:提取 Host * 的全局默认配置
|
|
185
|
+
for (const block of blocks) {
|
|
186
|
+
if (block.patterns.length === 1 && block.patterns[0] === '*') {
|
|
187
|
+
globalDefaults = { ...block.config };
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// 第三遍:展开所有 Host,应用继承
|
|
192
|
+
const hosts = [];
|
|
193
|
+
for (const block of blocks) {
|
|
194
|
+
for (const pattern of block.patterns) {
|
|
195
|
+
// 跳过通配符模式(*, *.example.com 等)
|
|
196
|
+
if (pattern.includes('*') || pattern.includes('?')) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// 合并配置:全局默认 + 当前块配置
|
|
200
|
+
const merged = {
|
|
201
|
+
host: pattern,
|
|
202
|
+
...globalDefaults,
|
|
203
|
+
...block.config,
|
|
204
|
+
};
|
|
205
|
+
hosts.push(merged);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return hosts;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 根据 Host 名称获取配置
|
|
212
|
+
*/
|
|
213
|
+
export function getHostConfig(hostName, configPath) {
|
|
214
|
+
const hosts = parseSSHConfig(configPath);
|
|
215
|
+
return hosts.find(h => h.host === hostName) || null;
|
|
216
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyrokine/mcp-ssh",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A comprehensive SSH MCP Server for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"repository": {
|
|
27
27
|
"type": "git",
|
|
28
|
-
"url": "https://github.com/
|
|
28
|
+
"url": "https://github.com/Pyrokine/claude-mcp-tools.git"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.25.3",
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { sessionManager } from './session-manager.js';
|
|
26
26
|
import * as fileOps from './file-ops.js';
|
|
27
27
|
import { ExecOptions, PtyOptions } from './types.js';
|
|
28
|
+
import { parseSSHConfig, getHostConfig, parseProxyJump, SSHConfigHost } from './ssh-config.js';
|
|
28
29
|
|
|
29
30
|
// 创建 MCP Server
|
|
30
31
|
const server = new Server(
|
|
@@ -46,28 +47,43 @@ const tools: Tool[] = [
|
|
|
46
47
|
name: 'ssh_connect',
|
|
47
48
|
description: `建立 SSH 连接并保持会话。支持密码、密钥认证,支持跳板机。
|
|
48
49
|
|
|
50
|
+
可通过 configHost 参数使用 ~/.ssh/config 中的配置,无需重复填写连接信息。
|
|
51
|
+
支持 Host 多别名、Host * 全局默认继承、ProxyJump(user@host:port 格式)。
|
|
52
|
+
|
|
49
53
|
示例:
|
|
50
|
-
-
|
|
54
|
+
- 使用 ssh config: ssh_connect(configHost="myserver")
|
|
51
55
|
- 密钥认证: ssh_connect(host="192.168.1.1", user="root", keyPath="/home/.ssh/id_rsa")
|
|
52
|
-
-
|
|
53
|
-
- 设置环境变量: ssh_connect(..., env={"LANG": "en_US.UTF-8"})`,
|
|
56
|
+
- 跳板机: ssh_connect(host="内网IP", user="root", keyPath="...", jumpHost={host:"跳板机IP", user:"root", keyPath:"..."})`,
|
|
54
57
|
inputSchema: {
|
|
55
58
|
type: 'object',
|
|
56
59
|
properties: {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
configHost: { type: 'string', description: '使用 ~/.ssh/config 中的 Host 配置(推荐)' },
|
|
61
|
+
configPath: { type: 'string', description: 'SSH 配置文件路径(默认 ~/.ssh/config)' },
|
|
62
|
+
host: { type: 'string', description: '服务器地址(使用 configHost 时可省略)' },
|
|
63
|
+
user: { type: 'string', description: '用户名(使用 configHost 时可省略)' },
|
|
64
|
+
password: { type: 'string', description: '密码' },
|
|
60
65
|
keyPath: { type: 'string', description: 'SSH 私钥路径' },
|
|
61
|
-
port: { type: 'number', description: 'SSH 端口,默认 22'
|
|
62
|
-
alias: { type: 'string', description: '
|
|
66
|
+
port: { type: 'number', description: 'SSH 端口,默认 22' },
|
|
67
|
+
alias: { type: 'string', description: '连接别名(可选,默认使用 configHost 或 host)' },
|
|
63
68
|
env: {
|
|
64
69
|
type: 'object',
|
|
65
|
-
description: '
|
|
70
|
+
description: '环境变量',
|
|
66
71
|
additionalProperties: { type: 'string' },
|
|
67
72
|
},
|
|
68
73
|
keepaliveInterval: { type: 'number', description: '心跳间隔(毫秒),默认 30000' },
|
|
74
|
+
jumpHost: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
description: '跳板机配置',
|
|
77
|
+
properties: {
|
|
78
|
+
host: { type: 'string', description: '跳板机地址' },
|
|
79
|
+
user: { type: 'string', description: '跳板机用户名' },
|
|
80
|
+
password: { type: 'string', description: '跳板机密码' },
|
|
81
|
+
keyPath: { type: 'string', description: '跳板机私钥路径' },
|
|
82
|
+
port: { type: 'number', description: '跳板机端口,默认 22' },
|
|
83
|
+
},
|
|
84
|
+
required: ['host', 'user'],
|
|
85
|
+
},
|
|
69
86
|
},
|
|
70
|
-
required: ['host', 'user'],
|
|
71
87
|
},
|
|
72
88
|
},
|
|
73
89
|
{
|
|
@@ -496,6 +512,44 @@ rsync 可实现增量传输,对大目录同步效率更高。
|
|
|
496
512
|
properties: {},
|
|
497
513
|
},
|
|
498
514
|
},
|
|
515
|
+
|
|
516
|
+
// ========== SSH Config ==========
|
|
517
|
+
{
|
|
518
|
+
name: 'ssh_config_list',
|
|
519
|
+
description: `列出 ~/.ssh/config 中配置的所有 Host。
|
|
520
|
+
|
|
521
|
+
返回每个 Host 的配置信息(别名、地址、用户、端口、密钥路径等)。`,
|
|
522
|
+
inputSchema: {
|
|
523
|
+
type: 'object',
|
|
524
|
+
properties: {
|
|
525
|
+
configPath: { type: 'string', description: 'SSH 配置文件路径(默认 ~/.ssh/config)' },
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
},
|
|
529
|
+
|
|
530
|
+
// ========== 批量执行 ==========
|
|
531
|
+
{
|
|
532
|
+
name: 'ssh_exec_parallel',
|
|
533
|
+
description: `在多个已连接的会话上并行执行同一命令。
|
|
534
|
+
|
|
535
|
+
示例:
|
|
536
|
+
- ssh_exec_parallel(aliases=["server1", "server2"], command="uptime")
|
|
537
|
+
|
|
538
|
+
返回每个主机的执行结果。`,
|
|
539
|
+
inputSchema: {
|
|
540
|
+
type: 'object',
|
|
541
|
+
properties: {
|
|
542
|
+
aliases: {
|
|
543
|
+
type: 'array',
|
|
544
|
+
items: { type: 'string' },
|
|
545
|
+
description: '连接别名列表',
|
|
546
|
+
},
|
|
547
|
+
command: { type: 'string', description: '要执行的命令' },
|
|
548
|
+
timeout: { type: 'number', description: '每个命令的超时(毫秒),默认 30000' },
|
|
549
|
+
},
|
|
550
|
+
required: ['aliases', 'command'],
|
|
551
|
+
},
|
|
552
|
+
},
|
|
499
553
|
];
|
|
500
554
|
|
|
501
555
|
// 注册工具列表
|
|
@@ -513,20 +567,81 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
513
567
|
switch (name) {
|
|
514
568
|
// ========== 连接管理 ==========
|
|
515
569
|
case 'ssh_connect': {
|
|
570
|
+
// 解析 configHost
|
|
571
|
+
let host = args.host as string | undefined;
|
|
572
|
+
let user = args.user as string | undefined;
|
|
573
|
+
let port = args.port as number | undefined;
|
|
574
|
+
let keyPath = args.keyPath as string | undefined;
|
|
575
|
+
const configPath = args.configPath as string | undefined;
|
|
576
|
+
let jumpHostResolved: { host: string; port: number; username: string; password?: string; privateKeyPath?: string } | undefined;
|
|
577
|
+
|
|
578
|
+
if (args.configHost) {
|
|
579
|
+
const allHosts = parseSSHConfig(configPath);
|
|
580
|
+
const hostConfig = allHosts.find(h => h.host === args.configHost);
|
|
581
|
+
if (!hostConfig) {
|
|
582
|
+
throw new Error(`Host '${args.configHost}' not found in SSH config`);
|
|
583
|
+
}
|
|
584
|
+
// 显式参数优先于 config 值
|
|
585
|
+
host = host || hostConfig.hostName || hostConfig.host;
|
|
586
|
+
user = user || hostConfig.user;
|
|
587
|
+
port = port || hostConfig.port;
|
|
588
|
+
keyPath = keyPath || hostConfig.identityFile;
|
|
589
|
+
|
|
590
|
+
// 解析 ProxyJump(支持 user@host:port 格式)
|
|
591
|
+
if (hostConfig.proxyJump) {
|
|
592
|
+
const parsed = parseProxyJump(hostConfig.proxyJump);
|
|
593
|
+
if (parsed) {
|
|
594
|
+
// 先尝试在 config 中查找对应的 Host
|
|
595
|
+
const jumpHostConfig = allHosts.find(h => h.host === parsed.host);
|
|
596
|
+
if (jumpHostConfig) {
|
|
597
|
+
// 使用 config 中的配置,但 parsed 的 user/port 优先
|
|
598
|
+
jumpHostResolved = {
|
|
599
|
+
host: jumpHostConfig.hostName || jumpHostConfig.host,
|
|
600
|
+
port: parsed.port || jumpHostConfig.port || 22,
|
|
601
|
+
username: parsed.user || jumpHostConfig.user || 'root',
|
|
602
|
+
privateKeyPath: jumpHostConfig.identityFile,
|
|
603
|
+
};
|
|
604
|
+
} else {
|
|
605
|
+
// 直接使用 parsed 的值
|
|
606
|
+
jumpHostResolved = {
|
|
607
|
+
host: parsed.host,
|
|
608
|
+
port: parsed.port || 22,
|
|
609
|
+
username: parsed.user || 'root',
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!host || !user) {
|
|
617
|
+
throw new Error('host and user are required (either directly or via configHost)');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 手动指定的 jumpHost 优先级高于 ProxyJump
|
|
621
|
+
const jumpHostArg = args.jumpHost as { host: string; user: string; password?: string; keyPath?: string; port?: number } | undefined;
|
|
622
|
+
const jumpHost = jumpHostArg ? {
|
|
623
|
+
host: jumpHostArg.host,
|
|
624
|
+
port: jumpHostArg.port || 22,
|
|
625
|
+
username: jumpHostArg.user,
|
|
626
|
+
password: jumpHostArg.password,
|
|
627
|
+
privateKeyPath: jumpHostArg.keyPath,
|
|
628
|
+
} : jumpHostResolved;
|
|
629
|
+
|
|
516
630
|
const alias = await sessionManager.connect({
|
|
517
|
-
host
|
|
518
|
-
port:
|
|
519
|
-
username:
|
|
631
|
+
host,
|
|
632
|
+
port: port || 22,
|
|
633
|
+
username: user,
|
|
520
634
|
password: args.password as string | undefined,
|
|
521
|
-
privateKeyPath:
|
|
522
|
-
alias: args.alias as string | undefined,
|
|
635
|
+
privateKeyPath: keyPath,
|
|
636
|
+
alias: (args.alias as string | undefined) || (args.configHost as string | undefined),
|
|
523
637
|
env: args.env as Record<string, string> | undefined,
|
|
524
638
|
keepaliveInterval: args.keepaliveInterval as number | undefined,
|
|
639
|
+
jumpHost,
|
|
525
640
|
});
|
|
526
641
|
result = {
|
|
527
642
|
success: true,
|
|
528
643
|
alias,
|
|
529
|
-
message: `Connected to ${
|
|
644
|
+
message: `Connected to ${user}@${host}:${port || 22}${jumpHost ? ' via jump host' : ''}`,
|
|
530
645
|
};
|
|
531
646
|
break;
|
|
532
647
|
}
|
|
@@ -893,6 +1008,59 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
893
1008
|
break;
|
|
894
1009
|
}
|
|
895
1010
|
|
|
1011
|
+
// ========== SSH Config ==========
|
|
1012
|
+
case 'ssh_config_list': {
|
|
1013
|
+
const hosts = parseSSHConfig(args.configPath as string | undefined);
|
|
1014
|
+
result = {
|
|
1015
|
+
success: true,
|
|
1016
|
+
count: hosts.length,
|
|
1017
|
+
hosts: hosts.map(h => ({
|
|
1018
|
+
host: h.host,
|
|
1019
|
+
hostName: h.hostName,
|
|
1020
|
+
user: h.user,
|
|
1021
|
+
port: h.port,
|
|
1022
|
+
identityFile: h.identityFile,
|
|
1023
|
+
proxyJump: h.proxyJump,
|
|
1024
|
+
})),
|
|
1025
|
+
};
|
|
1026
|
+
break;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ========== 批量执行 ==========
|
|
1030
|
+
case 'ssh_exec_parallel': {
|
|
1031
|
+
const aliases = args.aliases as string[];
|
|
1032
|
+
const command = args.command as string;
|
|
1033
|
+
const timeout = args.timeout as number | undefined;
|
|
1034
|
+
|
|
1035
|
+
const execPromises = aliases.map(async (alias) => {
|
|
1036
|
+
try {
|
|
1037
|
+
const execResult = await sessionManager.exec(alias, command, { timeout });
|
|
1038
|
+
return {
|
|
1039
|
+
alias,
|
|
1040
|
+
success: execResult.success,
|
|
1041
|
+
exitCode: execResult.exitCode,
|
|
1042
|
+
stdout: execResult.stdout,
|
|
1043
|
+
stderr: execResult.stderr,
|
|
1044
|
+
duration: execResult.duration,
|
|
1045
|
+
};
|
|
1046
|
+
} catch (err: any) {
|
|
1047
|
+
return {
|
|
1048
|
+
alias,
|
|
1049
|
+
success: false,
|
|
1050
|
+
error: err.message,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
const results = await Promise.all(execPromises);
|
|
1056
|
+
result = {
|
|
1057
|
+
success: results.every(r => r.success),
|
|
1058
|
+
total: aliases.length,
|
|
1059
|
+
results,
|
|
1060
|
+
};
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
896
1064
|
default:
|
|
897
1065
|
throw new Error(`Unknown tool: ${name}`);
|
|
898
1066
|
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH Config Parser
|
|
3
|
+
*
|
|
4
|
+
* 解析 ~/.ssh/config 文件,提取 Host 配置
|
|
5
|
+
* 支持:
|
|
6
|
+
* - Host 多别名(Host a b c)
|
|
7
|
+
* - Host * 全局默认配置继承
|
|
8
|
+
* - ProxyJump 解析(支持 user@host:port 格式)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
|
|
15
|
+
export interface SSHConfigHost {
|
|
16
|
+
host: string; // Host 别名
|
|
17
|
+
hostName?: string; // 实际地址
|
|
18
|
+
user?: string; // 用户名
|
|
19
|
+
port?: number; // 端口
|
|
20
|
+
identityFile?: string; // 私钥路径
|
|
21
|
+
proxyJump?: string; // 跳板机(原始字符串,可能是 user@host:port 格式)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** ProxyJump 解析结果 */
|
|
25
|
+
export interface ParsedProxyJump {
|
|
26
|
+
user?: string;
|
|
27
|
+
host: string;
|
|
28
|
+
port?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 内部使用的配置块 */
|
|
32
|
+
interface ConfigBlock {
|
|
33
|
+
patterns: string[]; // Host 行的所有模式/别名
|
|
34
|
+
config: Omit<SSHConfigHost, 'host'>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 解析 host:port 或 [ipv6]:port
|
|
39
|
+
* 返回 { host, port },host 不含方括号
|
|
40
|
+
*/
|
|
41
|
+
function parseHostPort(s: string): { host: string; port?: number } {
|
|
42
|
+
// IPv6 方括号格式: [addr]:port 或 [addr]
|
|
43
|
+
if (s.startsWith('[')) {
|
|
44
|
+
const closeBracket = s.indexOf(']');
|
|
45
|
+
if (closeBracket !== -1) {
|
|
46
|
+
const host = s.slice(1, closeBracket); // 去掉方括号
|
|
47
|
+
const rest = s.slice(closeBracket + 1);
|
|
48
|
+
if (rest.startsWith(':')) {
|
|
49
|
+
const parsedPort = parseInt(rest.slice(1), 10);
|
|
50
|
+
if (!isNaN(parsedPort)) {
|
|
51
|
+
return { host, port: parsedPort };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { host };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 检测裸 IPv6(多个冒号但无方括号):安全失败,当作 host-only
|
|
59
|
+
const colonCount = (s.match(/:/g) || []).length;
|
|
60
|
+
if (colonCount >= 2) {
|
|
61
|
+
return { host: s };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 普通格式: host:port 或 host
|
|
65
|
+
const colonIndex = s.lastIndexOf(':');
|
|
66
|
+
if (colonIndex !== -1) {
|
|
67
|
+
const host = s.slice(0, colonIndex);
|
|
68
|
+
const portStr = s.slice(colonIndex + 1);
|
|
69
|
+
const parsedPort = parseInt(portStr, 10);
|
|
70
|
+
if (!isNaN(parsedPort)) {
|
|
71
|
+
return { host, port: parsedPort };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { host: s };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 解析 ProxyJump 字符串
|
|
79
|
+
* 支持格式:host, user@host, host:port, user@host:port, [ipv6]:port
|
|
80
|
+
* 注意:只解析第一跳,不支持逗号分隔的多跳链路
|
|
81
|
+
*/
|
|
82
|
+
export function parseProxyJump(proxyJump: string): ParsedProxyJump | null {
|
|
83
|
+
if (!proxyJump) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 取第一跳(如果有逗号分隔)
|
|
88
|
+
const firstJump = proxyJump.split(',')[0].trim();
|
|
89
|
+
if (!firstJump) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let user: string | undefined;
|
|
94
|
+
|
|
95
|
+
// 解析 user@... 格式
|
|
96
|
+
const atIndex = firstJump.indexOf('@');
|
|
97
|
+
if (atIndex !== -1) {
|
|
98
|
+
user = firstJump.slice(0, atIndex);
|
|
99
|
+
const rest = firstJump.slice(atIndex + 1);
|
|
100
|
+
const { host, port } = parseHostPort(rest);
|
|
101
|
+
return { user, host, port };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const { host, port } = parseHostPort(firstJump);
|
|
105
|
+
return { user, host, port };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 展开 ~ 路径
|
|
110
|
+
*/
|
|
111
|
+
function expandTilde(filePath: string): string {
|
|
112
|
+
if (filePath.startsWith('~')) {
|
|
113
|
+
return path.join(os.homedir(), filePath.slice(1));
|
|
114
|
+
}
|
|
115
|
+
return filePath;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 剥离行尾注释
|
|
120
|
+
* "value # comment" -> "value"
|
|
121
|
+
*/
|
|
122
|
+
function stripInlineComment(value: string): string {
|
|
123
|
+
// 查找不在引号内的 #
|
|
124
|
+
let inQuote = false;
|
|
125
|
+
let quoteChar = '';
|
|
126
|
+
for (let i = 0; i < value.length; i++) {
|
|
127
|
+
const ch = value[i];
|
|
128
|
+
if (!inQuote && (ch === '"' || ch === "'")) {
|
|
129
|
+
inQuote = true;
|
|
130
|
+
quoteChar = ch;
|
|
131
|
+
} else if (inQuote && ch === quoteChar) {
|
|
132
|
+
inQuote = false;
|
|
133
|
+
} else if (!inQuote && ch === '#') {
|
|
134
|
+
return value.slice(0, i).trim();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return value.trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 解析 SSH config 文件
|
|
142
|
+
* 支持 Host 多别名和 Host * 继承
|
|
143
|
+
* 跳过 Match 块(避免条件配置被误应用)
|
|
144
|
+
*/
|
|
145
|
+
export function parseSSHConfig(configPath?: string): SSHConfigHost[] {
|
|
146
|
+
const filePath = configPath || path.join(os.homedir(), '.ssh', 'config');
|
|
147
|
+
|
|
148
|
+
if (!fs.existsSync(filePath)) {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
153
|
+
const lines = content.split('\n');
|
|
154
|
+
|
|
155
|
+
// 第一遍:收集所有配置块
|
|
156
|
+
const blocks: ConfigBlock[] = [];
|
|
157
|
+
let currentBlock: ConfigBlock | null = null;
|
|
158
|
+
let globalDefaults: Omit<SSHConfigHost, 'host'> = {};
|
|
159
|
+
let inMatchBlock = false; // 跳过 Match 块
|
|
160
|
+
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const trimmed = line.trim();
|
|
163
|
+
|
|
164
|
+
// 跳过空行和注释
|
|
165
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 解析 key value(支持 = 和空格分隔)
|
|
170
|
+
const match = trimmed.match(/^(\S+)\s*[=\s]\s*(.+)$/);
|
|
171
|
+
if (!match) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const [, key, rawValue] = match;
|
|
176
|
+
const keyLower = key.toLowerCase();
|
|
177
|
+
const value = stripInlineComment(rawValue);
|
|
178
|
+
|
|
179
|
+
if (keyLower === 'host') {
|
|
180
|
+
// Host 块开始,结束 Match 块
|
|
181
|
+
inMatchBlock = false;
|
|
182
|
+
|
|
183
|
+
// 保存上一个 block
|
|
184
|
+
if (currentBlock) {
|
|
185
|
+
blocks.push(currentBlock);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Host 行可能有多个别名/模式,用空格分隔
|
|
189
|
+
const patterns = value.split(/\s+/).filter(p => p.length > 0);
|
|
190
|
+
currentBlock = { patterns, config: {} };
|
|
191
|
+
} else if (keyLower === 'match') {
|
|
192
|
+
// Match 块开始,跳过直到下一个 Host
|
|
193
|
+
inMatchBlock = true;
|
|
194
|
+
// 保存当前 block(如果有)
|
|
195
|
+
if (currentBlock) {
|
|
196
|
+
blocks.push(currentBlock);
|
|
197
|
+
currentBlock = null;
|
|
198
|
+
}
|
|
199
|
+
} else if (!inMatchBlock && currentBlock) {
|
|
200
|
+
// 解析配置项(不在 Match 块内)
|
|
201
|
+
switch (keyLower) {
|
|
202
|
+
case 'hostname':
|
|
203
|
+
currentBlock.config.hostName = value;
|
|
204
|
+
break;
|
|
205
|
+
case 'user':
|
|
206
|
+
currentBlock.config.user = value;
|
|
207
|
+
break;
|
|
208
|
+
case 'port':
|
|
209
|
+
currentBlock.config.port = parseInt(value, 10);
|
|
210
|
+
break;
|
|
211
|
+
case 'identityfile':
|
|
212
|
+
currentBlock.config.identityFile = expandTilde(value);
|
|
213
|
+
break;
|
|
214
|
+
case 'proxyjump':
|
|
215
|
+
currentBlock.config.proxyJump = value;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 保存最后一个 block
|
|
222
|
+
if (currentBlock && !inMatchBlock) {
|
|
223
|
+
blocks.push(currentBlock);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 第二遍:提取 Host * 的全局默认配置
|
|
227
|
+
for (const block of blocks) {
|
|
228
|
+
if (block.patterns.length === 1 && block.patterns[0] === '*') {
|
|
229
|
+
globalDefaults = { ...block.config };
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 第三遍:展开所有 Host,应用继承
|
|
235
|
+
const hosts: SSHConfigHost[] = [];
|
|
236
|
+
|
|
237
|
+
for (const block of blocks) {
|
|
238
|
+
for (const pattern of block.patterns) {
|
|
239
|
+
// 跳过通配符模式(*, *.example.com 等)
|
|
240
|
+
if (pattern.includes('*') || pattern.includes('?')) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 合并配置:全局默认 + 当前块配置
|
|
245
|
+
const merged: SSHConfigHost = {
|
|
246
|
+
host: pattern,
|
|
247
|
+
...globalDefaults,
|
|
248
|
+
...block.config,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
hosts.push(merged);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return hosts;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 根据 Host 名称获取配置
|
|
260
|
+
*/
|
|
261
|
+
export function getHostConfig(hostName: string, configPath?: string): SSHConfigHost | null {
|
|
262
|
+
const hosts = parseSSHConfig(configPath);
|
|
263
|
+
return hosts.find(h => h.host === hostName) || null;
|
|
264
|
+
}
|