@bingzi-233/ssh-mcp 1.2.0 → 1.3.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 +65 -57
- package/dist/index.js +622 -232
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,65 +1,58 @@
|
|
|
1
1
|
# ssh-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
纯命令行 SSH/SFTP 工具:在多台远程服务器上执行命令、传输大文件(断点续传)。支持 CLI 模式和 MCP stdio 模式(`--mcp`)。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@bingzi-233/ssh-mcp)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](https://www.typescriptlang.org)
|
|
8
|
+
[](https://github.com/BingZi-233/ssh-mcp/stargazers)
|
|
9
|
+
[](https://github.com/BingZi-233/ssh-mcp/blob/master/LICENSE)
|
|
10
|
+
[](https://linux.do)
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|---|---|
|
|
9
|
-
| `list_servers` | 列出所有已配置的服务器(name / 描述 / 地址 / 用户,**不含密码私钥**) |
|
|
10
|
-
| `run_command` | 在指定 `name` 的服务器上执行一条命令,返回 stdout / stderr / 退出码 |
|
|
11
|
-
| `open_session` | 建立到服务器的长连接会话,返回会话 id 供 `run_command` 复用 |
|
|
12
|
-
| `close_session` | 关闭长连接会话 |
|
|
13
|
-
| `list_sessions` | 列出当前所有活跃的长连接会话 |
|
|
14
|
-
| `upload_file` | 上传本机文件到远程(后台任务,支持断点续传) |
|
|
15
|
-
| `download_file` | 从远程下载文件到本机(后台任务,支持断点续传) |
|
|
16
|
-
| `transfer_status` | 查询传输进度(已传字节、百分比、速度、ETA、状态) |
|
|
17
|
-
| `cancel_transfer` | 取消一个进行中的传输(已传部分保留,可续传) |
|
|
12
|
+
## CLI 快速上手
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
> - 大文件传输是**后台任务**:`upload_file`/`download_file` 立即返回一个传输 id,用 `transfer_status` 轮询进度,不阻塞。
|
|
23
|
-
> - **断点续传**基于目标文件的实际大小:对同一对路径再次发起传输会自动从已传字节处继续,进程重启后依然有效。
|
|
14
|
+
```bash
|
|
15
|
+
# 安装
|
|
16
|
+
npm i -g @bingzi-233/ssh-mcp
|
|
24
17
|
|
|
25
|
-
|
|
18
|
+
# 查看帮助
|
|
19
|
+
ssh-mcp --help
|
|
26
20
|
|
|
27
|
-
|
|
21
|
+
# 列出服务器
|
|
22
|
+
ssh-mcp list-servers
|
|
28
23
|
|
|
29
|
-
|
|
24
|
+
# 执行远程命令
|
|
25
|
+
ssh-mcp run-command -s prod-web -c "df -h /"
|
|
30
26
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/plugin install ssh-mcp@bingzi-plugins
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
插件会通过 `npx` 拉起已发布的 npm 包,因此**需要先把包发布到 npm**(见下文「发布到 npm」)。
|
|
27
|
+
# 上传文件(支持断点续传)
|
|
28
|
+
ssh-mcp upload -s prod-web -l ./dist.tar.gz -r /tmp/dist.tar.gz
|
|
37
29
|
|
|
38
|
-
|
|
30
|
+
# 下载文件
|
|
31
|
+
ssh-mcp download -s prod-web -r /var/log/app.log -l ./logs/app.log
|
|
39
32
|
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
# 传输进度
|
|
34
|
+
ssh-mcp transfer-status
|
|
42
35
|
```
|
|
43
36
|
|
|
44
|
-
|
|
37
|
+
## 命令一览
|
|
45
38
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
39
|
+
| 子命令 | 用途 |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `list-servers` | 列出所有已配置的服务器 |
|
|
42
|
+
| `run-command` | 在远程服务器上执行命令 |
|
|
43
|
+
| `open-session` | 打开长连接会话(复用 TCP 连接) |
|
|
44
|
+
| `close-session` | 关闭长连接会话 |
|
|
45
|
+
| `list-sessions` | 列出当前所有长连接会话 |
|
|
46
|
+
| `upload` | 上传文件到远程(后台任务,断点续传) |
|
|
47
|
+
| `download` | 从远程下载文件(后台任务,断点续传) |
|
|
48
|
+
| `transfer-status` | 查看传输进度 |
|
|
49
|
+
| `cancel-transfer` | 取消传输 |
|
|
57
50
|
|
|
58
|
-
|
|
51
|
+
每个子命令运行 `ssh-mcp <子命令> --help` 查看详细用法。
|
|
59
52
|
|
|
60
|
-
##
|
|
53
|
+
## 配置 servers.json
|
|
61
54
|
|
|
62
|
-
|
|
55
|
+
默认从运行目录下 `./servers.json` 读取,或设置环境变量 `SSH_MCP_CONFIG`。
|
|
63
56
|
|
|
64
57
|
```json
|
|
65
58
|
{
|
|
@@ -86,24 +79,39 @@ claude mcp add ssh -- node /绝对路径/ssh-mcp/dist/index.js
|
|
|
86
79
|
}
|
|
87
80
|
```
|
|
88
81
|
|
|
89
|
-
|
|
82
|
+
鉴权优先级:私钥 → 密码 → ssh-agent。修改配置无需重启。
|
|
83
|
+
|
|
84
|
+
## 长连接会话
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
SID=$(ssh-mcp open-session -s prod-web)
|
|
88
|
+
ssh-mcp run-command -s prod-web --session $SID -c "hostname"
|
|
89
|
+
ssh-mcp run-command -s prod-web --session $SID -c "uptime"
|
|
90
|
+
ssh-mcp close-session -s $SID
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
复用 TCP 连接,省去重复握手和认证。注意每条命令仍在独立 channel 中执行,不保留工作目录。
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
2. `password` —— 密码登录
|
|
93
|
-
3. 都没配 → 回退到本机 `ssh-agent`(读 `SSH_AUTH_SOCK`)
|
|
95
|
+
## 安全策略
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
内置拦截高危命令:`rm -rf /`、`dd` 写块设备、`mkfs`、fork 炸弹。在 `servers.json` 的 `security.blocked_patterns` 中追加自定义正则。传 `--force` 跳过检查。
|
|
96
98
|
|
|
97
|
-
|
|
99
|
+
## MCP 模式
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
以 MCP stdio 服务运行(供 Claude Code 等 AI 客户端调用):
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# 手动注册
|
|
105
|
+
claude mcp add ssh -- npx -y @bingzi-233/ssh-mcp --mcp
|
|
106
|
+
|
|
107
|
+
# 或通过插件安装
|
|
108
|
+
/plugin marketplace add BingZi-233/ssh-mcp
|
|
109
|
+
/plugin install ssh-mcp@bingzi-plugins
|
|
110
|
+
```
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
## 社区
|
|
102
113
|
|
|
103
|
-
|
|
104
|
-
- 优先用私钥或 `ssh-agent`,尽量不在配置里写明文密码。
|
|
105
|
-
- 给 SSH 账号最小权限;高危机器考虑用受限账号。
|
|
106
|
-
- 内置命令安全策略会拦截 `rm -rf /`、`dd` 写块设备、`mkfs`、fork 炸弹等高危模式。可在 `security.blocked_patterns` 中追加自定义正则(JSON 字符串,需双重转义,如 `"rm\\\\s.*-rf?\\\\s+/\\\\*"`)。传 `force=true` 可绕过。
|
|
114
|
+
本项目由 [LINUX DO](https://linux.do) 社区孵化并认可。
|
|
107
115
|
|
|
108
116
|
## License
|
|
109
117
|
|
package/dist/index.js
CHANGED
|
@@ -52,18 +52,362 @@ function formatTransfer(t) {
|
|
|
52
52
|
lines.push(` 错误: ${t.error}`);
|
|
53
53
|
return lines.join("\n");
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
server
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// MCP server setup (--mcp mode)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
function createMcpServer() {
|
|
59
|
+
const server = new McpServer({ name: "ssh-mcp", version: "1.3.0" });
|
|
60
|
+
server.registerTool("list_servers", {
|
|
61
|
+
title: "列出可用的 SSH 服务器",
|
|
62
|
+
description: "列出所有已配置的远程服务器及其 name、描述、地址和登录用户。" +
|
|
63
|
+
"在使用 run_command 之前,先用本工具确认有哪些服务器以及对应的 name。" +
|
|
64
|
+
"(不会返回任何密码或私钥等敏感信息。)",
|
|
65
|
+
inputSchema: {},
|
|
66
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
67
|
+
}, async () => {
|
|
68
|
+
try {
|
|
69
|
+
const servers = loadServers();
|
|
70
|
+
const list = [...servers.values()].map((s) => ({
|
|
71
|
+
name: s.name,
|
|
72
|
+
description: s.description ?? "",
|
|
73
|
+
host: s.host,
|
|
74
|
+
port: s.port ?? 22,
|
|
75
|
+
username: s.username,
|
|
76
|
+
}));
|
|
77
|
+
const text = list.length > 0
|
|
78
|
+
? JSON.stringify(list, null, 2)
|
|
79
|
+
: `没有已配置的服务器。请编辑配置文件:${configPath()}`;
|
|
80
|
+
return { content: [{ type: "text", text }] };
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
|
|
85
|
+
isError: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
server.registerTool("run_command", {
|
|
90
|
+
title: "在远程服务器上执行命令",
|
|
91
|
+
description: "通过 SSH 在指定的远程服务器上执行一条 shell 命令,返回 stdout、stderr 和退出码。" +
|
|
92
|
+
"用 server 参数指定目标服务器的 name(可先用 list_servers 查看)。" +
|
|
93
|
+
"可选传入 session(长连接会话 id)复用已有 TCP 连接,省去重复握手和认证;" +
|
|
94
|
+
"不传则每次新建连接、执行完即断开(短连接)。" +
|
|
95
|
+
"内置安全策略会拦截 rm -rf /、dd 写块设备、mkfs、fork 炸弹等高危命令," +
|
|
96
|
+
"可在 servers.json 的 security.blocked_patterns 中追加自定义正则。" +
|
|
97
|
+
"传入 force=true 可绕过安全检查(需明确知道自己在做什么)。" +
|
|
98
|
+
"注意:即便是长连接,每条命令仍在独立 channel 中执行,命令之间不保留工作目录或环境变量;" +
|
|
99
|
+
"需要保持上下文时请自行串接,例如 `cd /var/www && git pull`。",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
server: z.string().describe("目标服务器的 name,必须与 list_servers 返回的某个 name 完全一致"),
|
|
102
|
+
command: z.string().describe("要在远程服务器上执行的 shell 命令"),
|
|
103
|
+
timeout_ms: z.number().int().positive().optional().describe(`命令超时时间(毫秒),默认 ${DEFAULT_TIMEOUT_MS}`),
|
|
104
|
+
session: z.string().optional().describe("长连接会话 id(由 open_session 返回)。不填则使用短连接。"),
|
|
105
|
+
force: z.boolean().optional().describe("传入 true 跳过安全策略检查。请谨慎使用。"),
|
|
106
|
+
},
|
|
107
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
108
|
+
}, async ({ server: serverName, command, timeout_ms, session, force }) => {
|
|
109
|
+
let servers;
|
|
110
|
+
try {
|
|
111
|
+
servers = loadServers();
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
return { content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }], isError: true };
|
|
115
|
+
}
|
|
116
|
+
const cfg = servers.get(serverName);
|
|
117
|
+
if (!cfg) {
|
|
118
|
+
const names = [...servers.keys()].join(", ") || "(无)";
|
|
119
|
+
return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
|
|
120
|
+
}
|
|
121
|
+
if (!force) {
|
|
122
|
+
const security = loadSecurity();
|
|
123
|
+
const blocked = validateCommand(command, security.blocked_patterns ?? []);
|
|
124
|
+
if (blocked)
|
|
125
|
+
return { content: [{ type: "text", text: `${blocked}\n使用 force=true 可跳过安全检查。` }], isError: true };
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const r = await runCommand(cfg, command, timeout_ms ?? DEFAULT_TIMEOUT_MS, session);
|
|
129
|
+
const parts = [
|
|
130
|
+
`服务器: ${cfg.name} (${cfg.username}@${cfg.host}:${cfg.port ?? 22})`,
|
|
131
|
+
`退出码: ${r.code ?? "null"}${r.signal ? ` 信号: ${r.signal}` : ""}`,
|
|
132
|
+
];
|
|
133
|
+
if (r.stdout)
|
|
134
|
+
parts.push(`--- stdout ---\n${r.stdout.trimEnd()}`);
|
|
135
|
+
if (r.stderr)
|
|
136
|
+
parts.push(`--- stderr ---\n${r.stderr.trimEnd()}`);
|
|
137
|
+
if (!r.stdout && !r.stderr)
|
|
138
|
+
parts.push("(无输出)");
|
|
139
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
return { content: [{ type: "text", text: `执行失败:${e.message}` }], isError: true };
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
server.registerTool("open_session", {
|
|
146
|
+
title: "打开到远程服务器的长连接会话",
|
|
147
|
+
description: "与指定服务器建立一条持久的 SSH 连接并返回会话 id。该会话可被后续的 run_command " +
|
|
148
|
+
"通过 session 参数复用,省去重复的 TCP 握手和 SSH 认证开销。" +
|
|
149
|
+
"注意:即使使用长连接,每次 exec 仍在独立 channel 中执行,命令之间不保留工作目录或环境变量。",
|
|
150
|
+
inputSchema: { server: z.string().describe("目标服务器的 name(见 list_servers)") },
|
|
151
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
152
|
+
}, async ({ server: serverName }) => {
|
|
153
|
+
try {
|
|
154
|
+
const servers = loadServers();
|
|
155
|
+
const cfg = servers.get(serverName);
|
|
156
|
+
if (!cfg) {
|
|
157
|
+
const names = [...servers.keys()].join(", ") || "(无)";
|
|
158
|
+
return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
|
|
159
|
+
}
|
|
160
|
+
const s = await openSession(cfg, 20_000);
|
|
161
|
+
return { content: [{ type: "text", text: `长连接会话已建立\n id: ${s.id}\n 服务器: ${s.server}\n 创建时间: ${new Date(s.createdAt).toISOString()}` }] };
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
return { content: [{ type: "text", text: `打开长连接会话失败:${e.message}` }], isError: true };
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
server.registerTool("close_session", {
|
|
168
|
+
title: "关闭长连接会话",
|
|
169
|
+
description: "关闭由 open_session 建立的长连接会话,释放底层 SSH 连接。",
|
|
170
|
+
inputSchema: { session: z.string().describe("要关闭的会话 id(由 open_session 返回)") },
|
|
171
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
172
|
+
}, async ({ session }) => {
|
|
173
|
+
const ok = closeSession(session);
|
|
174
|
+
if (!ok)
|
|
175
|
+
return { content: [{ type: "text", text: `会话 ${session} 不存在或已断开。` }], isError: true };
|
|
176
|
+
return { content: [{ type: "text", text: `会话 ${session} 已关闭。` }] };
|
|
177
|
+
});
|
|
178
|
+
server.registerTool("list_sessions", {
|
|
179
|
+
title: "列出当前所有长连接会话",
|
|
180
|
+
description: "列出当前已打开的所有长连接会话的 id、关联服务器和创建时间。",
|
|
181
|
+
inputSchema: {},
|
|
182
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
183
|
+
}, async () => {
|
|
184
|
+
const all = listSessions();
|
|
185
|
+
const text = all.length > 0
|
|
186
|
+
? all.map((s) => ` ${s.id} → ${s.server}(${new Date(s.createdAt).toISOString()})`).join("\n")
|
|
187
|
+
: "当前没有长连接会话。";
|
|
188
|
+
return { content: [{ type: "text", text }] };
|
|
189
|
+
});
|
|
190
|
+
function loadServersOrError() {
|
|
191
|
+
try {
|
|
192
|
+
return { servers: loadServers(), error: null };
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return { servers: null, error: e.message };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
server.registerTool("upload_file", {
|
|
199
|
+
title: "上传文件到远程服务器",
|
|
200
|
+
description: "通过 SFTP 把本机文件上传到指定服务器,适用于大文件(40GB+)。" +
|
|
201
|
+
"这是后台任务:本工具立即返回一个传输 id,随后请用 transfer_status 轮询进度,不要阻塞等待。" +
|
|
202
|
+
"支持断点续传——对同一对路径再次调用会自动从远程已有字节处继续;" +
|
|
203
|
+
"若远程 remote_path 是已存在的目录,则自动在其下使用本地文件名。",
|
|
204
|
+
inputSchema: {
|
|
205
|
+
server: z.string().describe("目标服务器的 name(见 list_servers)"),
|
|
206
|
+
local_path: z.string().describe("本机要上传的文件路径(绝对路径)"),
|
|
207
|
+
remote_path: z.string().describe("远程目标路径(文件路径;若为已存在目录则自动追加文件名)"),
|
|
208
|
+
overwrite: z.boolean().optional().describe("true 则忽略远程已有部分、从头覆盖;默认 false(自动断点续传)"),
|
|
209
|
+
},
|
|
210
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
211
|
+
}, async ({ server: serverName, local_path, remote_path, overwrite }) => {
|
|
212
|
+
const { servers, error } = loadServersOrError();
|
|
213
|
+
if (!servers)
|
|
214
|
+
return { content: [{ type: "text", text: `读取服务器配置失败:${error}` }], isError: true };
|
|
215
|
+
const cfg = servers.get(serverName);
|
|
216
|
+
if (!cfg) {
|
|
217
|
+
const names = [...servers.keys()].join(", ") || "(无)";
|
|
218
|
+
return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const t = await startTransfer(cfg, "upload", local_path, remote_path, overwrite ?? false);
|
|
222
|
+
const hint = t.state === "completed" ? "\n(目标已是完整文件,无需传输。)" : `\n传输已在后台开始,用 transfer_status({ id: "${t.id}" }) 查看进度。`;
|
|
223
|
+
return { content: [{ type: "text", text: formatTransfer(t) + hint }] };
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
return { content: [{ type: "text", text: `发起上传失败:${e.message}` }], isError: true };
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
server.registerTool("download_file", {
|
|
230
|
+
title: "从远程服务器下载文件",
|
|
231
|
+
description: "通过 SFTP 把指定服务器上的文件下载到本机,适用于大文件(40GB+)。" +
|
|
232
|
+
"这是后台任务:本工具立即返回一个传输 id,随后请用 transfer_status 轮询进度,不要阻塞等待。" +
|
|
233
|
+
"支持断点续传——对同一对路径再次调用会自动从本地已有字节处继续;" +
|
|
234
|
+
"若本地 local_path 是已存在的目录,则自动在其下使用远程文件名。",
|
|
235
|
+
inputSchema: {
|
|
236
|
+
server: z.string().describe("源服务器的 name(见 list_servers)"),
|
|
237
|
+
remote_path: z.string().describe("远程要下载的文件路径"),
|
|
238
|
+
local_path: z.string().describe("本机目标路径(文件路径;若为已存在目录则自动追加文件名)"),
|
|
239
|
+
overwrite: z.boolean().optional().describe("true 则忽略本地已有部分、从头覆盖;默认 false(自动断点续传)"),
|
|
240
|
+
},
|
|
241
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
242
|
+
}, async ({ server: serverName, remote_path, local_path, overwrite }) => {
|
|
243
|
+
const { servers, error } = loadServersOrError();
|
|
244
|
+
if (!servers)
|
|
245
|
+
return { content: [{ type: "text", text: `读取服务器配置失败:${error}` }], isError: true };
|
|
246
|
+
const cfg = servers.get(serverName);
|
|
247
|
+
if (!cfg) {
|
|
248
|
+
const names = [...servers.keys()].join(", ") || "(无)";
|
|
249
|
+
return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
const t = await startTransfer(cfg, "download", local_path, remote_path, overwrite ?? false);
|
|
253
|
+
const hint = t.state === "completed" ? "\n(目标已是完整文件,无需传输。)" : `\n传输已在后台开始,用 transfer_status({ id: "${t.id}" }) 查看进度。`;
|
|
254
|
+
return { content: [{ type: "text", text: formatTransfer(t) + hint }] };
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
return { content: [{ type: "text", text: `发起下载失败:${e.message}` }], isError: true };
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
server.registerTool("transfer_status", {
|
|
261
|
+
title: "查看文件传输进度",
|
|
262
|
+
description: "查询后台文件传输任务的进度(已传字节、百分比、速度、预计剩余时间、状态)。传入 id 查看单个任务;不传则列出本次会话的全部任务。",
|
|
263
|
+
inputSchema: { id: z.string().optional().describe("传输任务 id;不填则列出全部任务") },
|
|
264
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
265
|
+
}, async ({ id }) => {
|
|
266
|
+
if (id) {
|
|
267
|
+
const t = getTransfer(id);
|
|
268
|
+
if (!t)
|
|
269
|
+
return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
|
|
270
|
+
return { content: [{ type: "text", text: formatTransfer(t) }] };
|
|
271
|
+
}
|
|
272
|
+
const all = listTransfers();
|
|
273
|
+
const text = all.length > 0 ? all.map(formatTransfer).join("\n\n") : "当前没有传输任务。";
|
|
274
|
+
return { content: [{ type: "text", text }] };
|
|
275
|
+
});
|
|
276
|
+
server.registerTool("cancel_transfer", {
|
|
277
|
+
title: "取消文件传输",
|
|
278
|
+
description: "取消一个正在进行的后台传输任务。已传输的部分文件会保留,之后可用同样的路径再次发起以断点续传。",
|
|
279
|
+
inputSchema: { id: z.string().describe("要取消的传输任务 id") },
|
|
280
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
281
|
+
}, async ({ id }) => {
|
|
282
|
+
const t = cancelTransfer(id);
|
|
283
|
+
if (!t)
|
|
284
|
+
return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
|
|
285
|
+
return { content: [{ type: "text", text: `已请求取消:\n${formatTransfer(t)}` }] };
|
|
286
|
+
});
|
|
287
|
+
return server;
|
|
288
|
+
}
|
|
289
|
+
function parseArgs(raw) {
|
|
290
|
+
const options = new Map();
|
|
291
|
+
let subcommand = "";
|
|
292
|
+
let i = 0;
|
|
293
|
+
while (i < raw.length) {
|
|
294
|
+
const a = raw[i];
|
|
295
|
+
if (a === "--") {
|
|
296
|
+
i++;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
if (a.startsWith("--")) {
|
|
300
|
+
const eq = a.indexOf("=");
|
|
301
|
+
if (eq >= 0) {
|
|
302
|
+
options.set(a.slice(2, eq), a.slice(eq + 1));
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
const key = a.slice(2);
|
|
306
|
+
if (i + 1 < raw.length && !raw[i + 1].startsWith("-")) {
|
|
307
|
+
options.set(key, raw[++i]);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
options.set(key, true);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
else if (a.startsWith("-") && a.length === 2 && a[1] !== "-") {
|
|
315
|
+
// short flag: -c → value
|
|
316
|
+
const key = a.slice(1);
|
|
317
|
+
if (i + 1 < raw.length && !raw[i + 1].startsWith("-")) {
|
|
318
|
+
options.set(key, raw[++i]);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
options.set(key, true);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
else if (!subcommand) {
|
|
325
|
+
subcommand = a;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
i++;
|
|
331
|
+
}
|
|
332
|
+
return { subcommand, options, positional: raw.slice(i) };
|
|
333
|
+
}
|
|
334
|
+
function optStr(opts, key) {
|
|
335
|
+
const v = opts.get(key);
|
|
336
|
+
return typeof v === "string" ? v : undefined;
|
|
337
|
+
}
|
|
338
|
+
function optNum(opts, key) {
|
|
339
|
+
const v = opts.get(key);
|
|
340
|
+
if (typeof v === "string") {
|
|
341
|
+
const n = Number(v);
|
|
342
|
+
return isNaN(n) ? undefined : n;
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
function optBool(opts, key) {
|
|
347
|
+
const v = opts.get(key);
|
|
348
|
+
return v === true || v === "true" || v === "1";
|
|
349
|
+
}
|
|
350
|
+
function die(msg) {
|
|
351
|
+
process.stderr.write(`ssh-mcp: ${msg}\n`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
function showHelp() {
|
|
355
|
+
process.stdout.write(`ssh-mcp — SSH/SFTP 远程服务器命令行工具 v1.2.0
|
|
356
|
+
|
|
357
|
+
用法: ssh-mcp <子命令> [选项]
|
|
358
|
+
|
|
359
|
+
子命令:
|
|
360
|
+
list-servers 列出所有已配置的服务器
|
|
361
|
+
run-command 在远程服务器上执行命令
|
|
362
|
+
open-session 打开到远程服务器的长连接会话
|
|
363
|
+
close-session 关闭长连接会话
|
|
364
|
+
list-sessions 列出当前所有长连接会话
|
|
365
|
+
upload 上传文件到远程服务器(支持断点续传)
|
|
366
|
+
download 从远程服务器下载文件(支持断点续传)
|
|
367
|
+
transfer-status 查看文件传输进度
|
|
368
|
+
cancel-transfer 取消文件传输
|
|
369
|
+
|
|
370
|
+
全局选项:
|
|
371
|
+
--mcp 以 MCP stdio 服务模式运行(供 AI 客户端调用)
|
|
372
|
+
--help, -h 显示此帮助信息
|
|
373
|
+
|
|
374
|
+
配置:
|
|
375
|
+
服务器配置文件路径: ${configPath()}
|
|
376
|
+
也可通过环境变量 SSH_MCP_CONFIG 指定其他路径。
|
|
377
|
+
|
|
378
|
+
示例 servers.json:
|
|
379
|
+
{
|
|
380
|
+
"servers": [
|
|
381
|
+
{
|
|
382
|
+
"name": "prod-web",
|
|
383
|
+
"description": "生产环境",
|
|
384
|
+
"host": "192.168.1.10",
|
|
385
|
+
"port": 22,
|
|
386
|
+
"username": "deploy",
|
|
387
|
+
"privateKeyPath": "~/.ssh/id_rsa"
|
|
388
|
+
}
|
|
389
|
+
]
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
各子命令详细用法请运行: ssh-mcp <子命令> --help
|
|
393
|
+
`);
|
|
394
|
+
}
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// CLI handlers
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
async function cmdListServers(opts) {
|
|
399
|
+
if (optBool(opts, "help")) {
|
|
400
|
+
process.stdout.write(`用法: ssh-mcp list-servers [--json]
|
|
401
|
+
|
|
402
|
+
选项:
|
|
403
|
+
--json 以 JSON 格式输出
|
|
404
|
+
|
|
405
|
+
示例:
|
|
406
|
+
ssh-mcp list-servers
|
|
407
|
+
ssh-mcp list-servers --json
|
|
408
|
+
`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
67
411
|
try {
|
|
68
412
|
const servers = loadServers();
|
|
69
413
|
const list = [...servers.values()].map((s) => ({
|
|
@@ -73,282 +417,328 @@ server.registerTool("list_servers", {
|
|
|
73
417
|
port: s.port ?? 22,
|
|
74
418
|
username: s.username,
|
|
75
419
|
}));
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
420
|
+
if (optBool(opts, "json")) {
|
|
421
|
+
process.stdout.write(JSON.stringify(list, null, 2) + "\n");
|
|
422
|
+
}
|
|
423
|
+
else if (list.length === 0) {
|
|
424
|
+
process.stdout.write(`没有已配置的服务器。请编辑配置文件:${configPath()}\n`);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
for (const s of list) {
|
|
428
|
+
process.stdout.write(`${s.name.padEnd(16)} ${s.username}@${s.host}:${s.port} ${s.description}\n`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
80
431
|
}
|
|
81
432
|
catch (e) {
|
|
82
|
-
|
|
83
|
-
content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
|
|
84
|
-
isError: true,
|
|
85
|
-
};
|
|
433
|
+
die(`读取服务器配置失败:${e.message}`);
|
|
86
434
|
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
},
|
|
119
|
-
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
120
|
-
}, async ({ server: serverName, command, timeout_ms, session, force }) => {
|
|
435
|
+
}
|
|
436
|
+
async function cmdRunCommand(opts, positional) {
|
|
437
|
+
if (optBool(opts, "help")) {
|
|
438
|
+
process.stdout.write(`用法: ssh-mcp run-command --server <name> [选项] <命令...>
|
|
439
|
+
|
|
440
|
+
选项:
|
|
441
|
+
--server, -s <name> 目标服务器 name(必需)
|
|
442
|
+
--timeout <ms> 命令超时毫秒数(默认 ${DEFAULT_TIMEOUT_MS})
|
|
443
|
+
--session <id> 长连接会话 id(复用已有连接)
|
|
444
|
+
--force 跳过安全策略检查
|
|
445
|
+
--command, -c <cmd> 要执行的命令(也可直接放在选项之后)
|
|
446
|
+
|
|
447
|
+
示例:
|
|
448
|
+
ssh-mcp run-command --server prod-web --command "uptime"
|
|
449
|
+
ssh-mcp run-command -s prod-web "df -h /"
|
|
450
|
+
ssh-mcp run-command -s prod-web --session s1 "tail -50 /var/log/nginx/access.log"
|
|
451
|
+
`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const serverName = optStr(opts, "server") ?? optStr(opts, "s");
|
|
455
|
+
if (!serverName)
|
|
456
|
+
die("缺少 --server。用法: ssh-mcp run-command --server <name> <命令>");
|
|
457
|
+
let command = optStr(opts, "command") ?? optStr(opts, "c");
|
|
458
|
+
if (!command) {
|
|
459
|
+
command = positional.join(" ");
|
|
460
|
+
}
|
|
461
|
+
if (!command || command.trim() === "")
|
|
462
|
+
die("缺少命令。用法: ssh-mcp run-command --server <name> <命令>");
|
|
463
|
+
const timeout = optNum(opts, "timeout") ?? DEFAULT_TIMEOUT_MS;
|
|
464
|
+
const session = optStr(opts, "session");
|
|
465
|
+
const force = optBool(opts, "force");
|
|
121
466
|
let servers;
|
|
122
467
|
try {
|
|
123
468
|
servers = loadServers();
|
|
124
469
|
}
|
|
125
470
|
catch (e) {
|
|
126
|
-
|
|
127
|
-
content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
|
|
128
|
-
isError: true,
|
|
129
|
-
};
|
|
471
|
+
die(`读取服务器配置失败:${e.message}`);
|
|
130
472
|
}
|
|
131
473
|
const cfg = servers.get(serverName);
|
|
132
474
|
if (!cfg) {
|
|
133
475
|
const names = [...servers.keys()].join(", ") || "(无)";
|
|
134
|
-
|
|
135
|
-
content: [
|
|
136
|
-
{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` },
|
|
137
|
-
],
|
|
138
|
-
isError: true,
|
|
139
|
-
};
|
|
476
|
+
die(`未找到名为 "${serverName}" 的服务器。可用:${names}`);
|
|
140
477
|
}
|
|
141
478
|
if (!force) {
|
|
142
479
|
const security = loadSecurity();
|
|
143
480
|
const blocked = validateCommand(command, security.blocked_patterns ?? []);
|
|
144
|
-
if (blocked)
|
|
145
|
-
|
|
146
|
-
content: [{ type: "text", text: `${blocked}\n使用 force=true 可跳过安全检查。` }],
|
|
147
|
-
isError: true,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
481
|
+
if (blocked)
|
|
482
|
+
die(blocked + "\n使用 --force 可跳过安全检查。");
|
|
150
483
|
}
|
|
151
484
|
try {
|
|
152
|
-
const r = await runCommand(cfg, command,
|
|
153
|
-
const parts = [
|
|
154
|
-
`服务器: ${cfg.name} (${cfg.username}@${cfg.host}:${cfg.port ?? 22})`,
|
|
155
|
-
`退出码: ${r.code ?? "null"}${r.signal ? ` 信号: ${r.signal}` : ""}`,
|
|
156
|
-
];
|
|
485
|
+
const r = await runCommand(cfg, command, timeout, session);
|
|
157
486
|
if (r.stdout)
|
|
158
|
-
|
|
487
|
+
process.stdout.write(r.stdout);
|
|
159
488
|
if (r.stderr)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
parts.push("(无输出)");
|
|
163
|
-
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
489
|
+
process.stderr.write(r.stderr);
|
|
490
|
+
process.exit(r.code ?? 1);
|
|
164
491
|
}
|
|
165
492
|
catch (e) {
|
|
166
|
-
|
|
167
|
-
content: [{ type: "text", text: `执行失败:${e.message}` }],
|
|
168
|
-
isError: true,
|
|
169
|
-
};
|
|
493
|
+
die(`执行失败:${e.message}`);
|
|
170
494
|
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
495
|
+
}
|
|
496
|
+
async function cmdOpenSession(opts) {
|
|
497
|
+
if (optBool(opts, "help")) {
|
|
498
|
+
process.stdout.write(`用法: ssh-mcp open-session --server <name> [--timeout <ms>]
|
|
499
|
+
|
|
500
|
+
选项:
|
|
501
|
+
--server, -s <name> 目标服务器 name(必需)
|
|
502
|
+
--timeout <ms> 连接超时毫秒数(默认 20000)
|
|
503
|
+
|
|
504
|
+
示例:
|
|
505
|
+
ssh-mcp open-session -s prod-web
|
|
506
|
+
SESSION=s1
|
|
507
|
+
ssh-mcp run-command -s prod-web --session $SESSION "hostname"
|
|
508
|
+
ssh-mcp close-session --session $SESSION
|
|
509
|
+
`);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const serverName = optStr(opts, "server") ?? optStr(opts, "s");
|
|
513
|
+
if (!serverName)
|
|
514
|
+
die("缺少 --server。用法: ssh-mcp open-session --server <name>");
|
|
182
515
|
try {
|
|
183
516
|
const servers = loadServers();
|
|
184
517
|
const cfg = servers.get(serverName);
|
|
185
518
|
if (!cfg) {
|
|
186
519
|
const names = [...servers.keys()].join(", ") || "(无)";
|
|
187
|
-
|
|
188
|
-
content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }],
|
|
189
|
-
isError: true,
|
|
190
|
-
};
|
|
520
|
+
die(`未找到名为 "${serverName}" 的服务器。可用:${names}`);
|
|
191
521
|
}
|
|
192
|
-
const s = await openSession(cfg, 20_000);
|
|
193
|
-
|
|
522
|
+
const s = await openSession(cfg, optNum(opts, "timeout") ?? 20_000);
|
|
523
|
+
process.stdout.write(`${s.id}\n`);
|
|
194
524
|
}
|
|
195
525
|
catch (e) {
|
|
196
|
-
|
|
197
|
-
content: [{ type: "text", text: `打开长连接会话失败:${e.message}` }],
|
|
198
|
-
isError: true,
|
|
199
|
-
};
|
|
526
|
+
die(`打开长连接会话失败:${e.message}`);
|
|
200
527
|
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
528
|
+
}
|
|
529
|
+
async function cmdCloseSession(opts) {
|
|
530
|
+
if (optBool(opts, "help")) {
|
|
531
|
+
process.stdout.write(`用法: ssh-mcp close-session --session <id>
|
|
532
|
+
|
|
533
|
+
选项:
|
|
534
|
+
--session, -s <id> 要关闭的会话 id(必需)
|
|
535
|
+
|
|
536
|
+
示例:
|
|
537
|
+
ssh-mcp close-session -s s1
|
|
538
|
+
`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const session = optStr(opts, "session") ?? optStr(opts, "s");
|
|
542
|
+
if (!session)
|
|
543
|
+
die("缺少 --session。用法: ssh-mcp close-session --session <id>");
|
|
210
544
|
const ok = closeSession(session);
|
|
211
|
-
if (!ok)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
545
|
+
if (!ok)
|
|
546
|
+
die(`会话 ${session} 不存在或已断开。`);
|
|
547
|
+
process.stdout.write(`会话 ${session} 已关闭。\n`);
|
|
548
|
+
}
|
|
549
|
+
async function cmdListSessions(opts) {
|
|
550
|
+
if (optBool(opts, "help")) {
|
|
551
|
+
process.stdout.write(`用法: ssh-mcp list-sessions
|
|
552
|
+
|
|
553
|
+
列出当前所有活跃的长连接会话(id、对应服务器、创建时间)。
|
|
554
|
+
|
|
555
|
+
示例:
|
|
556
|
+
ssh-mcp list-sessions
|
|
557
|
+
`);
|
|
558
|
+
return;
|
|
216
559
|
}
|
|
217
|
-
return { content: [{ type: "text", text: `会话 ${session} 已关闭。` }] };
|
|
218
|
-
});
|
|
219
|
-
server.registerTool("list_sessions", {
|
|
220
|
-
title: "列出当前所有长连接会话",
|
|
221
|
-
description: "列出当前已打开的所有长连接会话的 id、关联服务器和创建时间。",
|
|
222
|
-
inputSchema: {},
|
|
223
|
-
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
224
|
-
}, async () => {
|
|
225
560
|
const all = listSessions();
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
.join("\n")
|
|
230
|
-
: "当前没有长连接会话。";
|
|
231
|
-
return { content: [{ type: "text", text }] };
|
|
232
|
-
});
|
|
233
|
-
function loadServersOrError() {
|
|
234
|
-
try {
|
|
235
|
-
return { servers: loadServers(), error: null };
|
|
561
|
+
if (all.length === 0) {
|
|
562
|
+
process.stdout.write("当前没有长连接会话。\n");
|
|
563
|
+
return;
|
|
236
564
|
}
|
|
237
|
-
|
|
238
|
-
|
|
565
|
+
for (const s of all) {
|
|
566
|
+
process.stdout.write(`${s.id.padEnd(8)} → ${s.server.padEnd(20)} ${new Date(s.createdAt).toISOString()}\n`);
|
|
239
567
|
}
|
|
240
568
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
257
|
-
}, async ({ server: serverName, local_path, remote_path, overwrite }) => {
|
|
258
|
-
const { servers, error } = loadServersOrError();
|
|
259
|
-
if (!servers)
|
|
260
|
-
return { content: [{ type: "text", text: `读取服务器配置失败:${error}` }], isError: true };
|
|
261
|
-
const cfg = servers.get(serverName);
|
|
262
|
-
if (!cfg) {
|
|
263
|
-
const names = [...servers.keys()].join(", ") || "(无)";
|
|
264
|
-
return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
|
|
569
|
+
async function cmdUpload(opts) {
|
|
570
|
+
if (optBool(opts, "help")) {
|
|
571
|
+
process.stdout.write(`用法: ssh-mcp upload --server <name> --local <path> --remote <path> [--overwrite]
|
|
572
|
+
|
|
573
|
+
选项:
|
|
574
|
+
--server, -s <name> 目标服务器 name(必需)
|
|
575
|
+
--local, -l <path> 本地文件路径(必需)
|
|
576
|
+
--remote, -r <path> 远程目标路径(必需)
|
|
577
|
+
--overwrite 从头覆盖远程文件(默认断点续传)
|
|
578
|
+
|
|
579
|
+
示例:
|
|
580
|
+
ssh-mcp upload -s prod-web -l ./dist.tar.gz -r /tmp/dist.tar.gz
|
|
581
|
+
ssh-mcp upload -s prod-web -l ./app.log -r /var/log/ --overwrite
|
|
582
|
+
`);
|
|
583
|
+
return;
|
|
265
584
|
}
|
|
585
|
+
const serverName = optStr(opts, "server") ?? optStr(opts, "s");
|
|
586
|
+
const localPath = optStr(opts, "local") ?? optStr(opts, "l");
|
|
587
|
+
const remotePath = optStr(opts, "remote") ?? optStr(opts, "r");
|
|
588
|
+
if (!serverName)
|
|
589
|
+
die("缺少 --server");
|
|
590
|
+
if (!localPath)
|
|
591
|
+
die("缺少 --local");
|
|
592
|
+
if (!remotePath)
|
|
593
|
+
die("缺少 --remote");
|
|
594
|
+
const { servers, error } = loadServersOrDie();
|
|
595
|
+
const cfg = servers.get(serverName);
|
|
596
|
+
if (!cfg)
|
|
597
|
+
die(`未找到名为 "${serverName}" 的服务器。可用:${[...servers.keys()].join(", ") || "(无)"}`);
|
|
266
598
|
try {
|
|
267
|
-
const t = await startTransfer(cfg, "upload",
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
599
|
+
const t = await startTransfer(cfg, "upload", localPath, remotePath, optBool(opts, "overwrite"));
|
|
600
|
+
process.stdout.write(formatTransfer(t) + "\n");
|
|
601
|
+
if (t.state === "completed")
|
|
602
|
+
process.stdout.write("目标已是完整文件,无需传输。\n");
|
|
603
|
+
else
|
|
604
|
+
process.stdout.write(`传输已在后台开始,用 ssh-mcp transfer-status --id ${t.id} 查看进度。\n`);
|
|
272
605
|
}
|
|
273
606
|
catch (e) {
|
|
274
|
-
|
|
607
|
+
die(`发起上传失败:${e.message}`);
|
|
275
608
|
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
293
|
-
}, async ({ server: serverName, remote_path, local_path, overwrite }) => {
|
|
294
|
-
const { servers, error } = loadServersOrError();
|
|
295
|
-
if (!servers)
|
|
296
|
-
return { content: [{ type: "text", text: `读取服务器配置失败:${error}` }], isError: true };
|
|
297
|
-
const cfg = servers.get(serverName);
|
|
298
|
-
if (!cfg) {
|
|
299
|
-
const names = [...servers.keys()].join(", ") || "(无)";
|
|
300
|
-
return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
|
|
609
|
+
}
|
|
610
|
+
async function cmdDownload(opts) {
|
|
611
|
+
if (optBool(opts, "help")) {
|
|
612
|
+
process.stdout.write(`用法: ssh-mcp download --server <name> --remote <path> --local <path> [--overwrite]
|
|
613
|
+
|
|
614
|
+
选项:
|
|
615
|
+
--server, -s <name> 源服务器 name(必需)
|
|
616
|
+
--remote, -r <path> 远程文件路径(必需)
|
|
617
|
+
--local, -l <path> 本地目标路径(必需)
|
|
618
|
+
--overwrite 从头覆盖本地文件(默认断点续传)
|
|
619
|
+
|
|
620
|
+
示例:
|
|
621
|
+
ssh-mcp download -s prod-web -r /var/log/app.log -l ./logs/app.log
|
|
622
|
+
ssh-mcp download -s prod-web -r /tmp/data.bin -l ./downloads/ --overwrite
|
|
623
|
+
`);
|
|
624
|
+
return;
|
|
301
625
|
}
|
|
626
|
+
const serverName = optStr(opts, "server") ?? optStr(opts, "s");
|
|
627
|
+
const remotePath = optStr(opts, "remote") ?? optStr(opts, "r");
|
|
628
|
+
const localPath = optStr(opts, "local") ?? optStr(opts, "l");
|
|
629
|
+
if (!serverName)
|
|
630
|
+
die("缺少 --server");
|
|
631
|
+
if (!remotePath)
|
|
632
|
+
die("缺少 --remote");
|
|
633
|
+
if (!localPath)
|
|
634
|
+
die("缺少 --local");
|
|
635
|
+
const { servers, error } = loadServersOrDie();
|
|
636
|
+
const cfg = servers.get(serverName);
|
|
637
|
+
if (!cfg)
|
|
638
|
+
die(`未找到名为 "${serverName}" 的服务器。可用:${[...servers.keys()].join(", ") || "(无)"}`);
|
|
302
639
|
try {
|
|
303
|
-
const t = await startTransfer(cfg, "download",
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
640
|
+
const t = await startTransfer(cfg, "download", localPath, remotePath, optBool(opts, "overwrite"));
|
|
641
|
+
process.stdout.write(formatTransfer(t) + "\n");
|
|
642
|
+
if (t.state === "completed")
|
|
643
|
+
process.stdout.write("目标已是完整文件,无需传输。\n");
|
|
644
|
+
else
|
|
645
|
+
process.stdout.write(`传输已在后台开始,用 ssh-mcp transfer-status --id ${t.id} 查看进度。\n`);
|
|
308
646
|
}
|
|
309
647
|
catch (e) {
|
|
310
|
-
|
|
648
|
+
die(`发起下载失败:${e.message}`);
|
|
311
649
|
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
650
|
+
}
|
|
651
|
+
async function cmdTransferStatus(opts) {
|
|
652
|
+
if (optBool(opts, "help")) {
|
|
653
|
+
process.stdout.write(`用法: ssh-mcp transfer-status [--id <id>]
|
|
654
|
+
|
|
655
|
+
选项:
|
|
656
|
+
--id, -i <id> 传输任务 id;不填则列出全部任务
|
|
657
|
+
|
|
658
|
+
示例:
|
|
659
|
+
ssh-mcp transfer-status
|
|
660
|
+
ssh-mcp transfer-status -i t1
|
|
661
|
+
`);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const id = optStr(opts, "id") ?? optStr(opts, "i");
|
|
322
665
|
if (id) {
|
|
323
666
|
const t = getTransfer(id);
|
|
324
667
|
if (!t)
|
|
325
|
-
|
|
326
|
-
|
|
668
|
+
die(`未找到传输任务:${id}`);
|
|
669
|
+
process.stdout.write(formatTransfer(t) + "\n");
|
|
327
670
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
671
|
+
else {
|
|
672
|
+
const all = listTransfers();
|
|
673
|
+
if (all.length === 0)
|
|
674
|
+
process.stdout.write("当前没有传输任务。\n");
|
|
675
|
+
else
|
|
676
|
+
process.stdout.write(all.map(formatTransfer).join("\n\n") + "\n");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async function cmdCancelTransfer(opts) {
|
|
680
|
+
if (optBool(opts, "help")) {
|
|
681
|
+
process.stdout.write(`用法: ssh-mcp cancel-transfer --id <id>
|
|
682
|
+
|
|
683
|
+
选项:
|
|
684
|
+
--id, -i <id> 要取消的传输任务 id(必需)
|
|
685
|
+
|
|
686
|
+
示例:
|
|
687
|
+
ssh-mcp cancel-transfer -i t1
|
|
688
|
+
`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const id = optStr(opts, "id") ?? optStr(opts, "i");
|
|
692
|
+
if (!id)
|
|
693
|
+
die("缺少 --id。用法: ssh-mcp cancel-transfer --id <id>");
|
|
340
694
|
const t = cancelTransfer(id);
|
|
341
695
|
if (!t)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
696
|
+
die(`未找到传输任务:${id}`);
|
|
697
|
+
process.stdout.write(`已请求取消:\n${formatTransfer(t)}\n`);
|
|
698
|
+
}
|
|
699
|
+
function loadServersOrDie() {
|
|
700
|
+
try {
|
|
701
|
+
return { servers: loadServers(), error: null };
|
|
702
|
+
}
|
|
703
|
+
catch (e) {
|
|
704
|
+
die(`读取服务器配置失败:${e.message}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
// Main entry
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
345
710
|
async function main() {
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
711
|
+
const { subcommand, options, positional } = parseArgs(process.argv.slice(2));
|
|
712
|
+
// --mcp flag → run as MCP stdio server
|
|
713
|
+
if (subcommand === "--mcp" || options.has("mcp")) {
|
|
714
|
+
const server = createMcpServer();
|
|
715
|
+
const transport = new StdioServerTransport();
|
|
716
|
+
await server.connect(transport);
|
|
717
|
+
console.error(`ssh-mcp MCP 服务已启动,配置文件:${configPath()}`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// Route to subcommand first (subcommand may have its own --help)
|
|
721
|
+
switch (subcommand) {
|
|
722
|
+
case "help":
|
|
723
|
+
case undefined:
|
|
724
|
+
case "": {
|
|
725
|
+
showHelp();
|
|
726
|
+
process.exit(0);
|
|
727
|
+
}
|
|
728
|
+
case "list-servers": return cmdListServers(options);
|
|
729
|
+
case "run-command": return cmdRunCommand(options, positional);
|
|
730
|
+
case "open-session": return cmdOpenSession(options);
|
|
731
|
+
case "close-session": return cmdCloseSession(options);
|
|
732
|
+
case "list-sessions": return cmdListSessions(options);
|
|
733
|
+
case "upload": return cmdUpload(options);
|
|
734
|
+
case "download": return cmdDownload(options);
|
|
735
|
+
case "transfer-status": return cmdTransferStatus(options);
|
|
736
|
+
case "cancel-transfer": return cmdCancelTransfer(options);
|
|
737
|
+
default:
|
|
738
|
+
die(`未知子命令: ${subcommand}\n运行 ssh-mcp --help 查看可用命令。`);
|
|
739
|
+
}
|
|
350
740
|
}
|
|
351
741
|
main().catch((e) => {
|
|
352
|
-
|
|
742
|
+
process.stderr.write(`ssh-mcp 启动失败:${e}\n`);
|
|
353
743
|
process.exit(1);
|
|
354
744
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bingzi-233/ssh-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "纯命令行 SSH/SFTP 工具:在多台远程服务器上执行命令、传输大文件(断点续传)。支持 CLI 模式和 MCP stdio 模式。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ssh-mcp": "dist/index.js"
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
"prepublishOnly": "npm run build"
|
|
20
20
|
},
|
|
21
21
|
"keywords": [
|
|
22
|
-
"
|
|
23
|
-
"model-context-protocol",
|
|
22
|
+
"cli",
|
|
24
23
|
"ssh",
|
|
25
24
|
"sftp",
|
|
25
|
+
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
26
27
|
"remote",
|
|
27
|
-
"file-transfer"
|
|
28
|
-
"claude"
|
|
28
|
+
"file-transfer"
|
|
29
29
|
],
|
|
30
30
|
"author": "BingZi-233",
|
|
31
31
|
"license": "MIT",
|