@bingzi-233/ssh-mcp 1.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ssh-mcp
2
2
 
3
- 纯命令行 SSH/SFTP 工具:在多台远程服务器上执行命令、传输大文件(断点续传)。支持 CLI 模式和 MCP stdio 模式(`--mcp`)。
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
6
  [![Node](https://img.shields.io/node/v/@bingzi-233/ssh-mcp?color=339933&logo=nodedotjs)](https://nodejs.org)
@@ -12,26 +12,14 @@
12
12
  ## CLI 快速上手
13
13
 
14
14
  ```bash
15
- # 安装
16
15
  npm i -g @bingzi-233/ssh-mcp
17
16
 
18
- # 查看帮助
19
- ssh-mcp --help
20
-
21
- # 列出服务器
22
- ssh-mcp list-servers
23
-
24
- # 执行远程命令
25
- ssh-mcp run-command -s prod-web -c "df -h /"
26
-
27
- # 上传文件(支持断点续传)
28
- ssh-mcp upload -s prod-web -l ./dist.tar.gz -r /tmp/dist.tar.gz
29
-
30
- # 下载文件
31
- ssh-mcp download -s prod-web -r /var/log/app.log -l ./logs/app.log
32
-
33
- # 传输进度
34
- ssh-mcp transfer-status
17
+ ssh-mcp list-servers # 列出服务器
18
+ ssh-mcp run-command -s prod-web -c "df -h /" # 执行命令
19
+ ssh-mcp batch --servers web1,web2,web3 -c "uptime" # 批量执行
20
+ ssh-mcp upload -s prod-web -l ./dist.tar.gz -r /tmp/ # 上传文件
21
+ ssh-mcp ls -s prod-web -p /var/log # 列出目录
22
+ ssh-mcp forward -s prod-web -L 8080:192.168.1.5:80 # 端口转发
35
23
  ```
36
24
 
37
25
  ## 命令一览
@@ -40,13 +28,13 @@ ssh-mcp transfer-status
40
28
  |---|---|
41
29
  | `list-servers` | 列出所有已配置的服务器 |
42
30
  | `run-command` | 在远程服务器上执行命令 |
43
- | `open-session` | 打开长连接会话(复用 TCP 连接) |
44
- | `close-session` | 关闭长连接会话 |
45
- | `list-sessions` | 列出当前所有长连接会话 |
46
- | `upload` | 上传文件到远程(后台任务,断点续传) |
47
- | `download` | 从远程下载文件(后台任务,断点续传) |
48
- | `transfer-status` | 查看传输进度 |
49
- | `cancel-transfer` | 取消传输 |
31
+ | `batch` | 在多台服务器上批量执行同一命令 |
32
+ | `open-session` / `close-session` / `list-sessions` | 长连接会话管理 |
33
+ | `upload` / `download` | 大文件传输(后台任务,断点续传) |
34
+ | `transfer-status` / `cancel-transfer` | 传输进度与取消 |
35
+ | `forward` / `list-forwards` / `close-forward` | SSH 端口转发(本地/远程) |
36
+ | `ls` / `stat` | 列出远程目录、查看文件信息 |
37
+ | `rm` / `mkdir` | 删除远程文件/目录、创建远程目录 |
50
38
 
51
39
  每个子命令运行 `ssh-mcp <子命令> --help` 查看详细用法。
52
40
 
@@ -56,24 +44,15 @@ ssh-mcp transfer-status
56
44
 
57
45
  ```json
58
46
  {
59
- "security": {
60
- "blocked_patterns": []
61
- },
47
+ "security": { "blocked_patterns": [] },
62
48
  "servers": [
63
49
  {
64
50
  "name": "prod-web",
65
- "description": "生产环境 Web 服务器",
51
+ "description": "生产环境",
66
52
  "host": "192.168.1.10",
67
53
  "port": 22,
68
54
  "username": "deploy",
69
55
  "privateKeyPath": "~/.ssh/id_rsa"
70
- },
71
- {
72
- "name": "db",
73
- "description": "数据库服务器",
74
- "host": "db.example.com",
75
- "username": "admin",
76
- "password": "your-password"
77
56
  }
78
57
  ]
79
58
  }
@@ -81,16 +60,24 @@ ssh-mcp transfer-status
81
60
 
82
61
  鉴权优先级:私钥 → 密码 → ssh-agent。修改配置无需重启。
83
62
 
84
- ## 长连接会话
63
+ ## 端口转发
85
64
 
86
65
  ```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
66
+ # 本地转发:本机 8080 → 经 SSH 服务器 → 内网 192.168.1.5:80
67
+ ssh-mcp forward -s prod-web -L 8080:192.168.1.5:80
68
+
69
+ # 远程转发:SSH 服务器 9000 → 回传到本机 localhost:3000
70
+ ssh-mcp forward -s prod-web -R 9000:127.0.0.1:3000
71
+
72
+ ssh-mcp list-forwards
73
+ ssh-mcp close-forward -i f1
91
74
  ```
92
75
 
93
- 复用 TCP 连接,省去重复握手和认证。注意每条命令仍在独立 channel 中执行,不保留工作目录。
76
+ ## 批量执行
77
+
78
+ ```bash
79
+ ssh-mcp batch --servers prod-web,prod-api,prod-worker -c "systemctl status nginx"
80
+ ```
94
81
 
95
82
  ## 安全策略
96
83
 
@@ -101,10 +88,7 @@ ssh-mcp close-session -s $SID
101
88
  以 MCP stdio 服务运行(供 Claude Code 等 AI 客户端调用):
102
89
 
103
90
  ```bash
104
- # 手动注册
105
91
  claude mcp add ssh -- npx -y @bingzi-233/ssh-mcp --mcp
106
-
107
- # 或通过插件安装
108
92
  /plugin marketplace add BingZi-233/ssh-mcp
109
93
  /plugin install ssh-mcp@bingzi-plugins
110
94
  ```
@@ -0,0 +1,108 @@
1
+ import { createServer, Socket } from "node:net";
2
+ import { createConnection } from "./ssh.js";
3
+ const forwards = new Map();
4
+ /** 存活的本地 net.Server / SSH 连接,用于关闭 */
5
+ const resources = new Map();
6
+ let counter = 0;
7
+ function track(id, conn, server) {
8
+ resources.set(id, { conn, server });
9
+ conn.on("close", () => {
10
+ const f = forwards.get(id);
11
+ if (f)
12
+ f.state = "stopped";
13
+ server?.close();
14
+ });
15
+ conn.on("error", () => {
16
+ const f = forwards.get(id);
17
+ if (f)
18
+ f.state = "stopped";
19
+ server?.close();
20
+ });
21
+ }
22
+ /**
23
+ * 启动端口转发。
24
+ *
25
+ * type=local: 监听本机 localPort,流量经 SSH 服务器转发到 remoteHost:remotePort。
26
+ * type=remote: SSH 服务器监听 remotePort,流量经本机转发到 localHost:localPort。
27
+ */
28
+ export async function startForward(cfg, type, localHost, localPort, remoteHost, remotePort) {
29
+ const conn = await createConnection(cfg, 20_000);
30
+ const id = `f${++counter}`;
31
+ if (type === "local") {
32
+ await new Promise((resolve, reject) => {
33
+ const server = createServer((socket) => {
34
+ conn.forwardOut(localHost, localPort, remoteHost, remotePort, (err, stream) => {
35
+ if (err) {
36
+ socket.destroy();
37
+ return;
38
+ }
39
+ socket.pipe(stream).pipe(socket);
40
+ stream.on("error", () => socket.destroy());
41
+ socket.on("error", () => stream.destroy());
42
+ });
43
+ });
44
+ server.on("error", reject);
45
+ server.listen(localPort, localHost, () => {
46
+ server.removeListener("error", reject);
47
+ track(id, conn, server);
48
+ resolve();
49
+ });
50
+ });
51
+ }
52
+ else {
53
+ // remote: SSH 服务器暴露端口,转发回本机
54
+ await new Promise((resolve, reject) => {
55
+ conn.forwardIn(remoteHost, remotePort, (err) => {
56
+ if (err)
57
+ reject(err);
58
+ else {
59
+ track(id, conn);
60
+ resolve();
61
+ }
62
+ });
63
+ });
64
+ // 处理来自远程的入站连接
65
+ conn.on("tcp connection", (info, accept) => {
66
+ const stream = accept();
67
+ const sock = new Socket();
68
+ sock.connect(remotePort, localHost, () => {
69
+ sock.pipe(stream).pipe(sock);
70
+ stream.on("error", () => sock.destroy());
71
+ sock.on("error", () => stream.destroy());
72
+ });
73
+ sock.on("error", () => stream.end());
74
+ });
75
+ }
76
+ const fwd = {
77
+ id,
78
+ server: cfg.name,
79
+ type,
80
+ localHost,
81
+ localPort,
82
+ remoteHost,
83
+ remotePort,
84
+ state: "running",
85
+ createdAt: Date.now(),
86
+ };
87
+ forwards.set(id, fwd);
88
+ return fwd;
89
+ }
90
+ export function getForward(id) {
91
+ return forwards.get(id);
92
+ }
93
+ export function listForwards() {
94
+ return [...forwards.values()];
95
+ }
96
+ export function closeForward(id) {
97
+ const fwd = forwards.get(id);
98
+ if (!fwd || fwd.state === "stopped")
99
+ return false;
100
+ fwd.state = "stopped";
101
+ const res = resources.get(id);
102
+ if (res) {
103
+ res.server?.close();
104
+ res.conn.end();
105
+ resources.delete(id);
106
+ }
107
+ return true;
108
+ }
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)
@@ -56,7 +58,7 @@ function formatTransfer(t) {
56
58
  // MCP server setup (--mcp mode)
57
59
  // ---------------------------------------------------------------------------
58
60
  function createMcpServer() {
59
- const server = new McpServer({ name: "ssh-mcp", version: "1.3.0" });
61
+ const server = new McpServer({ name: "ssh-mcp", version: "1.4.0" });
60
62
  server.registerTool("list_servers", {
61
63
  title: "列出可用的 SSH 服务器",
62
64
  description: "列出所有已配置的远程服务器及其 name、描述、地址和登录用户。" +
@@ -284,6 +286,200 @@ function createMcpServer() {
284
286
  return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
285
287
  return { content: [{ type: "text", text: `已请求取消:\n${formatTransfer(t)}` }] };
286
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
+ });
287
483
  return server;
288
484
  }
289
485
  function parseArgs(raw) {
@@ -352,13 +548,14 @@ function die(msg) {
352
548
  process.exit(1);
353
549
  }
354
550
  function showHelp() {
355
- process.stdout.write(`ssh-mcp — SSH/SFTP 远程服务器命令行工具 v1.2.0
551
+ process.stdout.write(`ssh-mcp — SSH/SFTP 远程服务器命令行工具 v1.4.0
356
552
 
357
553
  用法: ssh-mcp <子命令> [选项]
358
554
 
359
555
  子命令:
360
556
  list-servers 列出所有已配置的服务器
361
557
  run-command 在远程服务器上执行命令
558
+ batch 在多台服务器上批量执行同一命令
362
559
  open-session 打开到远程服务器的长连接会话
363
560
  close-session 关闭长连接会话
364
561
  list-sessions 列出当前所有长连接会话
@@ -366,6 +563,13 @@ function showHelp() {
366
563
  download 从远程服务器下载文件(支持断点续传)
367
564
  transfer-status 查看文件传输进度
368
565
  cancel-transfer 取消文件传输
566
+ forward 启动 SSH 端口转发(本地/远程)
567
+ list-forwards 列出当前所有端口转发
568
+ close-forward 停止端口转发
569
+ ls 列出远程目录内容
570
+ stat 查看远程文件信息
571
+ rm 删除远程文件或目录
572
+ mkdir 创建远程目录
369
573
 
370
574
  全局选项:
371
575
  --mcp 以 MCP stdio 服务模式运行(供 AI 客户端调用)
@@ -696,6 +900,310 @@ async function cmdCancelTransfer(opts) {
696
900
  die(`未找到传输任务:${id}`);
697
901
  process.stdout.write(`已请求取消:\n${formatTransfer(t)}\n`);
698
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
+ }
699
1207
  function loadServersOrDie() {
700
1208
  try {
701
1209
  return { servers: loadServers(), error: null };
@@ -727,6 +1235,7 @@ async function main() {
727
1235
  }
728
1236
  case "list-servers": return cmdListServers(options);
729
1237
  case "run-command": return cmdRunCommand(options, positional);
1238
+ case "batch": return cmdBatch(options, positional);
730
1239
  case "open-session": return cmdOpenSession(options);
731
1240
  case "close-session": return cmdCloseSession(options);
732
1241
  case "list-sessions": return cmdListSessions(options);
@@ -734,6 +1243,13 @@ async function main() {
734
1243
  case "download": return cmdDownload(options);
735
1244
  case "transfer-status": return cmdTransferStatus(options);
736
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);
737
1253
  default:
738
1254
  die(`未知子命令: ${subcommand}\n运行 ssh-mcp --help 查看可用命令。`);
739
1255
  }
@@ -0,0 +1,160 @@
1
+ import { posix } from "node:path";
2
+ import { createConnection } from "./ssh.js";
3
+ function openSftp(conn) {
4
+ return new Promise((resolve, reject) => {
5
+ conn.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)));
6
+ });
7
+ }
8
+ function classify(mode) {
9
+ const ifmt = mode & 0o170000;
10
+ if (ifmt === 0o040000)
11
+ return "directory";
12
+ if (ifmt === 0o100000)
13
+ return "file";
14
+ if (ifmt === 0o120000)
15
+ return "link";
16
+ return "other";
17
+ }
18
+ function formatMode(mode) {
19
+ const r = (mode & 0o4) ? "r" : "-";
20
+ const w = (mode & 0o2) ? "w" : "-";
21
+ const x = (mode & 0o1) ? "x" : "-";
22
+ return r + w + x;
23
+ }
24
+ function formatPerms(mode) {
25
+ const f = classify(mode);
26
+ const c = f === "directory" ? "d" : f === "link" ? "l" : f === "other" ? "?" : "-";
27
+ return c + formatMode(mode >> 6) + formatMode(mode >> 3) + formatMode(mode);
28
+ }
29
+ export function formatLsLong(e) {
30
+ return `${formatPerms(e.mode)} ${String(e.uid).padStart(5)} ${String(e.gid).padStart(5)} ${String(e.size).padStart(10)} ${new Date(e.mtime * 1000).toISOString().replace("T", " ").replace(/\..*/, "")} ${e.name}`;
31
+ }
32
+ export function formatLsShort(e) {
33
+ return e.name;
34
+ }
35
+ export async function listDirectory(cfg, path) {
36
+ const conn = await createConnection(cfg, 20_000);
37
+ try {
38
+ const sftp = await openSftp(conn);
39
+ return new Promise((resolve, reject) => {
40
+ sftp.readdir(path, (err, entries) => {
41
+ conn.end();
42
+ if (err)
43
+ return reject(err);
44
+ resolve(entries.map((e) => ({
45
+ name: e.filename,
46
+ type: classify(e.attrs.mode),
47
+ size: e.attrs.size,
48
+ mode: e.attrs.mode,
49
+ uid: e.attrs.uid,
50
+ gid: e.attrs.gid,
51
+ mtime: e.attrs.mtime,
52
+ atime: e.attrs.atime,
53
+ longname: e.longname,
54
+ })));
55
+ });
56
+ });
57
+ }
58
+ catch (e) {
59
+ conn.end();
60
+ throw e;
61
+ }
62
+ }
63
+ export async function statPath(cfg, path) {
64
+ const conn = await createConnection(cfg, 20_000);
65
+ try {
66
+ const sftp = await openSftp(conn);
67
+ return new Promise((resolve, reject) => {
68
+ sftp.stat(path, (err, s) => {
69
+ conn.end();
70
+ if (err)
71
+ return reject(err);
72
+ resolve({
73
+ type: classify(s.mode),
74
+ size: s.size,
75
+ mode: s.mode,
76
+ uid: s.uid,
77
+ gid: s.gid,
78
+ mtime: s.mtime,
79
+ atime: s.atime,
80
+ });
81
+ });
82
+ });
83
+ }
84
+ catch (e) {
85
+ conn.end();
86
+ throw e;
87
+ }
88
+ }
89
+ export async function removePath(cfg, path, recursive) {
90
+ const conn = await createConnection(cfg, 20_000);
91
+ try {
92
+ const sftp = await openSftp(conn);
93
+ // 先 stat 确定类型
94
+ const s = await new Promise((resolve, reject) => {
95
+ sftp.stat(path, (err, st) => {
96
+ if (err)
97
+ return reject(err);
98
+ resolve({ type: classify(st.mode) });
99
+ });
100
+ });
101
+ if (s.type === "directory") {
102
+ if (!recursive)
103
+ throw new Error(`"${path}" 是目录,需要 --recursive 选项`);
104
+ // 递归删除目录内容
105
+ const entries = await new Promise((res, rej) => {
106
+ sftp.readdir(path, (err, e) => (err ? rej(err) : res(e)));
107
+ });
108
+ for (const e of entries) {
109
+ if (e.filename === "." || e.filename === "..")
110
+ continue;
111
+ await removePath(cfg, posix.join(path, e.filename), true);
112
+ }
113
+ await new Promise((res, rej) => {
114
+ sftp.rmdir(path, (err) => (err ? rej(err) : res()));
115
+ });
116
+ }
117
+ else {
118
+ await new Promise((res, rej) => {
119
+ sftp.unlink(path, (err) => (err ? rej(err) : res()));
120
+ });
121
+ }
122
+ conn.end();
123
+ }
124
+ catch (e) {
125
+ conn.end();
126
+ throw e;
127
+ }
128
+ }
129
+ export async function makeDir(cfg, path, parents) {
130
+ const conn = await createConnection(cfg, 20_000);
131
+ try {
132
+ const sftp = await openSftp(conn);
133
+ if (parents) {
134
+ // 逐层创建
135
+ const parts = path.split("/").filter(Boolean);
136
+ let current = path.startsWith("/") ? "/" : "";
137
+ for (const p of parts) {
138
+ current = current ? posix.join(current, p) : p;
139
+ await new Promise((resolve, reject) => {
140
+ sftp.mkdir(current, { mode: 0o755 }, (err) => {
141
+ // 已存在不算错
142
+ if (err && err.code !== 4)
143
+ return reject(err);
144
+ resolve();
145
+ });
146
+ });
147
+ }
148
+ }
149
+ else {
150
+ await new Promise((res, rej) => {
151
+ sftp.mkdir(path, { mode: 0o755 }, (err) => (err ? rej(err) : res()));
152
+ });
153
+ }
154
+ conn.end();
155
+ }
156
+ catch (e) {
157
+ conn.end();
158
+ throw e;
159
+ }
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bingzi-233/ssh-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "纯命令行 SSH/SFTP 工具:在多台远程服务器上执行命令、传输大文件(断点续传)。支持 CLI 模式和 MCP stdio 模式。",
5
5
  "type": "module",
6
6
  "bin": {