@bingzi-233/ssh-mcp 1.2.0 → 1.4.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/dist/index.js CHANGED
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  import { configPath, loadSecurity, loadServers, validateCommand } from "./config.js";
6
6
  import { closeSession, listSessions, openSession, runCommand, } from "./ssh.js";
7
7
  import { cancelTransfer, getTransfer, listTransfers, startTransfer, } from "./transfer.js";
8
+ import { startForward, listForwards, closeForward, } from "./forward.js";
9
+ import { listDirectory, statPath, removePath, makeDir, formatLsLong, formatLsShort, } from "./sftp-ops.js";
8
10
  const DEFAULT_TIMEOUT_MS = 60_000;
9
11
  function humanBytes(n) {
10
12
  if (n < 1024)
@@ -52,18 +54,564 @@ function formatTransfer(t) {
52
54
  lines.push(` 错误: ${t.error}`);
53
55
  return lines.join("\n");
54
56
  }
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 () => {
57
+ // ---------------------------------------------------------------------------
58
+ // MCP server setup (--mcp mode)
59
+ // ---------------------------------------------------------------------------
60
+ function createMcpServer() {
61
+ const server = new McpServer({ name: "ssh-mcp", version: "1.4.0" });
62
+ server.registerTool("list_servers", {
63
+ title: "列出可用的 SSH 服务器",
64
+ description: "列出所有已配置的远程服务器及其 name、描述、地址和登录用户。" +
65
+ "在使用 run_command 之前,先用本工具确认有哪些服务器以及对应的 name。" +
66
+ "(不会返回任何密码或私钥等敏感信息。)",
67
+ inputSchema: {},
68
+ annotations: { readOnlyHint: true, openWorldHint: false },
69
+ }, async () => {
70
+ try {
71
+ const servers = loadServers();
72
+ const list = [...servers.values()].map((s) => ({
73
+ name: s.name,
74
+ description: s.description ?? "",
75
+ host: s.host,
76
+ port: s.port ?? 22,
77
+ username: s.username,
78
+ }));
79
+ const text = list.length > 0
80
+ ? JSON.stringify(list, null, 2)
81
+ : `没有已配置的服务器。请编辑配置文件:${configPath()}`;
82
+ return { content: [{ type: "text", text }] };
83
+ }
84
+ catch (e) {
85
+ return {
86
+ content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
87
+ isError: true,
88
+ };
89
+ }
90
+ });
91
+ server.registerTool("run_command", {
92
+ title: "在远程服务器上执行命令",
93
+ description: "通过 SSH 在指定的远程服务器上执行一条 shell 命令,返回 stdout、stderr 和退出码。" +
94
+ "用 server 参数指定目标服务器的 name(可先用 list_servers 查看)。" +
95
+ "可选传入 session(长连接会话 id)复用已有 TCP 连接,省去重复握手和认证;" +
96
+ "不传则每次新建连接、执行完即断开(短连接)。" +
97
+ "内置安全策略会拦截 rm -rf /、dd 写块设备、mkfs、fork 炸弹等高危命令," +
98
+ "可在 servers.json 的 security.blocked_patterns 中追加自定义正则。" +
99
+ "传入 force=true 可绕过安全检查(需明确知道自己在做什么)。" +
100
+ "注意:即便是长连接,每条命令仍在独立 channel 中执行,命令之间不保留工作目录或环境变量;" +
101
+ "需要保持上下文时请自行串接,例如 `cd /var/www && git pull`。",
102
+ inputSchema: {
103
+ server: z.string().describe("目标服务器的 name,必须与 list_servers 返回的某个 name 完全一致"),
104
+ command: z.string().describe("要在远程服务器上执行的 shell 命令"),
105
+ timeout_ms: z.number().int().positive().optional().describe(`命令超时时间(毫秒),默认 ${DEFAULT_TIMEOUT_MS}`),
106
+ session: z.string().optional().describe("长连接会话 id(由 open_session 返回)。不填则使用短连接。"),
107
+ force: z.boolean().optional().describe("传入 true 跳过安全策略检查。请谨慎使用。"),
108
+ },
109
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
110
+ }, async ({ server: serverName, command, timeout_ms, session, force }) => {
111
+ let servers;
112
+ try {
113
+ servers = loadServers();
114
+ }
115
+ catch (e) {
116
+ return { content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }], isError: true };
117
+ }
118
+ const cfg = servers.get(serverName);
119
+ if (!cfg) {
120
+ const names = [...servers.keys()].join(", ") || "(无)";
121
+ return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
122
+ }
123
+ if (!force) {
124
+ const security = loadSecurity();
125
+ const blocked = validateCommand(command, security.blocked_patterns ?? []);
126
+ if (blocked)
127
+ return { content: [{ type: "text", text: `${blocked}\n使用 force=true 可跳过安全检查。` }], isError: true };
128
+ }
129
+ try {
130
+ const r = await runCommand(cfg, command, timeout_ms ?? DEFAULT_TIMEOUT_MS, session);
131
+ const parts = [
132
+ `服务器: ${cfg.name} (${cfg.username}@${cfg.host}:${cfg.port ?? 22})`,
133
+ `退出码: ${r.code ?? "null"}${r.signal ? ` 信号: ${r.signal}` : ""}`,
134
+ ];
135
+ if (r.stdout)
136
+ parts.push(`--- stdout ---\n${r.stdout.trimEnd()}`);
137
+ if (r.stderr)
138
+ parts.push(`--- stderr ---\n${r.stderr.trimEnd()}`);
139
+ if (!r.stdout && !r.stderr)
140
+ parts.push("(无输出)");
141
+ return { content: [{ type: "text", text: parts.join("\n") }] };
142
+ }
143
+ catch (e) {
144
+ return { content: [{ type: "text", text: `执行失败:${e.message}` }], isError: true };
145
+ }
146
+ });
147
+ server.registerTool("open_session", {
148
+ title: "打开到远程服务器的长连接会话",
149
+ description: "与指定服务器建立一条持久的 SSH 连接并返回会话 id。该会话可被后续的 run_command " +
150
+ "通过 session 参数复用,省去重复的 TCP 握手和 SSH 认证开销。" +
151
+ "注意:即使使用长连接,每次 exec 仍在独立 channel 中执行,命令之间不保留工作目录或环境变量。",
152
+ inputSchema: { server: z.string().describe("目标服务器的 name(见 list_servers)") },
153
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
154
+ }, async ({ server: serverName }) => {
155
+ try {
156
+ const servers = loadServers();
157
+ const cfg = servers.get(serverName);
158
+ if (!cfg) {
159
+ const names = [...servers.keys()].join(", ") || "(无)";
160
+ return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
161
+ }
162
+ const s = await openSession(cfg, 20_000);
163
+ return { content: [{ type: "text", text: `长连接会话已建立\n id: ${s.id}\n 服务器: ${s.server}\n 创建时间: ${new Date(s.createdAt).toISOString()}` }] };
164
+ }
165
+ catch (e) {
166
+ return { content: [{ type: "text", text: `打开长连接会话失败:${e.message}` }], isError: true };
167
+ }
168
+ });
169
+ server.registerTool("close_session", {
170
+ title: "关闭长连接会话",
171
+ description: "关闭由 open_session 建立的长连接会话,释放底层 SSH 连接。",
172
+ inputSchema: { session: z.string().describe("要关闭的会话 id(由 open_session 返回)") },
173
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
174
+ }, async ({ session }) => {
175
+ const ok = closeSession(session);
176
+ if (!ok)
177
+ return { content: [{ type: "text", text: `会话 ${session} 不存在或已断开。` }], isError: true };
178
+ return { content: [{ type: "text", text: `会话 ${session} 已关闭。` }] };
179
+ });
180
+ server.registerTool("list_sessions", {
181
+ title: "列出当前所有长连接会话",
182
+ description: "列出当前已打开的所有长连接会话的 id、关联服务器和创建时间。",
183
+ inputSchema: {},
184
+ annotations: { readOnlyHint: true, openWorldHint: false },
185
+ }, async () => {
186
+ const all = listSessions();
187
+ const text = all.length > 0
188
+ ? all.map((s) => ` ${s.id} → ${s.server}(${new Date(s.createdAt).toISOString()})`).join("\n")
189
+ : "当前没有长连接会话。";
190
+ return { content: [{ type: "text", text }] };
191
+ });
192
+ function loadServersOrError() {
193
+ try {
194
+ return { servers: loadServers(), error: null };
195
+ }
196
+ catch (e) {
197
+ return { servers: null, error: e.message };
198
+ }
199
+ }
200
+ server.registerTool("upload_file", {
201
+ title: "上传文件到远程服务器",
202
+ description: "通过 SFTP 把本机文件上传到指定服务器,适用于大文件(40GB+)。" +
203
+ "这是后台任务:本工具立即返回一个传输 id,随后请用 transfer_status 轮询进度,不要阻塞等待。" +
204
+ "支持断点续传——对同一对路径再次调用会自动从远程已有字节处继续;" +
205
+ "若远程 remote_path 是已存在的目录,则自动在其下使用本地文件名。",
206
+ inputSchema: {
207
+ server: z.string().describe("目标服务器的 name(见 list_servers)"),
208
+ local_path: z.string().describe("本机要上传的文件路径(绝对路径)"),
209
+ remote_path: z.string().describe("远程目标路径(文件路径;若为已存在目录则自动追加文件名)"),
210
+ overwrite: z.boolean().optional().describe("true 则忽略远程已有部分、从头覆盖;默认 false(自动断点续传)"),
211
+ },
212
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
213
+ }, async ({ server: serverName, local_path, remote_path, overwrite }) => {
214
+ const { servers, error } = loadServersOrError();
215
+ if (!servers)
216
+ return { content: [{ type: "text", text: `读取服务器配置失败:${error}` }], isError: true };
217
+ const cfg = servers.get(serverName);
218
+ if (!cfg) {
219
+ const names = [...servers.keys()].join(", ") || "(无)";
220
+ return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
221
+ }
222
+ try {
223
+ const t = await startTransfer(cfg, "upload", local_path, remote_path, overwrite ?? false);
224
+ const hint = t.state === "completed" ? "\n(目标已是完整文件,无需传输。)" : `\n传输已在后台开始,用 transfer_status({ id: "${t.id}" }) 查看进度。`;
225
+ return { content: [{ type: "text", text: formatTransfer(t) + hint }] };
226
+ }
227
+ catch (e) {
228
+ return { content: [{ type: "text", text: `发起上传失败:${e.message}` }], isError: true };
229
+ }
230
+ });
231
+ server.registerTool("download_file", {
232
+ title: "从远程服务器下载文件",
233
+ description: "通过 SFTP 把指定服务器上的文件下载到本机,适用于大文件(40GB+)。" +
234
+ "这是后台任务:本工具立即返回一个传输 id,随后请用 transfer_status 轮询进度,不要阻塞等待。" +
235
+ "支持断点续传——对同一对路径再次调用会自动从本地已有字节处继续;" +
236
+ "若本地 local_path 是已存在的目录,则自动在其下使用远程文件名。",
237
+ inputSchema: {
238
+ server: z.string().describe("源服务器的 name(见 list_servers)"),
239
+ remote_path: z.string().describe("远程要下载的文件路径"),
240
+ local_path: z.string().describe("本机目标路径(文件路径;若为已存在目录则自动追加文件名)"),
241
+ overwrite: z.boolean().optional().describe("true 则忽略本地已有部分、从头覆盖;默认 false(自动断点续传)"),
242
+ },
243
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
244
+ }, async ({ server: serverName, remote_path, local_path, overwrite }) => {
245
+ const { servers, error } = loadServersOrError();
246
+ if (!servers)
247
+ return { content: [{ type: "text", text: `读取服务器配置失败:${error}` }], isError: true };
248
+ const cfg = servers.get(serverName);
249
+ if (!cfg) {
250
+ const names = [...servers.keys()].join(", ") || "(无)";
251
+ return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
252
+ }
253
+ try {
254
+ const t = await startTransfer(cfg, "download", local_path, remote_path, overwrite ?? false);
255
+ const hint = t.state === "completed" ? "\n(目标已是完整文件,无需传输。)" : `\n传输已在后台开始,用 transfer_status({ id: "${t.id}" }) 查看进度。`;
256
+ return { content: [{ type: "text", text: formatTransfer(t) + hint }] };
257
+ }
258
+ catch (e) {
259
+ return { content: [{ type: "text", text: `发起下载失败:${e.message}` }], isError: true };
260
+ }
261
+ });
262
+ server.registerTool("transfer_status", {
263
+ title: "查看文件传输进度",
264
+ description: "查询后台文件传输任务的进度(已传字节、百分比、速度、预计剩余时间、状态)。传入 id 查看单个任务;不传则列出本次会话的全部任务。",
265
+ inputSchema: { id: z.string().optional().describe("传输任务 id;不填则列出全部任务") },
266
+ annotations: { readOnlyHint: true, openWorldHint: false },
267
+ }, async ({ id }) => {
268
+ if (id) {
269
+ const t = getTransfer(id);
270
+ if (!t)
271
+ return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
272
+ return { content: [{ type: "text", text: formatTransfer(t) }] };
273
+ }
274
+ const all = listTransfers();
275
+ const text = all.length > 0 ? all.map(formatTransfer).join("\n\n") : "当前没有传输任务。";
276
+ return { content: [{ type: "text", text }] };
277
+ });
278
+ server.registerTool("cancel_transfer", {
279
+ title: "取消文件传输",
280
+ description: "取消一个正在进行的后台传输任务。已传输的部分文件会保留,之后可用同样的路径再次发起以断点续传。",
281
+ inputSchema: { id: z.string().describe("要取消的传输任务 id") },
282
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
283
+ }, async ({ id }) => {
284
+ const t = cancelTransfer(id);
285
+ if (!t)
286
+ return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
287
+ return { content: [{ type: "text", text: `已请求取消:\n${formatTransfer(t)}` }] };
288
+ });
289
+ // ---- 端口转发 ----
290
+ server.registerTool("start_forward", {
291
+ title: "启动 SSH 端口转发",
292
+ description: "启动一个 SSH 端口转发隧道。本地转发(-L):将本机端口流量经由 SSH 服务器转发到内网目标。" +
293
+ "远程转发(-R):将 SSH 服务器端口流量回传到本机指定地址。" +
294
+ "返回转发 id,用 list_forwards 查看状态,close_forward 停止。",
295
+ inputSchema: {
296
+ server: z.string().describe("目标 SSH 服务器的 name"),
297
+ type: z.enum(["local", "remote"]).describe("转发类型:local 本地转发,remote 远程转发"),
298
+ local_host: z.string().default("127.0.0.1").describe("本机绑定地址,默认 127.0.0.1"),
299
+ local_port: z.number().int().positive().describe("本机端口号"),
300
+ remote_host: z.string().describe("远端目标地址(local 模式为内网主机,remote 模式为回传目标)"),
301
+ remote_port: z.number().int().positive().describe("远端端口号"),
302
+ },
303
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
304
+ }, async ({ server: serverName, type, local_host, local_port, remote_host, remote_port }) => {
305
+ try {
306
+ const servers = loadServers();
307
+ const cfg = servers.get(serverName);
308
+ if (!cfg)
309
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
310
+ const f = await startForward(cfg, type, local_host, local_port, remote_host, remote_port);
311
+ const dir = type === "local"
312
+ ? `${f.localHost}:${f.localPort} → ${f.remoteHost}:${f.remotePort}`
313
+ : `${f.remoteHost}:${f.remotePort} → ${f.localHost}:${f.localPort}`;
314
+ return { content: [{ type: "text", text: `转发已启动 [${f.id}] ${dir}\n类型: ${type}\n状态: ${f.state}` }] };
315
+ }
316
+ catch (e) {
317
+ return { content: [{ type: "text", text: `启动转发失败:${e.message}` }], isError: true };
318
+ }
319
+ });
320
+ server.registerTool("list_forwards", {
321
+ title: "列出所有端口转发",
322
+ description: "列出当前活跃的端口转发隧道。",
323
+ inputSchema: {},
324
+ annotations: { readOnlyHint: true, openWorldHint: false },
325
+ }, async () => {
326
+ const all = listForwards();
327
+ if (all.length === 0)
328
+ return { content: [{ type: "text", text: "当前没有端口转发。" }] };
329
+ const lines = all.map((f) => f.type === "local"
330
+ ? `[${f.id}] ${f.server} ${f.localHost}:${f.localPort} → ${f.remoteHost}:${f.remotePort} (${f.state})`
331
+ : `[${f.id}] ${f.server} ${f.remoteHost}:${f.remotePort} → ${f.localHost}:${f.localPort} (${f.state})`);
332
+ return { content: [{ type: "text", text: lines.join("\n") }] };
333
+ });
334
+ server.registerTool("close_forward", {
335
+ title: "停止端口转发",
336
+ description: "停止一个端口转发隧道。",
337
+ inputSchema: { id: z.string().describe("转发 id") },
338
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
339
+ }, async ({ id }) => {
340
+ const ok = closeForward(id);
341
+ if (!ok)
342
+ return { content: [{ type: "text", text: `转发 ${id} 不存在或已停止。` }], isError: true };
343
+ return { content: [{ type: "text", text: `转发 ${id} 已停止。` }] };
344
+ });
345
+ // ---- 批量执行 ----
346
+ server.registerTool("batch_run", {
347
+ title: "在多台服务器上批量执行命令",
348
+ description: "同时在多台服务器上执行同一条命令,并发执行、汇总结果。" +
349
+ "传入 servers 数组指定目标服务器 name 列表。",
350
+ inputSchema: {
351
+ servers: z.array(z.string()).describe("目标服务器 name 列表"),
352
+ command: z.string().describe("要执行的命令"),
353
+ timeout_ms: z.number().int().positive().optional().describe(`命令超时(毫秒),默认 ${DEFAULT_TIMEOUT_MS}`),
354
+ force: z.boolean().optional().describe("跳过安全策略检查"),
355
+ },
356
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
357
+ }, async ({ servers: serverNames, command, timeout_ms, force }) => {
358
+ let servers;
359
+ try {
360
+ servers = loadServers();
361
+ }
362
+ catch (e) {
363
+ return { content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }], isError: true };
364
+ }
365
+ const missing = serverNames.filter((n) => !servers.has(n));
366
+ if (missing.length)
367
+ return { content: [{ type: "text", text: `未找到服务器:${missing.join(", ")}` }], isError: true };
368
+ if (!force) {
369
+ const security = loadSecurity();
370
+ const blocked = validateCommand(command, security.blocked_patterns ?? []);
371
+ if (blocked)
372
+ return { content: [{ type: "text", text: `${blocked}\n使用 force=true 可跳过安全检查。` }], isError: true };
373
+ }
374
+ const timeout = timeout_ms ?? DEFAULT_TIMEOUT_MS;
375
+ const results = await Promise.allSettled(serverNames.map((name) => runCommand(servers.get(name), command, timeout).then((r) => ({ server: name, result: r }))));
376
+ const lines = [];
377
+ for (const r of results) {
378
+ if (r.status === "rejected") {
379
+ lines.push(`--- ${r.reason?.server ?? "?"} FAILED ---\n${r.reason.message}`);
380
+ }
381
+ else {
382
+ const { server: sName, result } = r.value;
383
+ lines.push(`--- ${sName} (exit ${result.code}) ---`);
384
+ if (result.stdout)
385
+ lines.push(result.stdout.trimEnd());
386
+ if (result.stderr)
387
+ lines.push(`[stderr]\n${result.stderr.trimEnd()}`);
388
+ }
389
+ }
390
+ return { content: [{ type: "text", text: lines.join("\n") || "(无输出)" }] };
391
+ });
392
+ // ---- SFTP 文件操作 ----
393
+ server.registerTool("list_directory", {
394
+ title: "列出远程目录内容",
395
+ description: "通过 SFTP 列出远程服务器上指定目录的文件和子目录。" +
396
+ "返回文件名、类型、大小、权限、修改时间等信息。",
397
+ inputSchema: {
398
+ server: z.string().describe("目标服务器 name"),
399
+ path: z.string().describe("远程目录路径"),
400
+ },
401
+ annotations: { readOnlyHint: true, openWorldHint: true },
402
+ }, async ({ server: serverName, path }) => {
403
+ try {
404
+ const servers = loadServers();
405
+ const cfg = servers.get(serverName);
406
+ if (!cfg)
407
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
408
+ const entries = await listDirectory(cfg, path);
409
+ const lines = entries.map(formatLsLong);
410
+ return { content: [{ type: "text", text: `${path}:\n${lines.join("\n")}` }] };
411
+ }
412
+ catch (e) {
413
+ return { content: [{ type: "text", text: `列出目录失败:${e.message}` }], isError: true };
414
+ }
415
+ });
416
+ server.registerTool("stat_file", {
417
+ title: "查看远程文件信息",
418
+ description: "通过 SFTP stat 查看远程文件的类型、大小、权限、修改时间。",
419
+ inputSchema: {
420
+ server: z.string().describe("目标服务器 name"),
421
+ path: z.string().describe("远程文件路径"),
422
+ },
423
+ annotations: { readOnlyHint: true, openWorldHint: true },
424
+ }, async ({ server: serverName, path }) => {
425
+ try {
426
+ const servers = loadServers();
427
+ const cfg = servers.get(serverName);
428
+ if (!cfg)
429
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
430
+ const s = await statPath(cfg, path);
431
+ return { content: [{ type: "text", text: JSON.stringify(s, null, 2) }] };
432
+ }
433
+ catch (e) {
434
+ return { content: [{ type: "text", text: `stat 失败:${e.message}` }], isError: true };
435
+ }
436
+ });
437
+ server.registerTool("remove_file", {
438
+ title: "删除远程文件或目录",
439
+ description: "通过 SFTP 删除远程服务器上的文件或目录。" +
440
+ "删除目录时需传入 recursive=true,将递归删除目录下所有内容。",
441
+ inputSchema: {
442
+ server: z.string().describe("目标服务器 name"),
443
+ path: z.string().describe("远程文件或目录路径"),
444
+ recursive: z.boolean().optional().describe("递归删除目录;默认 false"),
445
+ },
446
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
447
+ }, async ({ server: serverName, path, recursive }) => {
448
+ try {
449
+ const servers = loadServers();
450
+ const cfg = servers.get(serverName);
451
+ if (!cfg)
452
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
453
+ await removePath(cfg, path, recursive ?? false);
454
+ return { content: [{ type: "text", text: `已删除:${path}` }] };
455
+ }
456
+ catch (e) {
457
+ return { content: [{ type: "text", text: `删除失败:${e.message}` }], isError: true };
458
+ }
459
+ });
460
+ server.registerTool("make_directory", {
461
+ title: "创建远程目录",
462
+ description: "通过 SFTP 在远程服务器上创建目录。" +
463
+ "传入 parents=true 可自动创建父目录(类似 mkdir -p)。",
464
+ inputSchema: {
465
+ server: z.string().describe("目标服务器 name"),
466
+ path: z.string().describe("远程目录路径"),
467
+ parents: z.boolean().optional().describe("自动创建父目录;默认 false"),
468
+ },
469
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
470
+ }, async ({ server: serverName, path, parents }) => {
471
+ try {
472
+ const servers = loadServers();
473
+ const cfg = servers.get(serverName);
474
+ if (!cfg)
475
+ return { content: [{ type: "text", text: `未找到服务器 "${serverName}"` }], isError: true };
476
+ await makeDir(cfg, path, parents ?? false);
477
+ return { content: [{ type: "text", text: `已创建目录:${path}` }] };
478
+ }
479
+ catch (e) {
480
+ return { content: [{ type: "text", text: `创建目录失败:${e.message}` }], isError: true };
481
+ }
482
+ });
483
+ return server;
484
+ }
485
+ function parseArgs(raw) {
486
+ const options = new Map();
487
+ let subcommand = "";
488
+ let i = 0;
489
+ while (i < raw.length) {
490
+ const a = raw[i];
491
+ if (a === "--") {
492
+ i++;
493
+ break;
494
+ }
495
+ if (a.startsWith("--")) {
496
+ const eq = a.indexOf("=");
497
+ if (eq >= 0) {
498
+ options.set(a.slice(2, eq), a.slice(eq + 1));
499
+ }
500
+ else {
501
+ const key = a.slice(2);
502
+ if (i + 1 < raw.length && !raw[i + 1].startsWith("-")) {
503
+ options.set(key, raw[++i]);
504
+ }
505
+ else {
506
+ options.set(key, true);
507
+ }
508
+ }
509
+ }
510
+ else if (a.startsWith("-") && a.length === 2 && a[1] !== "-") {
511
+ // short flag: -c → value
512
+ const key = a.slice(1);
513
+ if (i + 1 < raw.length && !raw[i + 1].startsWith("-")) {
514
+ options.set(key, raw[++i]);
515
+ }
516
+ else {
517
+ options.set(key, true);
518
+ }
519
+ }
520
+ else if (!subcommand) {
521
+ subcommand = a;
522
+ }
523
+ else {
524
+ break;
525
+ }
526
+ i++;
527
+ }
528
+ return { subcommand, options, positional: raw.slice(i) };
529
+ }
530
+ function optStr(opts, key) {
531
+ const v = opts.get(key);
532
+ return typeof v === "string" ? v : undefined;
533
+ }
534
+ function optNum(opts, key) {
535
+ const v = opts.get(key);
536
+ if (typeof v === "string") {
537
+ const n = Number(v);
538
+ return isNaN(n) ? undefined : n;
539
+ }
540
+ return undefined;
541
+ }
542
+ function optBool(opts, key) {
543
+ const v = opts.get(key);
544
+ return v === true || v === "true" || v === "1";
545
+ }
546
+ function die(msg) {
547
+ process.stderr.write(`ssh-mcp: ${msg}\n`);
548
+ process.exit(1);
549
+ }
550
+ function showHelp() {
551
+ process.stdout.write(`ssh-mcp — SSH/SFTP 远程服务器命令行工具 v1.4.0
552
+
553
+ 用法: ssh-mcp <子命令> [选项]
554
+
555
+ 子命令:
556
+ list-servers 列出所有已配置的服务器
557
+ run-command 在远程服务器上执行命令
558
+ batch 在多台服务器上批量执行同一命令
559
+ open-session 打开到远程服务器的长连接会话
560
+ close-session 关闭长连接会话
561
+ list-sessions 列出当前所有长连接会话
562
+ upload 上传文件到远程服务器(支持断点续传)
563
+ download 从远程服务器下载文件(支持断点续传)
564
+ transfer-status 查看文件传输进度
565
+ cancel-transfer 取消文件传输
566
+ forward 启动 SSH 端口转发(本地/远程)
567
+ list-forwards 列出当前所有端口转发
568
+ close-forward 停止端口转发
569
+ ls 列出远程目录内容
570
+ stat 查看远程文件信息
571
+ rm 删除远程文件或目录
572
+ mkdir 创建远程目录
573
+
574
+ 全局选项:
575
+ --mcp 以 MCP stdio 服务模式运行(供 AI 客户端调用)
576
+ --help, -h 显示此帮助信息
577
+
578
+ 配置:
579
+ 服务器配置文件路径: ${configPath()}
580
+ 也可通过环境变量 SSH_MCP_CONFIG 指定其他路径。
581
+
582
+ 示例 servers.json:
583
+ {
584
+ "servers": [
585
+ {
586
+ "name": "prod-web",
587
+ "description": "生产环境",
588
+ "host": "192.168.1.10",
589
+ "port": 22,
590
+ "username": "deploy",
591
+ "privateKeyPath": "~/.ssh/id_rsa"
592
+ }
593
+ ]
594
+ }
595
+
596
+ 各子命令详细用法请运行: ssh-mcp <子命令> --help
597
+ `);
598
+ }
599
+ // ---------------------------------------------------------------------------
600
+ // CLI handlers
601
+ // ---------------------------------------------------------------------------
602
+ async function cmdListServers(opts) {
603
+ if (optBool(opts, "help")) {
604
+ process.stdout.write(`用法: ssh-mcp list-servers [--json]
605
+
606
+ 选项:
607
+ --json 以 JSON 格式输出
608
+
609
+ 示例:
610
+ ssh-mcp list-servers
611
+ ssh-mcp list-servers --json
612
+ `);
613
+ return;
614
+ }
67
615
  try {
68
616
  const servers = loadServers();
69
617
  const list = [...servers.values()].map((s) => ({
@@ -73,282 +621,640 @@ server.registerTool("list_servers", {
73
621
  port: s.port ?? 22,
74
622
  username: s.username,
75
623
  }));
76
- const text = list.length > 0
77
- ? JSON.stringify(list, null, 2)
78
- : `没有已配置的服务器。请编辑配置文件:${configPath()}`;
79
- return { content: [{ type: "text", text }] };
624
+ if (optBool(opts, "json")) {
625
+ process.stdout.write(JSON.stringify(list, null, 2) + "\n");
626
+ }
627
+ else if (list.length === 0) {
628
+ process.stdout.write(`没有已配置的服务器。请编辑配置文件:${configPath()}\n`);
629
+ }
630
+ else {
631
+ for (const s of list) {
632
+ process.stdout.write(`${s.name.padEnd(16)} ${s.username}@${s.host}:${s.port} ${s.description}\n`);
633
+ }
634
+ }
80
635
  }
81
636
  catch (e) {
82
- return {
83
- content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
84
- isError: true,
85
- };
637
+ die(`读取服务器配置失败:${e.message}`);
86
638
  }
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 }) => {
639
+ }
640
+ async function cmdRunCommand(opts, positional) {
641
+ if (optBool(opts, "help")) {
642
+ process.stdout.write(`用法: ssh-mcp run-command --server <name> [选项] <命令...>
643
+
644
+ 选项:
645
+ --server, -s <name> 目标服务器 name(必需)
646
+ --timeout <ms> 命令超时毫秒数(默认 ${DEFAULT_TIMEOUT_MS})
647
+ --session <id> 长连接会话 id(复用已有连接)
648
+ --force 跳过安全策略检查
649
+ --command, -c <cmd> 要执行的命令(也可直接放在选项之后)
650
+
651
+ 示例:
652
+ ssh-mcp run-command --server prod-web --command "uptime"
653
+ ssh-mcp run-command -s prod-web "df -h /"
654
+ ssh-mcp run-command -s prod-web --session s1 "tail -50 /var/log/nginx/access.log"
655
+ `);
656
+ return;
657
+ }
658
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
659
+ if (!serverName)
660
+ die("缺少 --server。用法: ssh-mcp run-command --server <name> <命令>");
661
+ let command = optStr(opts, "command") ?? optStr(opts, "c");
662
+ if (!command) {
663
+ command = positional.join(" ");
664
+ }
665
+ if (!command || command.trim() === "")
666
+ die("缺少命令。用法: ssh-mcp run-command --server <name> <命令>");
667
+ const timeout = optNum(opts, "timeout") ?? DEFAULT_TIMEOUT_MS;
668
+ const session = optStr(opts, "session");
669
+ const force = optBool(opts, "force");
121
670
  let servers;
122
671
  try {
123
672
  servers = loadServers();
124
673
  }
125
674
  catch (e) {
126
- return {
127
- content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
128
- isError: true,
129
- };
675
+ die(`读取服务器配置失败:${e.message}`);
130
676
  }
131
677
  const cfg = servers.get(serverName);
132
678
  if (!cfg) {
133
679
  const names = [...servers.keys()].join(", ") || "(无)";
134
- return {
135
- content: [
136
- { type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` },
137
- ],
138
- isError: true,
139
- };
680
+ die(`未找到名为 "${serverName}" 的服务器。可用:${names}`);
140
681
  }
141
682
  if (!force) {
142
683
  const security = loadSecurity();
143
684
  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
- }
685
+ if (blocked)
686
+ die(blocked + "\n使用 --force 可跳过安全检查。");
150
687
  }
151
688
  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
- ];
689
+ const r = await runCommand(cfg, command, timeout, session);
157
690
  if (r.stdout)
158
- parts.push(`--- stdout ---\n${r.stdout.trimEnd()}`);
691
+ process.stdout.write(r.stdout);
159
692
  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") }] };
693
+ process.stderr.write(r.stderr);
694
+ process.exit(r.code ?? 1);
164
695
  }
165
696
  catch (e) {
166
- return {
167
- content: [{ type: "text", text: `执行失败:${e.message}` }],
168
- isError: true,
169
- };
697
+ die(`执行失败:${e.message}`);
170
698
  }
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 }) => {
699
+ }
700
+ async function cmdOpenSession(opts) {
701
+ if (optBool(opts, "help")) {
702
+ process.stdout.write(`用法: ssh-mcp open-session --server <name> [--timeout <ms>]
703
+
704
+ 选项:
705
+ --server, -s <name> 目标服务器 name(必需)
706
+ --timeout <ms> 连接超时毫秒数(默认 20000
707
+
708
+ 示例:
709
+ ssh-mcp open-session -s prod-web
710
+ SESSION=s1
711
+ ssh-mcp run-command -s prod-web --session $SESSION "hostname"
712
+ ssh-mcp close-session --session $SESSION
713
+ `);
714
+ return;
715
+ }
716
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
717
+ if (!serverName)
718
+ die("缺少 --server。用法: ssh-mcp open-session --server <name>");
182
719
  try {
183
720
  const servers = loadServers();
184
721
  const cfg = servers.get(serverName);
185
722
  if (!cfg) {
186
723
  const names = [...servers.keys()].join(", ") || "(无)";
187
- return {
188
- content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }],
189
- isError: true,
190
- };
724
+ die(`未找到名为 "${serverName}" 的服务器。可用:${names}`);
191
725
  }
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()}` }] };
726
+ const s = await openSession(cfg, optNum(opts, "timeout") ?? 20_000);
727
+ process.stdout.write(`${s.id}\n`);
194
728
  }
195
729
  catch (e) {
196
- return {
197
- content: [{ type: "text", text: `打开长连接会话失败:${e.message}` }],
198
- isError: true,
199
- };
730
+ die(`打开长连接会话失败:${e.message}`);
200
731
  }
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 }) => {
732
+ }
733
+ async function cmdCloseSession(opts) {
734
+ if (optBool(opts, "help")) {
735
+ process.stdout.write(`用法: ssh-mcp close-session --session <id>
736
+
737
+ 选项:
738
+ --session, -s <id> 要关闭的会话 id(必需)
739
+
740
+ 示例:
741
+ ssh-mcp close-session -s s1
742
+ `);
743
+ return;
744
+ }
745
+ const session = optStr(opts, "session") ?? optStr(opts, "s");
746
+ if (!session)
747
+ die("缺少 --session。用法: ssh-mcp close-session --session <id>");
210
748
  const ok = closeSession(session);
211
- if (!ok) {
212
- return {
213
- content: [{ type: "text", text: `会话 ${session} 不存在或已断开。` }],
214
- isError: true,
215
- };
749
+ if (!ok)
750
+ die(`会话 ${session} 不存在或已断开。`);
751
+ process.stdout.write(`会话 ${session} 已关闭。\n`);
752
+ }
753
+ async function cmdListSessions(opts) {
754
+ if (optBool(opts, "help")) {
755
+ process.stdout.write(`用法: ssh-mcp list-sessions
756
+
757
+ 列出当前所有活跃的长连接会话(id、对应服务器、创建时间)。
758
+
759
+ 示例:
760
+ ssh-mcp list-sessions
761
+ `);
762
+ return;
216
763
  }
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
764
  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 };
765
+ if (all.length === 0) {
766
+ process.stdout.write("当前没有长连接会话。\n");
767
+ return;
236
768
  }
237
- catch (e) {
238
- return { servers: null, error: e.message };
769
+ for (const s of all) {
770
+ process.stdout.write(`${s.id.padEnd(8)} ${s.server.padEnd(20)} ${new Date(s.createdAt).toISOString()}\n`);
239
771
  }
240
772
  }
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 };
773
+ async function cmdUpload(opts) {
774
+ if (optBool(opts, "help")) {
775
+ process.stdout.write(`用法: ssh-mcp upload --server <name> --local <path> --remote <path> [--overwrite]
776
+
777
+ 选项:
778
+ --server, -s <name> 目标服务器 name(必需)
779
+ --local, -l <path> 本地文件路径(必需)
780
+ --remote, -r <path> 远程目标路径(必需)
781
+ --overwrite 从头覆盖远程文件(默认断点续传)
782
+
783
+ 示例:
784
+ ssh-mcp upload -s prod-web -l ./dist.tar.gz -r /tmp/dist.tar.gz
785
+ ssh-mcp upload -s prod-web -l ./app.log -r /var/log/ --overwrite
786
+ `);
787
+ return;
265
788
  }
789
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
790
+ const localPath = optStr(opts, "local") ?? optStr(opts, "l");
791
+ const remotePath = optStr(opts, "remote") ?? optStr(opts, "r");
792
+ if (!serverName)
793
+ die("缺少 --server");
794
+ if (!localPath)
795
+ die("缺少 --local");
796
+ if (!remotePath)
797
+ die("缺少 --remote");
798
+ const { servers, error } = loadServersOrDie();
799
+ const cfg = servers.get(serverName);
800
+ if (!cfg)
801
+ die(`未找到名为 "${serverName}" 的服务器。可用:${[...servers.keys()].join(", ") || "(无)"}`);
266
802
  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 }] };
