@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.
Files changed (3) hide show
  1. package/README.md +65 -57
  2. package/dist/index.js +622 -232
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -1,65 +1,58 @@
1
1
  # ssh-mcp
2
2
 
3
- 一个 MCP 服务器,让 Claude 通过 **SSH** 在多台远程服务器上执行命令、并通过 **SFTP** 传输大文件(支持 40GB+ 与断点续传)。每台服务器配一个 `name`,模型靠 `name` 区分目标机器。
3
+ 纯命令行 SSH/SFTP 工具:在多台远程服务器上执行命令、传输大文件(断点续传)。支持 CLI 模式和 MCP stdio 模式(`--mcp`)。
4
4
 
5
- ## 工具
5
+ [![NPM](https://img.shields.io/npm/v/@bingzi-233/ssh-mcp?color=CB3837&logo=npm)](https://www.npmjs.com/package/@bingzi-233/ssh-mcp)
6
+ [![Node](https://img.shields.io/node/v/@bingzi-233/ssh-mcp?color=339933&logo=nodedotjs)](https://nodejs.org)
7
+ [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org)
8
+ [![Stars](https://img.shields.io/github/stars/BingZi-233/ssh-mcp?color=EAC54F&logo=github)](https://github.com/BingZi-233/ssh-mcp/stargazers)
9
+ [![License](https://img.shields.io/github/license/BingZi-233/ssh-mcp?color=4A90D9)](https://github.com/BingZi-233/ssh-mcp/blob/master/LICENSE)
10
+ [![LINUX DO](https://img.shields.io/badge/LINUX_DO-社区认可-4A90D9?logo=discourse&logoColor=white)](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
- > - **长连接**:用 `open_session` 建立持久连接,`run_command` 传入 `session` 参数复用,省去重复握手和认证开销。即便复用了连接,每条命令仍在独立 channel 中执行,工作目录和环境变量不保留。
20
- > - **安全策略**:内置拦截 `rm -rf /`、`dd` 写块设备、`mkfs`、fork 炸弹等高危命令。可在 `servers.json` 的 `security.blocked_patterns` 中追加自定义正则。传入 `force=true` 可绕过。
21
- > - 命令在独立会话中执行,**命令之间不保留工作目录和环境变量**。需要保持上下文时自行串接,例如 `cd /var/www && git pull`。
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
- ### 方式一:作为 Claude Code 插件(推荐)
24
+ # 执行远程命令
25
+ ssh-mcp run-command -s prod-web -c "df -h /"
30
26
 
31
- ```shell
32
- /plugin marketplace add BingZi-233/ssh-mcp
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
- ### 方式二:作为 MCP 服务器手动注册
30
+ # 下载文件
31
+ ssh-mcp download -s prod-web -r /var/log/app.log -l ./logs/app.log
39
32
 
40
- ```bash
41
- claude mcp add ssh -- npx -y @bingzi-233/ssh-mcp
33
+ # 传输进度
34
+ ssh-mcp transfer-status
42
35
  ```
43
36
 
44
- 需要自定义配置文件路径时:
37
+ ## 命令一览
45
38
 
46
- ```bash
47
- claude mcp add ssh -e SSH_MCP_CONFIG=/path/to/servers.json -- npx -y @bingzi-233/ssh-mcp
48
- ```
49
-
50
- ### 方式三:本地源码构建(开发用)
51
-
52
- ```bash
53
- npm install
54
- npm run build
55
- claude mcp add ssh -- node /绝对路径/ssh-mcp/dist/index.js
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
- 默认从**运行目录下的 `./servers.json`** 读取(即 Claude 启动 MCP 服务器时所在的工作目录,通常是你当前的项目根目录)。也可用环境变量 `SSH_MCP_CONFIG` 指定任意路径。参考 `servers.example.json`:
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
- 1. `privateKeyPath`(+ 可选 `passphrase`)—— 私钥文件,支持 `~` 展开
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
- - `servers.json` 含明文凭据,已在 `.gitignore` 中排除,**切勿提交到仓库**。
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
- const server = new McpServer({
56
- name: "ssh-mcp",
57
- version: "1.0.0",
58
- });
59
- server.registerTool("list_servers", {
60
- title: "列出可用的 SSH 服务器",
61
- description: "列出所有已配置的远程服务器及其 name、描述、地址和登录用户。" +
62
- "在使用 run_command 之前,先用本工具确认有哪些服务器以及对应的 name" +
63
- "(不会返回任何密码或私钥等敏感信息。)",
64
- inputSchema: {},
65
- annotations: { readOnlyHint: true, openWorldHint: false },
66
- }, async () => {
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
- const text = list.length > 0
77
- ? JSON.stringify(list, null, 2)
78
- : `没有已配置的服务器。请编辑配置文件:${configPath()}`;
79
- return { content: [{ type: "text", text }] };
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
- return {
83
- content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
84
- isError: true,
85
- };
433
+ die(`读取服务器配置失败:${e.message}`);
86
434
  }
87
- });
88
- server.registerTool("run_command", {
89
- title: "在远程服务器上执行命令",
90
- description: "通过 SSH 在指定的远程服务器上执行一条 shell 命令,返回 stdout、stderr 和退出码。" +
91
- "用 server 参数指定目标服务器的 name(可先用 list_servers 查看)。" +
92
- "可选传入 session(长连接会话 id)复用已有 TCP 连接,省去重复握手和认证;" +
93
- "不传则每次新建连接、执行完即断开(短连接)。" +
94
- "内置安全策略会拦截 rm -rf /、dd 写块设备、mkfs、fork 炸弹等高危命令," +
95
- "可在 servers.json 的 security.blocked_patterns 中追加自定义正则。" +
96
- "传入 force=true 可绕过安全检查(需明确知道自己在做什么)。" +
97
- "注意:即便是长连接,每条命令仍在独立 channel 中执行,命令之间不保留工作目录或环境变量;" +
98
- "需要保持上下文时请自行串接,例如 `cd /var/www && git pull`。",
99
- inputSchema: {
100
- server: z
101
- .string()
102
- .describe("目标服务器的 name,必须与 list_servers 返回的某个 name 完全一致"),
103
- command: z.string().describe("要在远程服务器上执行的 shell 命令"),
104
- timeout_ms: z
105
- .number()
106
- .int()
107
- .positive()
108
- .optional()
109
- .describe(`命令超时时间(毫秒),默认 ${DEFAULT_TIMEOUT_MS}`),
110
- session: z
111
- .string()
112
- .optional()
113
- .describe("长连接会话 id(由 open_session 返回)。不填则使用短连接。"),
114
- force: z
115
- .boolean()
116
- .optional()
117
- .describe("传入 true 跳过安全策略检查。请谨慎使用。"),
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
- return {
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
- return {
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
- return {
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, timeout_ms ?? DEFAULT_TIMEOUT_MS, session);
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
- parts.push(`--- stdout ---\n${r.stdout.trimEnd()}`);
487
+ process.stdout.write(r.stdout);
159
488
  if (r.stderr)
160
- parts.push(`--- stderr ---\n${r.stderr.trimEnd()}`);
161
- if (!r.stdout && !r.stderr)
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
- return {
167
- content: [{ type: "text", text: `执行失败:${e.message}` }],
168
- isError: true,
169
- };
493
+ die(`执行失败:${e.message}`);
170
494
  }
171
- });
172
- server.registerTool("open_session", {
173
- title: "打开到远程服务器的长连接会话",
174
- description: "与指定服务器建立一条持久的 SSH 连接并返回会话 id。该会话可被后续的 run_command " +
175
- "通过 session 参数复用,省去重复的 TCP 握手和 SSH 认证开销。" +
176
- "注意:即使使用长连接,每次 exec 仍在独立 channel 中执行,命令之间不保留工作目录或环境变量。",
177
- inputSchema: {
178
- server: z.string().describe("目标服务器的 name(见 list_servers"),
179
- },
180
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
181
- }, async ({ server: serverName }) => {
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
- return {
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
- return { content: [{ type: "text", text: `长连接会话已建立\n id: ${s.id}\n 服务器: ${s.server}\n 创建时间: ${new Date(s.createdAt).toISOString()}` }] };
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
- return {
197
- content: [{ type: "text", text: `打开长连接会话失败:${e.message}` }],
198
- isError: true,
199
- };
526
+ die(`打开长连接会话失败:${e.message}`);
200
527
  }
201
- });
202
- server.registerTool("close_session", {
203
- title: "关闭长连接会话",
204
- description: "关闭由 open_session 建立的长连接会话,释放底层 SSH 连接。",
205
- inputSchema: {
206
- session: z.string().describe("要关闭的会话 id(由 open_session 返回)"),
207
- },
208
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
209
- }, async ({ session }) => {
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
- return {
213
- content: [{ type: "text", text: `会话 ${session} 不存在或已断开。` }],
214
- isError: true,
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
- const text = all.length > 0
227
- ? all
228
- .map((s) => ` ${s.id} → ${s.server}(${new Date(s.createdAt).toISOString()})`)
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
- catch (e) {
238
- return { servers: null, error: e.message };
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
- server.registerTool("upload_file", {
242
- title: "上传文件到远程服务器",
243
- description: "通过 SFTP 把本机文件上传到指定服务器,适用于大文件(40GB+)。" +
244
- "这是后台任务:本工具立即返回一个传输 id,随后请用 transfer_status 轮询进度,不要阻塞等待。" +
245
- "支持断点续传——对同一对路径再次调用会自动从远程已有字节处继续;" +
246
- "若远程 remote_path 是已存在的目录,则自动在其下使用本地文件名。",
247
- inputSchema: {
248
- server: z.string().describe("目标服务器的 name(见 list_servers)"),
249
- local_path: z.string().describe("本机要上传的文件路径(绝对路径)"),
250
- remote_path: z.string().describe("远程目标路径(文件路径;若为已存在目录则自动追加文件名)"),
251
- overwrite: z
252
- .boolean()
253
- .optional()
254
- .describe("true 则忽略远程已有部分、从头覆盖;默认 false(自动断点续传)"),
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", local_path, remote_path, overwrite ?? false);
268
- const hint = t.state === "completed"
269
- ? "\n(目标已是完整文件,无需传输。)"
270
- : `\n传输已在后台开始,用 transfer_status({ id: "${t.id}" }) 查看进度。`;
271
- return { content: [{ type: "text", text: formatTransfer(t) + hint }] };
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
- return { content: [{ type: "text", text: `发起上传失败:${e.message}` }], isError: true };
607
+ die(`发起上传失败:${e.message}`);
275
608
  }
276
- });
277
- server.registerTool("download_file", {
278
- title: "从远程服务器下载文件",
279
- description: "通过 SFTP 把指定服务器上的文件下载到本机,适用于大文件(40GB+)。" +
280
- "这是后台任务:本工具立即返回一个传输 id,随后请用 transfer_status 轮询进度,不要阻塞等待。" +
281
- "支持断点续传——对同一对路径再次调用会自动从本地已有字节处继续;" +
282
- "若本地 local_path 是已存在的目录,则自动在其下使用远程文件名。",
283
- inputSchema: {
284
- server: z.string().describe("源服务器的 name(见 list_servers)"),
285
- remote_path: z.string().describe("远程要下载的文件路径"),
286
- local_path: z.string().describe("本机目标路径(文件路径;若为已存在目录则自动追加文件名)"),
287
- overwrite: z
288
- .boolean()
289
- .optional()
290
- .describe("true 则忽略本地已有部分、从头覆盖;默认 false(自动断点续传)"),
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", local_path, remote_path, overwrite ?? false);
304
- const hint = t.state === "completed"
305
- ? "\n(目标已是完整文件,无需传输。)"
306
- : `\n传输已在后台开始,用 transfer_status({ id: "${t.id}" }) 查看进度。`;
307
- return { content: [{ type: "text", text: formatTransfer(t) + hint }] };
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
- return { content: [{ type: "text", text: `发起下载失败:${e.message}` }], isError: true };
648
+ die(`发起下载失败:${e.message}`);
311
649
  }
312
- });
313
- server.registerTool("transfer_status", {
314
- title: "查看文件传输进度",
315
- description: "查询后台文件传输任务的进度(已传字节、百分比、速度、预计剩余时间、状态)。" +
316
- "传入 id 查看单个任务;不传则列出本次会话的全部任务。",
317
- inputSchema: {
318
- id: z.string().optional().describe("传输任务 id;不填则列出全部任务"),
319
- },
320
- annotations: { readOnlyHint: true, openWorldHint: false },
321
- }, async ({ id }) => {
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
- return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
326
- return { content: [{ type: "text", text: formatTransfer(t) }] };
668
+ die(`未找到传输任务:${id}`);
669
+ process.stdout.write(formatTransfer(t) + "\n");
327
670
  }
328
- const all = listTransfers();
329
- const text = all.length > 0 ? all.map(formatTransfer).join("\n\n") : "当前没有传输任务。";
330
- return { content: [{ type: "text", text }] };
331
- });
332
- server.registerTool("cancel_transfer", {
333
- title: "取消文件传输",
334
- description: "取消一个正在进行的后台传输任务。已传输的部分文件会保留,之后可用同样的路径再次发起以断点续传。",
335
- inputSchema: {
336
- id: z.string().describe("要取消的传输任务 id"),
337
- },
338
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
339
- }, async ({ id }) => {
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
- return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
343
- return { content: [{ type: "text", text: `已请求取消:\n${formatTransfer(t)}` }] };
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 transport = new StdioServerTransport();
347
- await server.connect(transport);
348
- // 日志走 stderr,避免污染 stdio 上的 MCP 协议数据。
349
- console.error(`ssh-mcp 已启动,配置文件:${configPath()}`);
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
- console.error("ssh-mcp 启动失败:", e);
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.2.0",
4
- "description": "通过 SSH/SFTP AI 在多台远程服务器上执行命令并传输大文件(支持断点续传)的 MCP 服务器",
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
- "mcp",
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",