@bingzi-233/ssh-mcp 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BingZi-233
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # ssh-mcp
2
+
3
+ 一个 MCP 服务器,让 Claude 通过 **SSH** 在多台远程服务器上执行命令、并通过 **SFTP** 传输大文件(支持 40GB+ 与断点续传)。每台服务器配一个 `name`,模型靠 `name` 区分目标机器。
4
+
5
+ ## 工具
6
+
7
+ | 工具 | 作用 |
8
+ |---|---|
9
+ | `list_servers` | 列出所有已配置的服务器(name / 描述 / 地址 / 用户,**不含密码私钥**) |
10
+ | `run_command` | 在指定 `name` 的服务器上执行一条命令,返回 stdout / stderr / 退出码 |
11
+ | `upload_file` | 上传本机文件到远程(后台任务,支持断点续传) |
12
+ | `download_file` | 从远程下载文件到本机(后台任务,支持断点续传) |
13
+ | `transfer_status` | 查询传输进度(已传字节、百分比、速度、ETA、状态) |
14
+ | `cancel_transfer` | 取消一个进行中的传输(已传部分保留,可续传) |
15
+
16
+ > - 命令在独立会话中执行,**命令之间不保留工作目录和环境变量**。需要保持上下文时自行串接,例如 `cd /var/www && git pull`。
17
+ > - 大文件传输是**后台任务**:`upload_file`/`download_file` 立即返回一个传输 id,用 `transfer_status` 轮询进度,不阻塞。
18
+ > - **断点续传**基于目标文件的实际大小:对同一对路径再次发起传输会自动从已传字节处继续,进程重启后依然有效。
19
+
20
+ ---
21
+
22
+ ## 安装
23
+
24
+ ### 方式一:作为 Claude Code 插件(推荐)
25
+
26
+ ```shell
27
+ /plugin marketplace add BingZi-233/ssh-mcp
28
+ /plugin install ssh-mcp@bingzi-plugins
29
+ ```
30
+
31
+ 插件会通过 `npx` 拉起已发布的 npm 包,因此**需要先把包发布到 npm**(见下文「发布到 npm」)。
32
+
33
+ ### 方式二:作为 MCP 服务器手动注册
34
+
35
+ ```bash
36
+ claude mcp add ssh -- npx -y @bingzi-233/ssh-mcp
37
+ ```
38
+
39
+ 需要自定义配置文件路径时:
40
+
41
+ ```bash
42
+ claude mcp add ssh -e SSH_MCP_CONFIG=/path/to/servers.json -- npx -y @bingzi-233/ssh-mcp
43
+ ```
44
+
45
+ ### 方式三:本地源码构建(开发用)
46
+
47
+ ```bash
48
+ npm install
49
+ npm run build
50
+ claude mcp add ssh -- node /绝对路径/ssh-mcp/dist/index.js
51
+ ```
52
+
53
+ ---
54
+
55
+ ## 配置服务器
56
+
57
+ 默认从 `~/.ssh-mcp/servers.json` 读取(可用环境变量 `SSH_MCP_CONFIG` 指定别的路径)。参考 `servers.example.json`:
58
+
59
+ ```json
60
+ {
61
+ "servers": [
62
+ {
63
+ "name": "prod-web",
64
+ "description": "生产环境 Web 服务器",
65
+ "host": "192.168.1.10",
66
+ "port": 22,
67
+ "username": "deploy",
68
+ "privateKeyPath": "~/.ssh/id_rsa"
69
+ },
70
+ {
71
+ "name": "db",
72
+ "description": "数据库服务器",
73
+ "host": "db.example.com",
74
+ "username": "admin",
75
+ "password": "your-password"
76
+ }
77
+ ]
78
+ }
79
+ ```
80
+
81
+ **鉴权优先级**(每台服务器独立选择):
82
+
83
+ 1. `privateKeyPath`(+ 可选 `passphrase`)—— 私钥文件,支持 `~` 展开
84
+ 2. `password` —— 密码登录
85
+ 3. 都没配 → 回退到本机 `ssh-agent`(读 `SSH_AUTH_SOCK`)
86
+
87
+ 新增/修改服务器后**无需重启**——配置文件每次调用都会重新读取。
88
+
89
+ ---
90
+
91
+ ## 发布到 npm(维护者)
92
+
93
+ 仓库已配置 GitHub Actions,推送 `vX.Y.Z` 形式的 tag 即自动发布到 npm。
94
+
95
+ **一次性准备**:
96
+
97
+ 1. 在 npmjs.com 创建一个 **Automation** 类型的 Access Token。
98
+ 2. 把它加到仓库 Secrets:
99
+ ```bash
100
+ gh secret set NPM_TOKEN
101
+ ```
102
+ (或在 GitHub 网页 Settings → Secrets and variables → Actions 添加。)
103
+
104
+ > 包名 scope `@bingzi-233` 必须是你拥有的 npm 用户名或组织。若不一致,改 `package.json` 的 `name` 与插件 `.mcp.json` 里的 `args`。
105
+
106
+ **每次发版**:
107
+
108
+ ```bash
109
+ # 1. 同步版本号(两处都改:npm 包与插件)
110
+ # - package.json 的 "version"
111
+ # - plugins/ssh-mcp/.claude-plugin/plugin.json 的 "version"
112
+ # 2. 提交并打 tag
113
+ git commit -am "release: v1.0.1"
114
+ git tag v1.0.1
115
+ git push && git push --tags
116
+ ```
117
+
118
+ Actions 会自动 `npm ci && npm run build && npm publish`(带 provenance 来源证明)。
119
+
120
+ ---
121
+
122
+ ## ⚠️ 安全提示
123
+
124
+ 这个工具允许模型在你的服务器上执行**任意命令**、读写文件。请仅指向你拥有/授权的机器:
125
+
126
+ - `servers.json` 含明文凭据,已在 `.gitignore` 中排除,**切勿提交到仓库**。
127
+ - 优先用私钥或 `ssh-agent`,尽量不在配置里写明文密码。
128
+ - 给 SSH 账号最小权限;高危机器考虑用受限账号。
129
+ - `run_command` 默认每条命令 60s 超时,可用 `timeout_ms` 调整。
130
+
131
+ ## License
132
+
133
+ MIT © BingZi-233
package/dist/config.js ADDED
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /** 把开头的 ~ 展开成用户主目录。 */
5
+ export function expandHome(p) {
6
+ if (p === "~")
7
+ return homedir();
8
+ if (p.startsWith("~/") || p.startsWith("~\\"))
9
+ return join(homedir(), p.slice(2));
10
+ return p;
11
+ }
12
+ /** 配置文件路径:优先环境变量 SSH_MCP_CONFIG,否则取 ~/.ssh-mcp/servers.json。 */
13
+ export function configPath() {
14
+ const fromEnv = process.env.SSH_MCP_CONFIG;
15
+ return fromEnv ? expandHome(fromEnv) : join(homedir(), ".ssh-mcp", "servers.json");
16
+ }
17
+ /**
18
+ * 每次调用都从磁盘重新读取配置——配置文件就是唯一事实来源。
19
+ * 这样新增服务器后无需重启 MCP 服务器。读取或校验失败时抛错,由调用方处理。
20
+ */
21
+ export function loadServers() {
22
+ const path = configPath();
23
+ const raw = readFileSync(path, "utf8");
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(raw);
27
+ }
28
+ catch (e) {
29
+ throw new Error(`配置文件 ${path} 不是合法 JSON:${e.message}`);
30
+ }
31
+ const list = parsed.servers;
32
+ if (!Array.isArray(list)) {
33
+ throw new Error(`配置文件 ${path} 缺少顶层 "servers" 数组`);
34
+ }
35
+ const map = new Map();
36
+ for (const item of list) {
37
+ if (!item.name || !item.host || !item.username) {
38
+ throw new Error(`配置文件中存在缺少 name/host/username 的服务器条目`);
39
+ }
40
+ if (map.has(item.name)) {
41
+ throw new Error(`配置文件中存在重复的服务器 name:"${item.name}"`);
42
+ }
43
+ map.set(item.name, item);
44
+ }
45
+ return map;
46
+ }
package/dist/index.js ADDED
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { configPath, loadServers } from "./config.js";
6
+ import { runCommand } from "./ssh.js";
7
+ import { cancelTransfer, getTransfer, listTransfers, startTransfer, } from "./transfer.js";
8
+ const DEFAULT_TIMEOUT_MS = 60_000;
9
+ function humanBytes(n) {
10
+ if (n < 1024)
11
+ return `${n} B`;
12
+ const units = ["KB", "MB", "GB", "TB"];
13
+ let v = n / 1024;
14
+ let i = 0;
15
+ while (v >= 1024 && i < units.length - 1) {
16
+ v /= 1024;
17
+ i++;
18
+ }
19
+ return `${v.toFixed(2)} ${units[i]}`;
20
+ }
21
+ function humanDuration(sec) {
22
+ if (!isFinite(sec) || sec <= 0)
23
+ return "—";
24
+ const s = Math.round(sec);
25
+ if (s < 60)
26
+ return `${s}s`;
27
+ const m = Math.floor(s / 60);
28
+ if (m < 60)
29
+ return `${m}m ${s % 60}s`;
30
+ const h = Math.floor(m / 60);
31
+ return `${h}h ${m % 60}m`;
32
+ }
33
+ function formatTransfer(t) {
34
+ const pct = t.totalBytes > 0 ? (t.transferredBytes / t.totalBytes) * 100 : 100;
35
+ const movedThisSession = t.transferredBytes - t.startOffset;
36
+ const elapsedSec = Math.max((t.updatedAt - t.startedAt) / 1000, 0.001);
37
+ const speed = movedThisSession / elapsedSec;
38
+ const remaining = t.totalBytes - t.transferredBytes;
39
+ const eta = t.state === "running" && speed > 0 ? remaining / speed : NaN;
40
+ const lines = [
41
+ `[${t.id}] ${t.direction === "upload" ? "上传" : "下载"} @ ${t.server} — ${t.state}`,
42
+ ` 本地: ${t.localPath}`,
43
+ ` 远程: ${t.remotePath}`,
44
+ ` 进度: ${humanBytes(t.transferredBytes)} / ${humanBytes(t.totalBytes)} (${pct.toFixed(1)}%)`,
45
+ ];
46
+ if (t.startOffset > 0)
47
+ lines.push(` 续传起点: ${humanBytes(t.startOffset)}`);
48
+ if (t.state === "running") {
49
+ lines.push(` 速度: ${humanBytes(speed)}/s 预计剩余: ${humanDuration(eta)}`);
50
+ }
51
+ if (t.error)
52
+ lines.push(` 错误: ${t.error}`);
53
+ return lines.join("\n");
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 () => {
67
+ try {
68
+ const servers = loadServers();
69
+ const list = [...servers.values()].map((s) => ({
70
+ name: s.name,
71
+ description: s.description ?? "",
72
+ host: s.host,
73
+ port: s.port ?? 22,
74
+ username: s.username,
75
+ }));
76
+ const text = list.length > 0
77
+ ? JSON.stringify(list, null, 2)
78
+ : `没有已配置的服务器。请编辑配置文件:${configPath()}`;
79
+ return { content: [{ type: "text", text }] };
80
+ }
81
+ catch (e) {
82
+ return {
83
+ content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
84
+ isError: true,
85
+ };
86
+ }
87
+ });
88
+ server.registerTool("run_command", {
89
+ title: "在远程服务器上执行命令",
90
+ description: "通过 SSH 在指定的远程服务器上执行一条 shell 命令,返回 stdout、stderr 和退出码。" +
91
+ "用 server 参数指定目标服务器的 name(可先用 list_servers 查看)。" +
92
+ "注意:每条命令在独立的会话中执行,命令之间不保留工作目录或环境变量;" +
93
+ "需要保持上下文时请自行串接,例如 `cd /var/www && git pull`。",
94
+ inputSchema: {
95
+ server: z
96
+ .string()
97
+ .describe("目标服务器的 name,必须与 list_servers 返回的某个 name 完全一致"),
98
+ command: z.string().describe("要在远程服务器上执行的 shell 命令"),
99
+ timeout_ms: z
100
+ .number()
101
+ .int()
102
+ .positive()
103
+ .optional()
104
+ .describe(`命令超时时间(毫秒),默认 ${DEFAULT_TIMEOUT_MS}`),
105
+ },
106
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
107
+ }, async ({ server: serverName, command, timeout_ms }) => {
108
+ let servers;
109
+ try {
110
+ servers = loadServers();
111
+ }
112
+ catch (e) {
113
+ return {
114
+ content: [{ type: "text", text: `读取服务器配置失败:${e.message}` }],
115
+ isError: true,
116
+ };
117
+ }
118
+ const cfg = servers.get(serverName);
119
+ if (!cfg) {
120
+ const names = [...servers.keys()].join(", ") || "(无)";
121
+ return {
122
+ content: [
123
+ { type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` },
124
+ ],
125
+ isError: true,
126
+ };
127
+ }
128
+ try {
129
+ const r = await runCommand(cfg, command, timeout_ms ?? DEFAULT_TIMEOUT_MS);
130
+ const parts = [
131
+ `服务器: ${cfg.name} (${cfg.username}@${cfg.host}:${cfg.port ?? 22})`,
132
+ `退出码: ${r.code ?? "null"}${r.signal ? ` 信号: ${r.signal}` : ""}`,
133
+ ];
134
+ if (r.stdout)
135
+ parts.push(`--- stdout ---\n${r.stdout.trimEnd()}`);
136
+ if (r.stderr)
137
+ parts.push(`--- stderr ---\n${r.stderr.trimEnd()}`);
138
+ if (!r.stdout && !r.stderr)
139
+ parts.push("(无输出)");
140
+ return { content: [{ type: "text", text: parts.join("\n") }] };
141
+ }
142
+ catch (e) {
143
+ return {
144
+ content: [{ type: "text", text: `执行失败:${e.message}` }],
145
+ isError: true,
146
+ };
147
+ }
148
+ });
149
+ function loadServersOrError() {
150
+ try {
151
+ return { servers: loadServers(), error: null };
152
+ }
153
+ catch (e) {
154
+ return { servers: null, error: e.message };
155
+ }
156
+ }
157
+ server.registerTool("upload_file", {
158
+ title: "上传文件到远程服务器",
159
+ description: "通过 SFTP 把本机文件上传到指定服务器,适用于大文件(40GB+)。" +
160
+ "这是后台任务:本工具立即返回一个传输 id,随后请用 transfer_status 轮询进度,不要阻塞等待。" +
161
+ "支持断点续传——对同一对路径再次调用会自动从远程已有字节处继续;" +
162
+ "若远程 remote_path 是已存在的目录,则自动在其下使用本地文件名。",
163
+ inputSchema: {
164
+ server: z.string().describe("目标服务器的 name(见 list_servers)"),
165
+ local_path: z.string().describe("本机要上传的文件路径(绝对路径)"),
166
+ remote_path: z.string().describe("远程目标路径(文件路径;若为已存在目录则自动追加文件名)"),
167
+ overwrite: z
168
+ .boolean()
169
+ .optional()
170
+ .describe("true 则忽略远程已有部分、从头覆盖;默认 false(自动断点续传)"),
171
+ },
172
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
173
+ }, async ({ server: serverName, local_path, remote_path, overwrite }) => {
174
+ const { servers, error } = loadServersOrError();
175
+ if (!servers)
176
+ return { content: [{ type: "text", text: `读取服务器配置失败:${error}` }], isError: true };
177
+ const cfg = servers.get(serverName);
178
+ if (!cfg) {
179
+ const names = [...servers.keys()].join(", ") || "(无)";
180
+ return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
181
+ }
182
+ try {
183
+ const t = await startTransfer(cfg, "upload", local_path, remote_path, overwrite ?? false);
184
+ const hint = t.state === "completed"
185
+ ? "\n(目标已是完整文件,无需传输。)"
186
+ : `\n传输已在后台开始,用 transfer_status({ id: "${t.id}" }) 查看进度。`;
187
+ return { content: [{ type: "text", text: formatTransfer(t) + hint }] };
188
+ }
189
+ catch (e) {
190
+ return { content: [{ type: "text", text: `发起上传失败:${e.message}` }], isError: true };
191
+ }
192
+ });
193
+ server.registerTool("download_file", {
194
+ title: "从远程服务器下载文件",
195
+ description: "通过 SFTP 把指定服务器上的文件下载到本机,适用于大文件(40GB+)。" +
196
+ "这是后台任务:本工具立即返回一个传输 id,随后请用 transfer_status 轮询进度,不要阻塞等待。" +
197
+ "支持断点续传——对同一对路径再次调用会自动从本地已有字节处继续;" +
198
+ "若本地 local_path 是已存在的目录,则自动在其下使用远程文件名。",
199
+ inputSchema: {
200
+ server: z.string().describe("源服务器的 name(见 list_servers)"),
201
+ remote_path: z.string().describe("远程要下载的文件路径"),
202
+ local_path: z.string().describe("本机目标路径(文件路径;若为已存在目录则自动追加文件名)"),
203
+ overwrite: z
204
+ .boolean()
205
+ .optional()
206
+ .describe("true 则忽略本地已有部分、从头覆盖;默认 false(自动断点续传)"),
207
+ },
208
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
209
+ }, async ({ server: serverName, remote_path, local_path, overwrite }) => {
210
+ const { servers, error } = loadServersOrError();
211
+ if (!servers)
212
+ return { content: [{ type: "text", text: `读取服务器配置失败:${error}` }], isError: true };
213
+ const cfg = servers.get(serverName);
214
+ if (!cfg) {
215
+ const names = [...servers.keys()].join(", ") || "(无)";
216
+ return { content: [{ type: "text", text: `未找到名为 "${serverName}" 的服务器。可用:${names}` }], isError: true };
217
+ }
218
+ try {
219
+ const t = await startTransfer(cfg, "download", local_path, remote_path, overwrite ?? false);
220
+ const hint = t.state === "completed"
221
+ ? "\n(目标已是完整文件,无需传输。)"
222
+ : `\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("transfer_status", {
230
+ title: "查看文件传输进度",
231
+ description: "查询后台文件传输任务的进度(已传字节、百分比、速度、预计剩余时间、状态)。" +
232
+ "传入 id 查看单个任务;不传则列出本次会话的全部任务。",
233
+ inputSchema: {
234
+ id: z.string().optional().describe("传输任务 id;不填则列出全部任务"),
235
+ },
236
+ annotations: { readOnlyHint: true, openWorldHint: false },
237
+ }, async ({ id }) => {
238
+ if (id) {
239
+ const t = getTransfer(id);
240
+ if (!t)
241
+ return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
242
+ return { content: [{ type: "text", text: formatTransfer(t) }] };
243
+ }
244
+ const all = listTransfers();
245
+ const text = all.length > 0 ? all.map(formatTransfer).join("\n\n") : "当前没有传输任务。";
246
+ return { content: [{ type: "text", text }] };
247
+ });
248
+ server.registerTool("cancel_transfer", {
249
+ title: "取消文件传输",
250
+ description: "取消一个正在进行的后台传输任务。已传输的部分文件会保留,之后可用同样的路径再次发起以断点续传。",
251
+ inputSchema: {
252
+ id: z.string().describe("要取消的传输任务 id"),
253
+ },
254
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
255
+ }, async ({ id }) => {
256
+ const t = cancelTransfer(id);
257
+ if (!t)
258
+ return { content: [{ type: "text", text: `未找到传输任务:${id}` }], isError: true };
259
+ return { content: [{ type: "text", text: `已请求取消:\n${formatTransfer(t)}` }] };
260
+ });
261
+ async function main() {
262
+ const transport = new StdioServerTransport();
263
+ await server.connect(transport);
264
+ // 日志走 stderr,避免污染 stdio 上的 MCP 协议数据。
265
+ console.error(`ssh-mcp 已启动,配置文件:${configPath()}`);
266
+ }
267
+ main().catch((e) => {
268
+ console.error("ssh-mcp 启动失败:", e);
269
+ process.exit(1);
270
+ });
package/dist/ssh.js ADDED
@@ -0,0 +1,97 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { Client } from "ssh2";
3
+ import { expandHome } from "./config.js";
4
+ /** 根据服务器配置组装 ssh2 的连接参数,挑选鉴权方式。 */
5
+ export function buildConnectConfig(server, readyTimeoutMs) {
6
+ const conf = {
7
+ host: server.host,
8
+ port: server.port ?? 22,
9
+ username: server.username,
10
+ readyTimeout: readyTimeoutMs,
11
+ };
12
+ if (server.privateKeyPath) {
13
+ // 私钥登录优先级最高。
14
+ conf.privateKey = readFileSync(expandHome(server.privateKeyPath));
15
+ if (server.passphrase)
16
+ conf.passphrase = server.passphrase;
17
+ }
18
+ else if (server.password) {
19
+ // 其次是密码登录。
20
+ conf.password = server.password;
21
+ }
22
+ else if (process.env.SSH_AUTH_SOCK) {
23
+ // 最后退回到本机 ssh-agent。
24
+ conf.agent = process.env.SSH_AUTH_SOCK;
25
+ }
26
+ return conf;
27
+ }
28
+ /** 建立一条已就绪的 SSH 连接。命令执行和文件传输共用这一条连接路径。 */
29
+ export function createConnection(server, readyTimeoutMs) {
30
+ return new Promise((resolve, reject) => {
31
+ const conn = new Client();
32
+ let settled = false;
33
+ conn.on("ready", () => {
34
+ if (!settled) {
35
+ settled = true;
36
+ resolve(conn);
37
+ }
38
+ });
39
+ conn.on("error", (err) => {
40
+ if (!settled) {
41
+ settled = true;
42
+ reject(err);
43
+ }
44
+ });
45
+ try {
46
+ conn.connect(buildConnectConfig(server, readyTimeoutMs));
47
+ }
48
+ catch (e) {
49
+ // 读取私钥失败等同步错误。
50
+ if (!settled) {
51
+ settled = true;
52
+ reject(e);
53
+ }
54
+ }
55
+ });
56
+ }
57
+ /**
58
+ * 在指定服务器上执行一条命令,收集 stdout/stderr/退出码后返回。
59
+ *
60
+ * 设计取舍:每次调用建立一条独立连接,执行完即断开。SSH 的 exec channel 本就
61
+ * 无状态,命令之间不会保留工作目录或环境变量——需要时请用 `cd x && cmd` 自行串接。
62
+ */
63
+ export async function runCommand(server, command, timeoutMs) {
64
+ const conn = await createConnection(server, timeoutMs);
65
+ return new Promise((resolve, reject) => {
66
+ let settled = false;
67
+ const finish = (fn) => {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ clearTimeout(timer);
72
+ fn();
73
+ conn.end();
74
+ };
75
+ const timer = setTimeout(() => {
76
+ finish(() => reject(new Error(`命令执行超时(${timeoutMs}ms)`)));
77
+ }, timeoutMs);
78
+ conn.on("error", (err) => finish(() => reject(err)));
79
+ conn.exec(command, (err, stream) => {
80
+ if (err) {
81
+ finish(() => reject(err));
82
+ return;
83
+ }
84
+ let stdout = "";
85
+ let stderr = "";
86
+ stream.on("data", (d) => {
87
+ stdout += d.toString("utf8");
88
+ });
89
+ stream.stderr.on("data", (d) => {
90
+ stderr += d.toString("utf8");
91
+ });
92
+ stream.on("close", (code, signal) => {
93
+ finish(() => resolve({ stdout, stderr, code, signal }));
94
+ });
95
+ });
96
+ });
97
+ }
@@ -0,0 +1,179 @@
1
+ import { createReadStream, createWriteStream, statSync } from "node:fs";
2
+ import { basename } from "node:path";
3
+ import { posix as posixPath } from "node:path";
4
+ import { createConnection } from "./ssh.js";
5
+ // 任务注册表只活在内存里。续传不依赖它——真正的事实来源是磁盘上文件的实际大小,
6
+ // 所以即便进程重启、注册表清空,再次发起同一对路径的传输也能自动续上。
7
+ const transfers = new Map();
8
+ const handles = new Map();
9
+ let counter = 0;
10
+ function statRemote(sftp, path) {
11
+ return new Promise((resolve) => {
12
+ sftp.stat(path, (err, stats) => resolve(err ? null : stats));
13
+ });
14
+ }
15
+ function statLocal(path) {
16
+ try {
17
+ const s = statSync(path);
18
+ return { size: s.size, isDir: s.isDirectory() };
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function openSftp(conn) {
25
+ return new Promise((resolve, reject) => {
26
+ conn.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)));
27
+ });
28
+ }
29
+ /**
30
+ * 发起一次后台文件传输并立即返回任务记录。调用方应随后用 getTransfer/listTransfers 轮询进度。
31
+ *
32
+ * 续传逻辑:根据目标当前大小决定起始偏移量。overwrite=true 时从头覆盖。
33
+ * 若目标是已存在的目录,则自动在其下追加源文件名。
34
+ */
35
+ export async function startTransfer(cfg, direction, localPath, remotePath, overwrite) {
36
+ const conn = await createConnection(cfg, 20_000);
37
+ let sftp;
38
+ try {
39
+ sftp = await openSftp(conn);
40
+ }
41
+ catch (e) {
42
+ conn.end();
43
+ throw e;
44
+ }
45
+ let totalBytes;
46
+ let startOffset;
47
+ let localFile = localPath;
48
+ let remoteFile = remotePath;
49
+ try {
50
+ if (direction === "upload") {
51
+ const lst = statLocal(localPath);
52
+ if (!lst)
53
+ throw new Error(`本地文件不存在:${localPath}`);
54
+ if (lst.isDir)
55
+ throw new Error(`本地路径是目录,请指定文件:${localPath}`);
56
+ totalBytes = lst.size;
57
+ const rst = await statRemote(sftp, remotePath);
58
+ if (rst?.isDirectory()) {
59
+ // 远程是目录 → 在其下追加本地文件名。
60
+ remoteFile = posixPath.join(remotePath, basename(localPath));
61
+ const r2 = await statRemote(sftp, remoteFile);
62
+ startOffset = overwrite ? 0 : r2?.size ?? 0;
63
+ }
64
+ else {
65
+ startOffset = overwrite ? 0 : rst?.size ?? 0;
66
+ }
67
+ }
68
+ else {
69
+ const rst = await statRemote(sftp, remotePath);
70
+ if (!rst)
71
+ throw new Error(`远程文件不存在:${remotePath}`);
72
+ if (rst.isDirectory())
73
+ throw new Error(`远程路径是目录,请指定文件:${remotePath}`);
74
+ totalBytes = rst.size;
75
+ const lst = statLocal(localPath);
76
+ if (lst?.isDir) {
77
+ // 本地是目录 → 在其下追加远程文件名。
78
+ localFile = posixPath.join(localPath, basename(remotePath));
79
+ const l2 = statLocal(localFile);
80
+ startOffset = overwrite ? 0 : l2?.size ?? 0;
81
+ }
82
+ else {
83
+ startOffset = overwrite ? 0 : lst?.size ?? 0;
84
+ }
85
+ }
86
+ }
87
+ catch (e) {
88
+ conn.end();
89
+ throw e;
90
+ }
91
+ if (startOffset > totalBytes) {
92
+ conn.end();
93
+ throw new Error(`目标已有 ${startOffset} 字节,超过源文件的 ${totalBytes} 字节,文件可能不一致。` +
94
+ `请用 overwrite=true 重新传输。`);
95
+ }
96
+ const id = `t${++counter}`;
97
+ const now = Date.now();
98
+ const t = {
99
+ id,
100
+ server: cfg.name,
101
+ direction,
102
+ localPath: localFile,
103
+ remotePath: remoteFile,
104
+ totalBytes,
105
+ startOffset,
106
+ transferredBytes: startOffset,
107
+ state: "running",
108
+ startedAt: now,
109
+ updatedAt: now,
110
+ };
111
+ transfers.set(id, t);
112
+ // 目标已经完整,无需传输。
113
+ if (startOffset === totalBytes) {
114
+ t.state = "completed";
115
+ t.updatedAt = Date.now();
116
+ conn.end();
117
+ return t;
118
+ }
119
+ const flags = overwrite ? "w" : "a";
120
+ const read = direction === "download"
121
+ ? sftp.createReadStream(remoteFile, { start: startOffset })
122
+ : createReadStream(localFile, { start: startOffset });
123
+ const write = direction === "download"
124
+ ? createWriteStream(localFile, { flags })
125
+ : sftp.createWriteStream(remoteFile, { flags });
126
+ const cleanup = () => {
127
+ read.destroy();
128
+ write.destroy();
129
+ conn.end();
130
+ handles.delete(id);
131
+ };
132
+ handles.set(id, () => {
133
+ read.destroy();
134
+ write.destroy();
135
+ conn.end();
136
+ });
137
+ read.on("data", (chunk) => {
138
+ t.transferredBytes += chunk.length;
139
+ t.updatedAt = Date.now();
140
+ });
141
+ const onError = (err) => {
142
+ if (t.state === "running") {
143
+ t.state = "failed";
144
+ t.error = err.message;
145
+ t.updatedAt = Date.now();
146
+ }
147
+ cleanup();
148
+ };
149
+ read.on("error", onError);
150
+ write.on("error", onError);
151
+ write.on("finish", () => {
152
+ if (t.state === "running") {
153
+ t.state = "completed";
154
+ t.transferredBytes = totalBytes;
155
+ t.updatedAt = Date.now();
156
+ }
157
+ cleanup();
158
+ });
159
+ read.pipe(write);
160
+ return t;
161
+ }
162
+ export function getTransfer(id) {
163
+ return transfers.get(id);
164
+ }
165
+ export function listTransfers() {
166
+ return [...transfers.values()];
167
+ }
168
+ export function cancelTransfer(id) {
169
+ const t = transfers.get(id);
170
+ if (!t)
171
+ return undefined;
172
+ if (t.state === "running") {
173
+ t.state = "cancelled";
174
+ t.updatedAt = Date.now();
175
+ handles.get(id)?.();
176
+ handles.delete(id);
177
+ }
178
+ return t;
179
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@bingzi-233/ssh-mcp",
3
+ "version": "1.0.0",
4
+ "description": "通过 SSH/SFTP 让 AI 在多台远程服务器上执行命令并传输大文件(支持断点续传)的 MCP 服务器",
5
+ "type": "module",
6
+ "bin": {
7
+ "ssh-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "dev": "tsc --watch",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "ssh",
25
+ "sftp",
26
+ "remote",
27
+ "file-transfer",
28
+ "claude"
29
+ ],
30
+ "author": "BingZi-233",
31
+ "license": "MIT",
32
+ "homepage": "https://github.com/BingZi-233/ssh-mcp#readme",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/BingZi-233/ssh-mcp.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/BingZi-233/ssh-mcp/issues"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.12.0",
45
+ "ssh2": "^1.16.0",
46
+ "zod": "^3.23.8"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.0.0",
50
+ "@types/ssh2": "^1.15.0",
51
+ "typescript": "^5.6.0"
52
+ }
53
+ }