803
+ const t = await startTransfer(cfg, "upload", localPath, remotePath, optBool(opts, "overwrite"));
804
+ process.stdout.write(formatTransfer(t) + "\n");
805
+ if (t.state === "completed")
806
+ process.stdout.write("目标已是完整文件,无需传输。\n");
807
+ else
808
+ process.stdout.write(`传输已在后台开始,用 ssh-mcp transfer-status --id ${t.id} 查看进度。\n`);
272
809
  }
273
810
  catch (e) {
274
- return { content: [{ type: "text", text: `发起上传失败:${e.message}` }], isError: true };
811
+ die(`发起上传失败:${e.message}`);
275
812
  }
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 };
813
+ }
814
+ async function cmdDownload(opts) {
815
+ if (optBool(opts, "help")) {
816
+ process.stdout.write(`用法: ssh-mcp download --server <name> --remote <path> --local <path> [--overwrite]
817
+
818
+ 选项:
819
+ --server, -s <name> 源服务器 name(必需)
820
+ --remote, -r <path> 远程文件路径(必需)
821
+ --local, -l <path> 本地目标路径(必需)
822
+ --overwrite 从头覆盖本地文件(默认断点续传)
823
+
824
+ 示例:
825
+ ssh-mcp download -s prod-web -r /var/log/app.log -l ./logs/app.log
826
+ ssh-mcp download -s prod-web -r /tmp/data.bin -l ./downloads/ --overwrite
827
+ `);
828
+ return;
301
829
  }
830
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
831
+ const remotePath = optStr(opts, "remote") ?? optStr(opts, "r");
832
+ const localPath = optStr(opts, "local") ?? optStr(opts, "l");
833
+ if (!serverName)
834
+ die("缺少 --server");
835
+ if (!remotePath)
836
+ die("缺少 --remote");
837
+ if (!localPath)
838
+ die("缺少 --local");
839
+ const { servers, error } = loadServersOrDie();
840
+ const cfg = servers.get(serverName);
841
+ if (!cfg)
842
+ die(`未找到名为 "${serverName}" 的服务器。可用:${[...servers.keys()].join(", ") || "(无)"}`);
302
843
  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 }] };
844
+ const t = await startTransfer(cfg, "download", localPath, remotePath, optBool(opts, "overwrite"));
845
+ process.stdout.write(formatTransfer(t) + "\n");
846
+ if (t.state === "completed")
847
+ process.stdout.write("目标已是完整文件,无需传输。\n");
848
+ else
849
+ process.stdout.write(`传输已在后台开始,用 ssh-mcp transfer-status --id ${t.id} 查看进度。\n`);
308
850
  }
309
851
  catch (e) {
310
- return { content: [{ type: "text", text: `发起下载失败:${e.message}` }], isError: true };
852
+ die(`发起下载失败:${e.message}`);
311
853
  }
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 }) => {
854
+ }
855
+ async function cmdTransferStatus(opts) {
856
+ if (optBool(opts, "help")) {
857
+ process.stdout.write(`用法: ssh-mcp transfer-status [--id <id>]
858
+
859
+ 选项:
860
+ --id, -i <id> 传输任务 id;不填则列出全部任务
861
+
862
+ 示例:
863
+ ssh-mcp transfer-status
864
+ ssh-mcp transfer-status -i t1
865
+ `);
866
+ return;
867
+ }
868
+ const id = optStr(opts, "id") ?? optStr(opts, "i");
322
869
  if (id) {
323
870
  const t = getTransfer(id);
324
871
  if (!t)
325
- return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
326
- return { content: [{ type: "text", text: formatTransfer(t) }] };
872
+ die(`未找到传输任务:${id}`);
873
+ process.stdout.write(formatTransfer(t) + "\n");
327
874
  }
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 }) => {
875
+ else {
876
+ const all = listTransfers();
877
+ if (all.length === 0)
878
+ process.stdout.write("当前没有传输任务。\n");
879
+ else
880
+ process.stdout.write(all.map(formatTransfer).join("\n\n") + "\n");
881
+ }
882
+ }
883
+ async function cmdCancelTransfer(opts) {
884
+ if (optBool(opts, "help")) {
885
+ process.stdout.write(`用法: ssh-mcp cancel-transfer --id <id>
886
+
887
+ 选项:
888
+ --id, -i <id> 要取消的传输任务 id(必需)
889
+
890
+ 示例:
891
+ ssh-mcp cancel-transfer -i t1
892
+ `);
893
+ return;
894
+ }
895
+ const id = optStr(opts, "id") ?? optStr(opts, "i");
896
+ if (!id)
897
+ die("缺少 --id。用法: ssh-mcp cancel-transfer --id <id>");
340
898
  const t = cancelTransfer(id);
341
899
  if (!t)
342
- return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
343
- return { content: [{ type: "text", text: `已请求取消:\n${formatTransfer(t)}` }] };
344
- });
900
+ die(`未找到传输任务:${id}`);
901
+ process.stdout.write(`已请求取消:\n${formatTransfer(t)}\n`);
902
+ }
903
+ // ---- 端口转发 CLI ----
904
+ async function cmdForward(opts) {
905
+ if (optBool(opts, "help")) {
906
+ process.stdout.write(`用法: ssh-mcp forward --server <name> -L <本地端口>:<远端主机>:<远端端口>
907
+ ssh-mcp forward --server <name> -R <远端端口>:<本地主机>:<本地端口>
908
+
909
+ 选项:
910
+ --server, -s <name> 目标 SSH 服务器 name(必需)
911
+ -L <lport>:<rhost>:<rport> 本地端口转发
912
+ -R <rport>:<lhost>:<lport> 远程端口转发
913
+
914
+ 示例:
915
+ # 本地转发:本机 8080 → 经 prod-web → 内网 192.168.1.5:80
916
+ ssh-mcp forward -s prod-web -L 8080:192.168.1.5:80
917
+
918
+ # 远程转发:prod-web 的 9000 → 回传到本机 localhost:3000
919
+ ssh-mcp forward -s prod-web -R 9000:127.0.0.1:3000
920
+ `);
921
+ return;
922
+ }
923
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
924
+ if (!serverName)
925
+ die("缺少 --server");
926
+ const lFwd = optStr(opts, "L");
927
+ const rFwd = optStr(opts, "R");
928
+ if (!lFwd && !rFwd)
929
+ die("缺少 -L 或 -R。用法: ssh-mcp forward -s <name> -L lport:rhost:rport");
930
+ if (lFwd && rFwd)
931
+ die("不能同时指定 -L 和 -R");
932
+ const raw = (lFwd ?? rFwd);
933
+ const parts = raw.split(":");
934
+ if (parts.length !== 3)
935
+ die("转发格式错误:应为 -L lport:rhost:rport 或 -R rport:lhost:lport");
936
+ let type;
937
+ let localHost, localPort, remoteHost, remotePort;
938
+ if (lFwd) {
939
+ type = "local";
940
+ localPort = parseInt(parts[0], 10);
941
+ remoteHost = parts[1];
942
+ remotePort = parseInt(parts[2], 10);
943
+ localHost = "127.0.0.1";
944
+ }
945
+ else {
946
+ type = "remote";
947
+ remotePort = parseInt(parts[0], 10);
948
+ localHost = parts[1];
949
+ localPort = parseInt(parts[2], 10);
950
+ remoteHost = "0.0.0.0";
951
+ }
952
+ if (isNaN(localPort) || isNaN(remotePort))
953
+ die("端口号必须是数字");
954
+ try {
955
+ const servers = loadServers();
956
+ const cfg = servers.get(serverName);
957
+ if (!cfg)
958
+ die(`未找到服务器 "${serverName}"。可用:${[...servers.keys()].join(", ") || "(无)"}`);
959
+ const f = await startForward(cfg, type, localHost, localPort, remoteHost, remotePort);
960
+ const dir = type === "local"
961
+ ? `${f.localHost}:${f.localPort} → ${f.remoteHost}:${f.remotePort}`
962
+ : `${f.remoteHost}:${f.remotePort} → ${f.localHost}:${f.localPort}`;
963
+ process.stdout.write(`转发已启动 [${f.id}] ${dir}\n`);
964
+ }
965
+ catch (e) {
966
+ die(`启动转发失败:${e.message}`);
967
+ }
968
+ }
969
+ async function cmdListForwards(opts) {
970
+ if (optBool(opts, "help")) {
971
+ process.stdout.write(`用法: ssh-mcp list-forwards
972
+
973
+ 列出当前所有活跃的端口转发隧道。
974
+
975
+ 示例:
976
+ ssh-mcp list-forwards
977
+ `);
978
+ return;
979
+ }
980
+ const all = listForwards();
981
+ if (all.length === 0) {
982
+ process.stdout.write("当前没有端口转发。\n");
983
+ return;
984
+ }
985
+ for (const f of all) {
986
+ const dir = f.type === "local"
987
+ ? `${f.localHost}:${f.localPort} → ${f.remoteHost}:${f.remotePort}`
988
+ : `${f.remoteHost}:${f.remotePort} → ${f.localHost}:${f.localPort}`;
989
+ process.stdout.write(`[${f.id}] ${f.server.padEnd(16)} ${dir.padEnd(32)} ${f.state}\n`);
990
+ }
991
+ }
992
+ async function cmdCloseForward(opts) {
993
+ if (optBool(opts, "help")) {
994
+ process.stdout.write(`用法: ssh-mcp close-forward --id <id>
995
+
996
+ 选项:
997
+ --id, -i <id> 要停止的转发 id(必需)
998
+
999
+ 示例:
1000
+ ssh-mcp close-forward -i f1
1001
+ `);
1002
+ return;
1003
+ }
1004
+ const id = optStr(opts, "id") ?? optStr(opts, "i");
1005
+ if (!id)
1006
+ die("缺少 --id");
1007
+ if (!closeForward(id))
1008
+ die(`转发 ${id} 不存在或已停止。`);
1009
+ process.stdout.write(`转发 ${id} 已停止。\n`);
1010
+ }
1011
+ // ---- 批量执行 CLI ----
1012
+ async function cmdBatch(opts, positional) {
1013
+ if (optBool(opts, "help")) {
1014
+ process.stdout.write(`用法: ssh-mcp batch --servers <s1,s2,...> [选项] <命令...>
1015
+
1016
+ 选项:
1017
+ --servers <names> 目标服务器 name 列表,逗号分隔(必需)
1018
+ --timeout <ms> 命令超时毫秒数(默认 ${DEFAULT_TIMEOUT_MS})
1019
+ --force 跳过安全策略检查
1020
+ --command, -c <cmd> 要执行的命令(也可直接放在选项之后)
1021
+
1022
+ 示例:
1023
+ ssh-mcp batch --servers prod-web,prod-api -c "df -h /"
1024
+ ssh-mcp batch --servers web1,web2,web3 "systemctl status nginx"
1025
+ `);
1026
+ return;
1027
+ }
1028
+ const serversArg = optStr(opts, "servers");
1029
+ if (!serversArg)
1030
+ die("缺少 --servers。用法: ssh-mcp batch --servers s1,s2,... <命令>");
1031
+ let command = optStr(opts, "command") ?? optStr(opts, "c");
1032
+ if (!command)
1033
+ command = positional.join(" ");
1034
+ if (!command || command.trim() === "")
1035
+ die("缺少命令");
1036
+ const serverNames = serversArg.split(",").map((s) => s.trim()).filter(Boolean);
1037
+ if (serverNames.length === 0)
1038
+ die("--servers 格式错误");
1039
+ const timeout = optNum(opts, "timeout") ?? DEFAULT_TIMEOUT_MS;
1040
+ const force = optBool(opts, "force");
1041
+ let servers;
1042
+ try {
1043
+ servers = loadServers();
1044
+ }
1045
+ catch (e) {
1046
+ die(`读取配置失败:${e.message}`);
1047
+ }
1048
+ const missing = serverNames.filter((n) => !servers.has(n));
1049
+ if (missing.length)
1050
+ die(`未找到服务器:${missing.join(", ")}`);
1051
+ if (!force) {
1052
+ const security = loadSecurity();
1053
+ const blocked = validateCommand(command, security.blocked_patterns ?? []);
1054
+ if (blocked)
1055
+ die(blocked + "\n使用 --force 可跳过安全检查。");
1056
+ }
1057
+ const results = await Promise.allSettled(serverNames.map((name) => runCommand(servers.get(name), command, timeout).then((r) => ({ server: name, result: r }))));
1058
+ for (const r of results) {
1059
+ if (r.status === "rejected") {
1060
+ process.stderr.write(`=== ${r.reason?.server ?? "?"} FAILED ===\n${r.reason.message}\n`);
1061
+ }
1062
+ else {
1063
+ const { server: sName, result } = r.value;
1064
+ process.stdout.write(`=== ${sName} (exit ${result.code}) ===\n`);
1065
+ if (result.stdout)
1066
+ process.stdout.write(result.stdout.trimEnd() + "\n");
1067
+ if (result.stderr)
1068
+ process.stderr.write(`[stderr]\n${result.stderr.trimEnd()}\n`);
1069
+ }
1070
+ }
1071
+ }
1072
+ // ---- SFTP 文件操作 CLI ----
1073
+ async function cmdLs(opts) {
1074
+ if (optBool(opts, "help")) {
1075
+ process.stdout.write(`用法: ssh-mcp ls --server <name> --path <path> [--long]
1076
+
1077
+ 选项:
1078
+ --server, -s <name> 目标服务器 name(必需)
1079
+ --path, -p <path> 远程目录路径(必需)
1080
+ --long, -l 详细列表模式(权限/大小/时间)
1081
+
1082
+ 示例:
1083
+ ssh-mcp ls -s prod-web -p /var/log
1084
+ ssh-mcp ls -s prod-web -p /tmp --long
1085
+ `);
1086
+ return;
1087
+ }
1088
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1089
+ const path = optStr(opts, "path") ?? optStr(opts, "p");
1090
+ if (!serverName)
1091
+ die("缺少 --server");
1092
+ if (!path)
1093
+ die("缺少 --path");
1094
+ try {
1095
+ const servers = loadServers();
1096
+ const cfg = servers.get(serverName);
1097
+ if (!cfg)
1098
+ die(`未找到服务器 "${serverName}"`);
1099
+ const entries = await listDirectory(cfg, path);
1100
+ process.stdout.write(`${path}:\n`);
1101
+ const long = optBool(opts, "long") || optBool(opts, "l");
1102
+ for (const e of entries) {
1103
+ process.stdout.write((long ? formatLsLong(e) : formatLsShort(e)) + "\n");
1104
+ }
1105
+ process.stdout.write(`\n${entries.length} 条\n`);
1106
+ }
1107
+ catch (e) {
1108
+ die(`列出目录失败:${e.message}`);
1109
+ }
1110
+ }
1111
+ async function cmdStat(opts) {
1112
+ if (optBool(opts, "help")) {
1113
+ process.stdout.write(`用法: ssh-mcp stat --server <name> --path <path>
1114
+
1115
+ 选项:
1116
+ --server, -s <name> 目标服务器 name(必需)
1117
+ --path, -p <path> 远程文件路径(必需)
1118
+
1119
+ 示例:
1120
+ ssh-mcp stat -s prod-web -p /etc/nginx/nginx.conf
1121
+ `);
1122
+ return;
1123
+ }
1124
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1125
+ const path = optStr(opts, "path") ?? optStr(opts, "p");
1126
+ if (!serverName)
1127
+ die("缺少 --server");
1128
+ if (!path)
1129
+ die("缺少 --path");
1130
+ try {
1131
+ const servers = loadServers();
1132
+ const cfg = servers.get(serverName);
1133
+ if (!cfg)
1134
+ die(`未找到服务器 "${serverName}"`);
1135
+ const s = await statPath(cfg, path);
1136
+ process.stdout.write(`类型: ${s.type}\n大小: ${s.size}\n权限: ${s.mode.toString(8)}\nuid: ${s.uid}\ngid: ${s.gid}\n修改: ${new Date(s.mtime * 1000).toISOString()}\n`);
1137
+ }
1138
+ catch (e) {
1139
+ die(`stat 失败:${e.message}`);
1140
+ }
1141
+ }
1142
+ async function cmdRm(opts) {
1143
+ if (optBool(opts, "help")) {
1144
+ process.stdout.write(`用法: ssh-mcp rm --server <name> --path <path> [--recursive]
1145
+
1146
+ 选项:
1147
+ --server, -s <name> 目标服务器 name(必需)
1148
+ --path, -p <path> 要删除的远程文件或目录(必需)
1149
+ --recursive, -r 递归删除目录
1150
+
1151
+ 示例:
1152
+ ssh-mcp rm -s prod-web -p /tmp/old.log
1153
+ ssh-mcp rm -s prod-web -p /tmp/backup --recursive
1154
+ `);
1155
+ return;
1156
+ }
1157
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1158
+ const path = optStr(opts, "path") ?? optStr(opts, "p");
1159
+ if (!serverName)
1160
+ die("缺少 --server");
1161
+ if (!path)
1162
+ die("缺少 --path");
1163
+ try {
1164
+ const servers = loadServers();
1165
+ const cfg = servers.get(serverName);
1166
+ if (!cfg)
1167
+ die(`未找到服务器 "${serverName}"`);
1168
+ await removePath(cfg, path, optBool(opts, "recursive") || optBool(opts, "r"));
1169
+ process.stdout.write(`已删除:${path}\n`);
1170
+ }
1171
+ catch (e) {
1172
+ die(`删除失败:${e.message}`);
1173
+ }
1174
+ }
1175
+ async function cmdMkdir(opts) {
1176
+ if (optBool(opts, "help")) {
1177
+ process.stdout.write(`用法: ssh-mcp mkdir --server <name> --path <path> [--parents]
1178
+
1179
+ 选项:
1180
+ --server, -s <name> 目标服务器 name(必需)
1181
+ --path, -p <path> 要创建的远程目录路径(必需)
1182
+ --parents 自动创建父目录(类似 mkdir -p)
1183
+
1184
+ 示例:
1185
+ ssh-mcp mkdir -s prod-web -p /opt/app/logs --parents
1186
+ `);
1187
+ return;
1188
+ }
1189
+ const serverName = optStr(opts, "server") ?? optStr(opts, "s");
1190
+ const path = optStr(opts, "path") ?? optStr(opts, "p");
1191
+ if (!serverName)
1192
+ die("缺少 --server");
1193
+ if (!path)
1194
+ die("缺少 --path");
1195
+ try {
1196
+ const servers = loadServers();
1197
+ const cfg = servers.get(serverName);
1198
+ if (!cfg)
1199
+ die(`未找到服务器 "${serverName}"`);
1200
+ await makeDir(cfg, path, optBool(opts, "parents"));
1201
+ process.stdout.write(`已创建目录:${path}\n`);
1202
+ }
1203
+ catch (e) {
1204
+ die(`创建目录失败:${e.message}`);
1205
+ }
1206
+ }
1207
+ function loadServersOrDie() {
1208
+ try {
1209
+ return { servers: loadServers(), error: null };
1210
+ }
1211
+ catch (e) {
1212
+ die(`读取服务器配置失败:${e.message}`);
1213
+ }
1214
+ }
1215
+ // ---------------------------------------------------------------------------
1216
+ // Main entry
1217
+ // ---------------------------------------------------------------------------
345
1218
  async function main() {
346
- const transport = new StdioServerTransport();
347
- await server.connect(transport);
348
- // 日志走 stderr,避免污染 stdio 上的 MCP 协议数据。
349
- console.error(`ssh-mcp 已启动,配置文件:${configPath()}`);
1219
+ const { subcommand, options, positional } = parseArgs(process.argv.slice(2));
1220
+ // --mcp flag → run as MCP stdio server
1221
+ if (subcommand === "--mcp" || options.has("mcp")) {
1222
+ const server = createMcpServer();
1223
+ const transport = new StdioServerTransport();
1224
+ await server.connect(transport);
1225
+ console.error(`ssh-mcp MCP 服务已启动,配置文件:${configPath()}`);
1226
+ return;
1227
+ }
1228
+ // Route to subcommand first (subcommand may have its own --help)
1229
+ switch (subcommand) {
1230
+ case "help":
1231
+ case undefined:
1232
+ case "": {
1233
+ showHelp();
1234
+ process.exit(0);
1235
+ }
1236
+ case "list-servers": return cmdListServers(options);
1237
+ case "run-command": return cmdRunCommand(options, positional);
1238
+ case "batch": return cmdBatch(options, positional);
1239
+ case "open-session": return cmdOpenSession(options);
1240
+ case "close-session": return cmdCloseSession(options);
1241
+ case "list-sessions": return cmdListSessions(options);
1242
+ case "upload": return cmdUpload(options);
1243
+ case "download": return cmdDownload(options);
1244
+ case "transfer-status": return cmdTransferStatus(options);
1245
+ case "cancel-transfer": return cmdCancelTransfer(options);
1246
+ case "forward": return cmdForward(options);
1247
+ case "list-forwards": return cmdListForwards(options);
1248
+ case "close-forward": return cmdCloseForward(options);
1249
+ case "ls": return cmdLs(options);
1250
+ case "stat": return cmdStat(options);
1251
+ case "rm": return cmdRm(options);
1252
+ case "mkdir": return cmdMkdir(options);
1253
+ default:
1254
+ die(`未知子命令: ${subcommand}\n运行 ssh-mcp --help 查看可用命令。`);
1255
+ }
350
1256
  }
351
1257
  main().catch((e) => {
352
- console.error("ssh-mcp 启动失败:", e);
1258
+ process.stderr.write(`ssh-mcp 启动失败:${e}\n`);
353
1259
  process.exit(1);
354
1260
  });