commit b889154bb3d11d8c5d7816a9fe269e8d634d8b6d Author: Max Shcheglov Date: Tue Sep 1 10:54:20 2026 +0700 feat: add dynamic per-call SSH connections and secret redaction - execute-command/upload/download accept host/port/username/password inline to open an ephemeral connection for a single call - add redactSecret/redactSecrets to mask credentials in logs and errors - logger log()/handleError() accept optional secrets[] for redaction - add SSH_CONFIG_MISSING error code and --password-from-env CLI flag - dynamic-mode startup: server runs with no static config diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c709fba --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# 依赖目录 +node_modules/ +package-lock.json + +# 构建输出 +build/ + +# 操作系统文件 +.DS_Store +Thumbs.db + +# 编辑器和IDE文件 +.idea/ +.vscode/ +*.swp +*.swo +.project +.classpath +.settings/ +memo.md + +# claude +.claude/ +.mcp.json +docs + +# codex +.codex/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..963ce2f --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2025 junki.cn + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcb109c --- /dev/null +++ b/README.md @@ -0,0 +1,624 @@ +
+ +ssh-mcp-server logo + +# ssh-mcp-server + +![NPM Version](https://img.shields.io/npm/v/%40fangjunjie%2Fssh-mcp-server?label=%40fangjunjie%2Fssh-mcp-server) +![GitHub forks](https://img.shields.io/github/forks/classfang/ssh-mcp-server) +![GitHub Repo stars](https://img.shields.io/github/stars/classfang/ssh-mcp-server) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/classfang/ssh-mcp-server) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-closed/classfang/ssh-mcp-server) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr/classfang/ssh-mcp-server) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr-closed/classfang/ssh-mcp-server) + +基于 SSH 的 MCP (Model Context Protocol) 服务器,允许通过 MCP 协议远程执行 SSH 命令。 + +[English Document](README_EN.md) | 中文文档 + +
+ +## 📝 项目介绍 + +ssh-mcp-server 是一个桥接工具,可以让 AI 助手等支持 MCP 协议的应用通过标准化接口执行远程 SSH 命令。这使得 AI 助手能够安全地操作远程服务器,执行命令并获取结果,而无需直接暴露 SSH 凭据给 AI 模型。 + +💬 如有任何问题,欢迎加入微信群交流: + +wechat + +## ✨ 功能亮点 + +- **🔒 安全连接**:支持多种安全的 SSH 连接方式,包括密码认证和私钥认证(支持带密码的私钥) +- **🛡️ 命令安全控制**:通过灵活的黑白名单机制,精确控制允许执行的命令范围,防止危险操作 +- **🔄 标准化接口**:符合 MCP 协议规范,与支持该协议的 AI 助手无缝集成 +- **🚇 双传输模式**:同时支持 `exec` 和 `shell` 两种 transport,兼容直连主机与堡垒机或跳板机场景 +- **📂 文件传输**:支持双向文件传输功能,可上传本地文件到服务器或从服务器下载文件 +- **🔑 凭据隔离**:SSH 凭据完全在本地管理,不会暴露给 AI 模型,增强安全性 +- **🚀 即用即走**:使用 NPX 可直接运行,无需全局安装,方便快捷 + +## 📦 开源仓库 + +GitHub:[https://github.com/classfang/ssh-mcp-server](https://github.com/classfang/ssh-mcp-server) + +NPM: [https://www.npmjs.com/package/@fangjunjie/ssh-mcp-server](https://www.npmjs.com/package/@fangjunjie/ssh-mcp-server) + +## 🛠️ 工具列表 + +| 工具 | 名称 | 描述 | +|---------|-----------|----------| +| execute-command | 命令执行工具 | 在远程服务器上执行 SSH 命令并获取执行结果 | +| upload | 文件上传工具 | 将本地文件上传到远程服务器指定位置 | +| download | 文件下载工具 | 从远程服务器下载文件到本地指定位置 | +| list-servers | 服务器列表工具 | 列出所有可用SSH服务器配置 | + +## 📚 使用方法 + +### 0. 🤖 通过 AI Skill 快速配置(推荐) + +如果你使用支持 skill 的 AI 编程助手(如 Claude Code),可以直接使用内置的 **ssh-mcp-helper** skill 通过交互式问答完成安装和配置,无需手动编辑 JSON 文件。 + +**使用方式:** + +1. 从本仓库 `skills/` 目录安装该 skill +2. 告诉你的 AI 助手:"帮我配置 ssh-mcp-server" 或 "给 Cursor 加一个 SSH MCP 连接" +3. skill 会逐步引导你:检查 Node.js 环境 → 选择 MCP 客户端 → 选择认证方式 → 收集连接参数 → 生成并写入配置 + +该 skill 支持下文所有场景(账号密码、私钥、SSH config 复用、SOCKS 代理、堡垒机、多连接、2FA、命令限制等),并自动生成格式正确的配置。 + +--- + +下面的章节按从简单到复杂的顺序排列,最简单的入门方式就是用账号密码连接服务器。直接复制对应场景下的 `mcp.json` 配置到你的 MCP 客户端即可使用。 + +> **⚠️ 重要提示**:在 MCP 配置文件中,每个命令行参数和其值必须是 `args` 数组中的独立元素。不要用空格将它们连接在一起。例如,使用 `"--host", "192.168.1.1"` 而不是 `"--host 192.168.1.1"`。 + +### 1. 🔑 账号密码(最简单) + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456" + ] + } + } +} +``` + +### 2. 🔐 账号 + 私钥 + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--privateKey", "~/.ssh/id_rsa" + ] + } + } +} +``` + +### 3. 🔏 带密码的私钥 + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--privateKey", "~/.ssh/id_rsa", + "--passphrase", "pwd123456" + ] + } + } +} +``` + +### 4. 📋 复用 `~/.ssh/config` + +如果你已经在 `~/.ssh/config` 配置了主机别名,服务器会自动从中读取连接参数,`mcp.json` 里就不用再写一遍。 + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "myserver" + ] + } + } +} +``` + +假设你的 `~/.ssh/config` 包含: + +``` +Host myserver + HostName 192.168.1.1 + Port 22 + User root + IdentityFile ~/.ssh/id_rsa +``` + +你也可以指定自定义的 SSH 配置文件路径: + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "myserver", + "--ssh-config-file", "/path/to/custom/ssh_config" + ] + } + } +} +``` + +**注意**:命令行参数优先级高于 SSH 配置值。例如,如果你指定了 `--port 2222`,它会覆盖 SSH 配置中的端口。 + +### 5. 🌐 通过代理连接 + +当目标主机只能通过代理访问时,可使用 `--proxy` 配置 SOCKS5、HTTP 或 HTTPS 代理。 + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456", + "--proxy", "http://username:password@proxy-host:proxy-port" + ] + } + } +} +``` + +支持的 URL 格式: + +```text +socks://username:password@proxy-host:1080 +socks5://username:password@proxy-host:1080 +http://username:password@proxy-host:8080 +https://username:password@proxy-host:8443 +``` + +HTTP 和 HTTPS 代理通过 `CONNECT` 方法建立到 SSH 服务的隧道,用户名和密码使用 Basic 代理认证。HTTP、HTTPS 未填写端口时分别默认使用 `80`、`443`;SOCKS5 必须填写端口。HTTPS 代理证书使用 Node.js 默认信任链进行验证。 + +原有 `socksProxy` 配置和 `--socksProxy` 参数继续兼容,但只接受 `socks://` 和 `socks5://`。不要同时配置 `proxy` 和 `socksProxy`。 + +### 6. 📝 使用命令白名单 / 黑名单 + +通过 `--whitelist` 和 `--blacklist` 限制服务器允许执行的命令范围。多个模式之间用逗号分隔,每个模式都是一个正则表达式。**生产环境强烈建议配置**。 + +白名单示例(仅允许只读型查看命令): + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456", + "--whitelist", "^ls( .*)?,^cat .*,^df.*" + ] + } + } +} +``` + +黑名单示例(屏蔽危险命令): + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456", + "--blacklist", "^rm .*,^shutdown.*,^reboot.*" + ] + } + } +} +``` + +> 注意:如果同时指定了白名单和黑名单,系统会先检查命令是否在白名单中,再检查是否在黑名单中,命令必须同时通过两项检查才能被执行。 + +### 7. 🧩 使用命令模板包裹命令 + +`commandTemplate` 会把每条执行的命令套进一个模板里,适合切换用户(`su`)、放进容器、或经过跳板机的场景。当命令会作为 shell 参数传入时使用 ``,需要原样插入时使用 ``;模板会**在目录 `cd` 拼接之后**应用,因此整个 `cd ... && <实际命令>` 都会被包裹起来。 + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "10.0.0.1", + "--port", "22", + "--username", "deploy", + "--password", "xxx", + "--command-template", "su root -c " + ] + } + } +} +``` + +当指定目录为 `/data` 执行 `ls /app` 时,实际发送的命令是: + +``` +su root -c 'cd -- '\''/data'\'' && ls /app' +``` + +其他常见模板: + +```text +sudo bash -c +docker exec -i mycontainer sh -c +ssh jumphost +``` + +### 8. 🚇 堡垒机 / 跳板机(`transportMode: shell`) + +`transportMode` 默认是 `exec`。出现下面这些情况时,应该切换到 `shell`: + +- SSH 登录成功,但 `exec` 执行命令失败 +- 远端必须等登录 banner、profile、环境初始化完成后才能正常执行命令 +- 连接目标本质上是堡垒机或只暴露交互式 shell 的设备 + +两者差异: + +- `exec`:支持 `execute-command`、`upload`、`download` +- `shell`:命令通过持久 shell 会话串行执行,内部带命令队列;但**不支持** `upload` / `download`,因为该模式下禁用了 SFTP + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "bastion.example.com", + "--port", "22", + "--username", "ops", + "--password", "pwd123456", + "--transport-mode", "shell", + "--shell-ready-timeout", "15000" + ] + } + } +} +``` + +JSON 配置文件中还可以通过 `shellCommandTimeoutMs` 覆盖 shell 模式下单条命令的默认超时。 + +### 9. 🔐 多因素认证(2FA / MFA) + +当 SSH 服务器要求多因素认证(密码 + 私钥 + 2FA 验证码)时启用 `tryKeyboard`。密码和私钥会自动提供;对于非密码提示,请在连接前通过服务端环境变量 `SSH_MCP_2FA_CODE` 提供验证码。 + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "example.com", + "--port", "22", + "--username", "user", + "--password", "your_password", + "--privateKey", "/path/to/key", + "--try-keyboard" + ] + } + } +} +``` + +**认证流程:** +1. 私钥认证(如果提供) +2. 密码认证(如果提供) +3. 键盘交互式认证通过 `SSH_MCP_2FA_CODE` 提供 2FA 验证码 + +### 10. 🧩 多 SSH 连接配置 + +需要在同一个 MCP server 里同时管理多个 SSH 目标时,给每个连接命名,调用时通过 `connectionName` 选择。共有三种配置方式: + +#### 📄 方式一:使用配置文件(推荐) + +创建 JSON 配置文件(例如 `ssh-config.json`): + +**数组格式:** + +```json +[ + { + "name": "dev", + "host": "1.2.3.4", + "port": 22, + "username": "alice", + "password": "{abc=P100s0}", + "socksProxy": "socks://127.0.0.1:10808", + "commandTimeoutMs": 120000, + "maxOutputBytes": 10485760 + }, + { + "name": "bastion", + "host": "9.9.9.9", + "port": 22, + "username": "ops", + "password": "pwd123456", + "transportMode": "shell", + "shellReadyTimeoutMs": 15000, + "shellCommandTimeoutMs": 45000, + "connectionTimeoutMs": 30000, + "keepaliveIntervalMs": 10000, + "keepaliveCountMax": 3 + }, + { + "name": "prod", + "host": "5.6.7.8", + "port": 22, + "username": "bob", + "password": "yyy", + "socksProxy": "socks://127.0.0.1:10808" + }, + { + "name": "secure-server", + "host": "secure.example.com", + "port": 22, + "username": "admin", + "password": "your_password", + "privateKey": "/path/to/private/key", + "tryKeyboard": true + } +] +``` + +**对象格式:** + +```json +{ + "dev": { + "host": "1.2.3.4", + "port": 22, + "username": "alice", + "password": "{abc=P100s0}", + "socksProxy": "socks://127.0.0.1:10808", + "commandTimeoutMs": 120000, + "maxOutputBytes": 10485760 + }, + "bastion": { + "host": "9.9.9.9", + "port": 22, + "username": "ops", + "password": "pwd123456", + "transportMode": "shell", + "shellReadyTimeoutMs": 15000, + "shellCommandTimeoutMs": 45000 + }, + "prod": { + "host": "5.6.7.8", + "port": 22, + "username": "bob", + "password": "yyy", + "socksProxy": "socks://127.0.0.1:10808" + } +} +``` + +然后使用 `--config-file` 参数: + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--config-file", "ssh-config.json" + ] + } + } +} +``` + +#### 🔧 方式二:使用 JSON 格式的 --ssh 参数 + +可以直接传递 JSON 格式的配置字符串: + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--ssh", "{\"name\":\"dev\",\"host\":\"1.2.3.4\",\"port\":22,\"username\":\"alice\",\"password\":\"{abc=P100s0}\",\"socksProxy\":\"socks://127.0.0.1:10808\"}", + "--ssh", "{\"name\":\"bastion\",\"host\":\"9.9.9.9\",\"port\":22,\"username\":\"ops\",\"password\":\"pwd123456\",\"transportMode\":\"shell\",\"shellReadyTimeoutMs\":15000}", + "--ssh", "{\"name\":\"prod\",\"host\":\"5.6.7.8\",\"port\":22,\"username\":\"bob\",\"password\":\"yyy\",\"socksProxy\":\"socks://127.0.0.1:10808\"}" + ] + } + } +} +``` + +#### 📝 方式三:旧格式逗号分隔(向后兼容) + +对于密码中不包含特殊字符的简单情况,仍可使用旧格式: + +```bash +npx @fangjunjie/ssh-mcp-server \ + --ssh "name=dev,host=1.2.3.4,port=22,user=alice,password=xxx" \ + --ssh "name=prod,host=5.6.7.8,port=22,user=bob,password=yyy" +``` + +> **⚠️ 注意**:旧格式在处理包含特殊字符(如 `=`、`,`、`{`、`}`)的密码时可能会有问题。如果密码包含特殊字符,请使用方式一或方式二。 + +在MCP工具调用时,通过 `connectionName` 参数指定目标连接名称,未指定时使用默认连接。 + +示例(在prod连接上执行命令): + +```json +{ + "tool": "execute-command", + "params": { + "cmdString": "ls -al", + "connectionName": "prod" + } +} +``` + +示例(带超时选项的命令执行): + +```json +{ + "tool": "execute-command", + "params": { + "cmdString": "ping -c 10 127.0.0.1", + "connectionName": "prod", + "timeout": 5000 + } +} +``` + +### ⏱️ 命令执行超时 + +`execute-command` 工具支持超时选项,防止命令无限期挂起: + +- **timeout**: 单次调用的命令执行超时时间(毫秒,可选);传入时会覆盖连接配置,未传入时使用对应连接配置或其 30000ms 默认值 +- 在 JSON 配置文件里为单个连接设置 `commandTimeoutMs`,可以改掉这个默认值,避免每次调用都手动传 `timeout`(`exec` 模式) +- `shell` 模式对应的配置项是 `shellCommandTimeoutMs` +- 调用参数里的 `timeout` 始终优先于上面两个配置项 +- 连接默认启用 SSH keepalive(`keepaliveIntervalMs`: 10000,`keepaliveCountMax`: 3),并使用 `connectionTimeoutMs` 限制连接建立时间 +- SFTP 打开和传输操作使用 `sftpTimeoutMs` 控制超时(默认 300000ms) +- 错误响应现在包含稳定的 `code`、`message`、`retriable` 字段,便于上层 Agent 处理 + +这对于像 `ping`、`tail -f` 或其他可能阻塞执行的长时间运行进程特别有用。 + +### 📦 命令输出限制 + +会限制单条命令捕获的 `stdout` 和 `stderr` 总量,避免大文件或无限输出耗尽 MCP server 内存: + +- 在 JSON 连接配置中使用 `maxOutputBytes` 设置上限,默认值为 `10485760`(10 MiB) +- `maxOutputBytes` 必须是非负整数;设置为 `0` 可禁用限制,但不建议对不受信任的命令禁用 +- 输出超过限制时,远端命令会被中止,工具返回 `OUTPUT_LIMIT_EXCEEDED` 错误和已经捕获的截断输出,不会把中止的命令误报为成功 +- 当 `pty` 为 `false` 时,成功命令写入 `stderr` 的警告或进度信息会保留在 `[stderr]` 区段中 +- `exec` 与 `shell` 两种模式都会应用该限制。区别在于 `exec` 模式只关闭该命令的通道,而 `shell` 模式的通道由该连接上的所有命令共用、远端在中止后仍会继续写入,因此会断开连接(与 shell 模式命令超时的处理一致) + +### 🗂️ 列出所有SSH服务器 + +可以通过MCP工具 `list-servers` 获取所有可用的SSH服务器配置: + +调用示例: + +```json +{ + "tool": "list-servers", + "params": {} +} +``` + +返回示例: + +```json +[ + { "name": "dev", "host": "1.2.3.4", "port": 22, "username": "alice" }, + { "name": "prod", "host": "5.6.7.8", "port": 22, "username": "bob" } +] +``` + +### ⚙️ 命令行选项参考 + +```text +选项: + --config-file JSON 配置文件路径(推荐用于多服务器配置) + --ssh-config-file SSH 配置文件路径(默认: ~/.ssh/config) + --ssh SSH 连接配置(可以是 JSON 字符串或旧格式) + -h, --host SSH 服务器主机地址或 SSH 配置中的别名 + -p, --port SSH 服务器端口 + -u, --username SSH 用户名 + -w, --password SSH 密码 + -k, --privateKey SSH 私钥文件路径 + -P, --passphrase 私钥密码(如果有的话) + -a, --agent SSH agent socket 路径 + --try-keyboard 启用键盘交互式认证以支持 2FA/MFA(默认: false) + -W, --whitelist 命令白名单,以逗号分隔的正则表达式 + -B, --blacklist 命令黑名单,以逗号分隔的正则表达式 + --proxy 代理地址,支持 SOCKS5、HTTP 和 HTTPS + -s, --socksProxy 旧版 SOCKS5 代理地址(兼容参数) + --allowed-local-paths upload/download 允许访问的额外本地路径,逗号分隔 + --allowed-remote-paths SFTP upload/download 允许访问的远端路径(POSIX 绝对路径),逗号分隔 + --transport-mode SSH transport 模式: exec 或 shell(默认: exec) + --shell-ready-timeout shell 就绪探测超时,单位毫秒(默认: 10000) + --command-template 命令模板;shell 参数用 ,原样插入用 + --pty 为命令执行分配伪终端(默认: true) + --pre-connect 启动时预连接所有配置的 SSH 服务器 + --version, -v 打印包版本 + --help 打印帮助信息 +``` + +## 🛡️ 安全注意事项 + +该服务器提供了在远程服务器上执行命令和传输文件的强大功能。为确保安全使用,请注意以下几点: + +- **命令白名单**:*强烈建议* 使用 `--whitelist` 选项来限制可执行的命令集合。如果没有白名单,任何命令都可以在远程服务器上执行,这可能带来重大的安全风险。 +- **私钥安全**:服务器会将 SSH 私钥读入内存。请确保运行 `ssh-mcp-server` 的机器是安全的。不要将服务器暴露给不受信任的网络。 +- **拒绝服务攻击 (DoS)**:服务器没有内置的速率限制。攻击者可能通过向服务器发送大量连接请求或大文件传输来发起 DoS 攻击。建议在具有速率限制功能的防火墙或反向代理后面运行服务器。 +- **路径遍历**:服务器内置了对本地文件系统路径遍历攻击的保护。但是,仍然需要注意在 `upload` 和 `download` 命令中使用的路径。 +- **本地传输范围**:默认仅允许访问当前工作目录。只有在明确可信时,才建议通过 `--allowed-local-paths` 或配置文件中的 `allowedLocalPaths` 放宽范围。 +- **远端传输范围**:SFTP upload/download 仅接受绝对 POSIX 路径。未配置 `allowedRemotePaths`(或 `--allowed-remote-paths`)时,任意远端路径都允许,但启动时会打印警告。强烈建议显式配置 `allowedRemotePaths` 白名单,避免模型被 prompt 注入后读写 `~/.ssh/authorized_keys`、`/etc/sshd_config` 之类敏感文件。 + +## 🌟 Star 历史 + +## Star History + +[![Star History Chart](https://api.star-history.com/chart?repos=classfang/ssh-mcp-server&type=date&legend=top-left&sealed_token=ndORao73xOZgyX7IvlIIOynMoeEP5Ds9YAG-zOfMMBlNepLdP3e7T7k9K94X8TdvuxplN5DXLolbF9jFFsYDD-1V0V8HO6B3swaPOvJaonKeiFNdAuWsXg)](https://www.star-history.com/?type=date&legend=top-left&repos=classfang%2Fssh-mcp-server) diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..a8f8bd6 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,618 @@ +
+ +ssh-mcp-server logo + +# ssh-mcp-server + +![NPM Version](https://img.shields.io/npm/v/%40fangjunjie%2Fssh-mcp-server?label=%40fangjunjie%2Fssh-mcp-server) +![GitHub forks](https://img.shields.io/github/forks/classfang/ssh-mcp-server) +![GitHub Repo stars](https://img.shields.io/github/stars/classfang/ssh-mcp-server) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/classfang/ssh-mcp-server) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-closed/classfang/ssh-mcp-server) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr/classfang/ssh-mcp-server) +![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr-closed/classfang/ssh-mcp-server) + +SSH-based MCP (Model Context Protocol) server that allows remote execution of SSH commands via the MCP protocol. + +English Document | [中文文档](README.md) + +
+ +## 📝 Project Overview + +ssh-mcp-server is a bridging tool that enables AI assistants and other applications supporting the MCP protocol to execute remote SSH commands through a standardized interface. This allows AI assistants to safely operate remote servers, execute commands, and retrieve results without directly exposing SSH credentials to AI models. + +## ✨ Key Features + +- **🔒 Secure Connections**: Supports multiple secure SSH connection methods, including password authentication and private key authentication (with passphrase support) +- **🛡️ Command Security Control**: Precisely control the range of allowed commands through flexible blacklist and whitelist mechanisms to prevent dangerous operations +- **🔄 Standardized Interface**: Complies with MCP protocol specifications for seamless integration with AI assistants supporting the protocol +- **🚇 Dual Transport Modes**: Supports both `exec` and `shell` transport modes for direct SSH hosts and bastion or jump-host scenarios +- **📂 File Transfer**: Supports bidirectional file transfers, uploading local files to servers or downloading files from servers +- **🔑 Credential Isolation**: SSH credentials are managed entirely locally and never exposed to AI models, enhancing security +- **🚀 Ready to Use**: Can be run directly using NPX without global installation, making it convenient and quick to deploy + +## 📦 Open Source Repository + +GitHub: [https://github.com/classfang/ssh-mcp-server](https://github.com/classfang/ssh-mcp-server) + +NPM: [https://www.npmjs.com/package/@fangjunjie/ssh-mcp-server](https://www.npmjs.com/package/@fangjunjie/ssh-mcp-server) + +## 🛠️ Tools List + +| Tool | Name | Description | +|---------|-----------|----------| +| execute-command | Command Execution Tool | Execute SSH commands on remote servers and get results | +| upload | File Upload Tool | Upload local files to specified locations on remote servers | +| download | File Download Tool | Download files from remote servers to local specified locations | +| list-servers | List Servers Tool | List all available SSH server configurations | + +## 📚 Usage + +### 0. 🤖 Quick Setup via AI Skill (Recommended) + +If you are using an AI coding assistant that supports skills (such as Claude Code), you can use the built-in **ssh-mcp-helper** skill to complete the installation and configuration interactively — no need to manually edit JSON files. + +**How to use:** + +1. Install the skill from this repository's `skills/` directory +2. Tell your AI assistant: "Help me set up ssh-mcp-server" or "Configure SSH MCP for my remote server" +3. The skill will guide you step by step: check Node.js environment → choose MCP client → select authentication method → collect connection parameters → generate and write configuration + +The skill supports all scenarios covered below (password, private key, SSH config reuse, SOCKS proxy, bastion hosts, multi-connection, 2FA, command restrictions, etc.) and automatically produces correctly formatted configuration. + +--- + +The sections below are arranged from the simplest entry point (username + password) to more advanced scenarios. Pick the case that matches yours and copy the `mcp.json` snippet directly into your MCP client configuration. + +> **⚠️ Important**: In MCP configuration files, each command line argument and its value must be separate elements in the `args` array. Do NOT combine them with spaces. For example, use `"--host", "192.168.1.1"` instead of `"--host 192.168.1.1"`. + +### 1. 🔑 Username + Password (simplest) + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456" + ] + } + } +} +``` + +### 2. 🔐 Username + Private Key + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--privateKey", "~/.ssh/id_rsa" + ] + } + } +} +``` + +### 3. 🔏 Private Key with Passphrase + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--privateKey", "~/.ssh/id_rsa", + "--passphrase", "pwd123456" + ] + } + } +} +``` + +### 4. 📋 Reuse `~/.ssh/config` + +If you already have a host alias in `~/.ssh/config`, the server reads connection parameters directly from it — no need to repeat them in `mcp.json`. + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "myserver" + ] + } + } +} +``` + +Assuming your `~/.ssh/config` contains: + +``` +Host myserver + HostName 192.168.1.1 + Port 22 + User root + IdentityFile ~/.ssh/id_rsa +``` + +You can also specify a custom SSH config file path: + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "myserver", + "--ssh-config-file", "/path/to/custom/ssh_config" + ] + } + } +} +``` + +**Note**: Command-line parameters take precedence over SSH config values. For example, if you specify `--port 2222`, it will override the port from SSH config. + +### 5. 🌐 Connecting Through a Proxy + +When the target host is only reachable through a proxy, use `--proxy` with a SOCKS5, HTTP, or HTTPS proxy. + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456", + "--proxy", "http://username:password@proxy-host:proxy-port" + ] + } + } +} +``` + +Supported URL formats: + +```text +socks://username:password@proxy-host:1080 +socks5://username:password@proxy-host:1080 +http://username:password@proxy-host:8080 +https://username:password@proxy-host:8443 +``` + +HTTP and HTTPS proxies use the `CONNECT` method to tunnel to the SSH server, with optional Basic proxy authentication. HTTP and HTTPS default to ports `80` and `443`; SOCKS5 requires an explicit port. HTTPS proxy certificates are verified using the default Node.js trust store. + +The existing `socksProxy` configuration and `--socksProxy` option remain supported for backward compatibility, but only accept `socks://` and `socks5://`. Do not configure both `proxy` and `socksProxy`. + +### 6. 📝 Restricting Commands With Whitelist / Blacklist + +Use `--whitelist` and `--blacklist` to limit which commands the server is allowed to run. Patterns are comma-separated regular expressions. **Strongly recommended** for any production use. + +Whitelist example (only allow read-only inspection commands): + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456", + "--whitelist", "^ls( .*)?,^cat .*,^df.*" + ] + } + } +} +``` + +Blacklist example (block destructive commands): + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456", + "--blacklist", "^rm .*,^shutdown.*,^reboot.*" + ] + } + } +} +``` + +> Note: If both whitelist and blacklist are specified, the command must pass both checks (whitelist first, then blacklist) to be executed. + +### 7. 🧩 Wrapping Commands With a Template + +`commandTemplate` wraps every executed command in a template — useful for switching user via `su`, running inside a container, or jumping through another host. Use `` when the command is passed as a shell argument, or `` for raw insertion. The template is applied **after** the working-directory `cd` is prepended, so the entire `cd ... && ` chain gets wrapped. + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "10.0.0.1", + "--port", "22", + "--username", "deploy", + "--password", "xxx", + "--command-template", "su root -c " + ] + } + } +} +``` + +Executing `ls /app` with directory `/data` actually sends: + +``` +su root -c 'cd -- '\''/data'\'' && ls /app' +``` + +Other useful templates: + +```text +sudo bash -c +docker exec -i mycontainer sh -c +ssh jumphost +``` + +### 8. 🚇 Bastion / Jump Host (`transportMode: shell`) + +`transportMode` defaults to `exec`. Switch to `shell` when: + +- SSH login succeeds but `exec` command execution fails +- The remote side requires shell startup scripts, banners, or environment initialization first +- The target effectively exposes only an interactive shell (bastion hosts, jump hosts, network devices) + +Behavior differences: + +- `exec`: supports `execute-command`, `upload`, and `download` +- `shell`: runs commands through a persistent shell session with an internal command queue, but does **not** support `upload` / `download` because SFTP is unavailable in this mode + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "bastion.example.com", + "--port", "22", + "--username", "ops", + "--password", "pwd123456", + "--transport-mode", "shell", + "--shell-ready-timeout", "15000" + ] + } + } +} +``` + +In JSON config files you can also set `shellCommandTimeoutMs` to override the default per-command timeout for shell-backed connections. + +### 9. 🔐 Multi-Factor Authentication (2FA / MFA) + +When the SSH server requires multi-factor authentication (password + private key + 2FA verification code), enable `tryKeyboard`. The password and private key are auto-supplied. For non-password prompts, set `SSH_MCP_2FA_CODE` in the server environment before connecting. + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "example.com", + "--port", "22", + "--username", "user", + "--password", "your_password", + "--privateKey", "/path/to/key", + "--try-keyboard" + ] + } + } +} +``` + +**Authentication flow:** +1. Private key authentication (if provided) +2. Password authentication (if provided) +3. Keyboard-interactive for 2FA code via `SSH_MCP_2FA_CODE` + +### 10. 🧩 Managing Multiple SSH Connections + +When you need to expose more than one SSH target through the same MCP server, register them under unique connection names and select the target at call time via `connectionName`. There are three ways to configure them: + +#### 📄 Method 1: Using Config File (Recommended) + +Create a JSON configuration file (e.g., `ssh-config.json`): + +**Array Format:** +```json +[ + { + "name": "dev", + "host": "1.2.3.4", + "port": 22, + "username": "alice", + "password": "{abc=P100s0}", + "socksProxy": "socks://127.0.0.1:10808", + "commandTimeoutMs": 120000, + "maxOutputBytes": 10485760 + }, + { + "name": "bastion", + "host": "9.9.9.9", + "port": 22, + "username": "ops", + "password": "pwd123456", + "transportMode": "shell", + "shellReadyTimeoutMs": 15000, + "shellCommandTimeoutMs": 45000, + "connectionTimeoutMs": 30000, + "keepaliveIntervalMs": 10000, + "keepaliveCountMax": 3 + }, + { + "name": "prod", + "host": "5.6.7.8", + "port": 22, + "username": "bob", + "password": "yyy", + "socksProxy": "socks://127.0.0.1:10808" + }, + { + "name": "secure-server", + "host": "secure.example.com", + "port": 22, + "username": "admin", + "password": "your_password", + "privateKey": "/path/to/private/key", + "tryKeyboard": true + } +] +``` + +**Object Format:** +```json +{ + "dev": { + "host": "1.2.3.4", + "port": 22, + "username": "alice", + "password": "{abc=P100s0}", + "socksProxy": "socks://127.0.0.1:10808", + "commandTimeoutMs": 120000, + "maxOutputBytes": 10485760 + }, + "bastion": { + "host": "9.9.9.9", + "port": 22, + "username": "ops", + "password": "pwd123456", + "transportMode": "shell", + "shellReadyTimeoutMs": 15000, + "shellCommandTimeoutMs": 45000 + }, + "prod": { + "host": "5.6.7.8", + "port": 22, + "username": "bob", + "password": "yyy", + "socksProxy": "socks://127.0.0.1:10808" + } +} +``` + +Then use the `--config-file` parameter: + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--config-file", "ssh-config.json" + ] + } + } +} +``` + +#### 🔧 Method 2: Using JSON Format with --ssh Parameter + +You can pass JSON-formatted configuration strings directly: + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--ssh", "{\"name\":\"dev\",\"host\":\"1.2.3.4\",\"port\":22,\"username\":\"alice\",\"password\":\"{abc=P100s0}\",\"socksProxy\":\"socks://127.0.0.1:10808\"}", + "--ssh", "{\"name\":\"bastion\",\"host\":\"9.9.9.9\",\"port\":22,\"username\":\"ops\",\"password\":\"pwd123456\",\"transportMode\":\"shell\",\"shellReadyTimeoutMs\":15000}", + "--ssh", "{\"name\":\"prod\",\"host\":\"5.6.7.8\",\"port\":22,\"username\":\"bob\",\"password\":\"yyy\",\"socksProxy\":\"socks://127.0.0.1:10808\"}" + ] + } + } +} +``` + +#### 📝 Method 3: Legacy Comma-Separated Format (Backward Compatible) + +For simple cases without special characters in passwords, you can still use the legacy format: + +```bash +npx @fangjunjie/ssh-mcp-server \ + --ssh "name=dev,host=1.2.3.4,port=22,user=alice,password=xxx" \ + --ssh "name=prod,host=5.6.7.8,port=22,user=bob,password=yyy" +``` + +> **⚠️ Note**: The legacy format may have issues with passwords containing special characters like `=`, `,`, `{`, `}`. Use Method 1 or Method 2 for passwords with special characters. + +In MCP tool calls, specify the connection name via the `connectionName` parameter. If omitted, the default connection is used. + +Example (execute command on 'prod' connection): + +```json +{ + "tool": "execute-command", + "params": { + "cmdString": "ls -al", + "connectionName": "prod" + } +} +``` + +Example (execute command with timeout options): + +```json +{ + "tool": "execute-command", + "params": { + "cmdString": "ping -c 10 127.0.0.1", + "connectionName": "prod", + "timeout": 5000 + } +} +``` + +### ⏱️ Command Execution Timeout + +The `execute-command` tool supports timeout options to prevent commands from hanging indefinitely: + +- **timeout**: Per-call command execution timeout in milliseconds (optional); when provided, it overrides the connection setting, otherwise the connection setting or its 30000ms default is used +- Set `commandTimeoutMs` per connection in the JSON config file to change that default, so callers do not have to pass `timeout` on every call (`exec` mode) +- The `shell` mode equivalent is `shellCommandTimeoutMs` +- A `timeout` passed with the call always takes precedence over both settings +- Connections use SSH keepalives by default (`keepaliveIntervalMs`: 10000, `keepaliveCountMax`: 3) and respect `connectionTimeoutMs` for connection setup +- SFTP open and transfer operations respect `sftpTimeoutMs` (default 300000ms) +- Error responses include stable `code`, `message`, and `retriable` fields for easier agent-side handling + +This is particularly useful for commands like `ping`, `tail -f`, or other long-running processes that might block execution. + +### 📦 Command Output Limit + +The combined captured `stdout` and `stderr` for each command is limited to protect the MCP server from large files or unbounded output: + +- Set `maxOutputBytes` in a JSON connection configuration; the default is `10485760` bytes (10 MiB) +- `maxOutputBytes` must be a non-negative integer; `0` disables the limit, which is not recommended for untrusted commands +- When output exceeds the limit, the remote command is aborted and the tool returns an `OUTPUT_LIMIT_EXCEEDED` error with the captured, truncated output instead of reporting success +- With `pty: false`, warnings and progress written to `stderr` by successful commands are preserved in a `[stderr]` section +- The limit applies to both `exec` and `shell` mode. `exec` mode closes just that command's channel, whereas the `shell` channel is shared by every command on the connection and the remote keeps writing after an abort, so the connection is dropped instead — the same way a shell mode command timeout behaves + +### 🗂️ List All SSH Servers + +You can use the MCP tool `list-servers` to get all available SSH server configurations: + +Example call: + +```json +{ + "tool": "list-servers", + "params": {} +} +``` + +Example response: + +```json +[ + { "name": "dev", "host": "1.2.3.4", "port": 22, "username": "alice" }, + { "name": "prod", "host": "5.6.7.8", "port": 22, "username": "bob" } +] +``` + +### ⚙️ Command Line Options Reference + +```text +Options: + --config-file JSON configuration file path (recommended for multiple servers) + --ssh-config-file SSH config file path (default: ~/.ssh/config) + --ssh SSH connection configuration (can be JSON string or legacy format) + -h, --host SSH server host address or alias from SSH config + -p, --port SSH server port + -u, --username SSH username + -w, --password SSH password + -k, --privateKey SSH private key file path + -P, --passphrase Private key passphrase (if any) + -a, --agent SSH agent socket path + --try-keyboard Enable keyboard-interactive authentication for 2FA/MFA (default: false) + -W, --whitelist Command whitelist, comma-separated regular expressions + -B, --blacklist Command blacklist, comma-separated regular expressions + --proxy Proxy URL supporting SOCKS5, HTTP, and HTTPS + -s, --socksProxy Legacy SOCKS5 proxy URL + --allowed-local-paths Additional allowed local paths for upload/download, comma-separated + --allowed-remote-paths Allowed remote (POSIX, absolute) paths for SFTP upload/download, comma-separated + --transport-mode SSH transport mode: exec or shell (default: exec) + --shell-ready-timeout Shell readiness probe timeout in milliseconds (default: 10000) + --command-template Command template, use for shell arguments or for raw insertion + --pty Allocate pseudo-tty for command execution (default: true) + --pre-connect Pre-connect to all configured SSH servers on startup + --version, -v Print package version + --help Print this help message +``` + +## 🛡️ Security Considerations + +This server provides powerful capabilities to execute commands and transfer files on remote servers. To ensure it is used securely, please consider the following: + +- **Command Whitelisting**: It is *strongly recommended* to use the `--whitelist` option to restrict the set of commands that can be executed. Without a whitelist, any command can be executed on the remote server, which can be a significant security risk. +- **Private Key Security**: The server reads the SSH private key into memory. Ensure that the machine running the `ssh-mcp-server` is secure. Do not expose the server to untrusted networks. +- **Denial of Service (DoS)**: The server does not have built-in rate limiting. An attacker could potentially launch a DoS attack by flooding the server with connection requests or large file transfers. It is recommended to run the server behind a firewall or reverse proxy with rate-limiting capabilities. +- **Path Traversal**: The server has built-in protection against path traversal attacks on the local filesystem. However, it is still important to be mindful of the paths used in `upload` and `download` commands. +- **Local Transfer Scope**: By default, local file transfers are restricted to the current working directory. Use `--allowed-local-paths` or `allowedLocalPaths` in config only for explicitly trusted directories. +- **Remote Transfer Scope**: SFTP upload/download accepts only absolute POSIX paths. If `allowedRemotePaths` (or `--allowed-remote-paths`) is not configured, any remote path is accepted and the server prints a startup warning. Configure `allowedRemotePaths` to whitelist a small set of remote directories; this is strongly recommended to prevent prompt-injection-driven reads or writes of files like `~/.ssh/authorized_keys` or `/etc/sshd_config`. + +## 🌟 Star History + +## Star History + +[![Star History Chart](https://api.star-history.com/chart?repos=classfang/ssh-mcp-server&type=date&legend=top-left&sealed_token=ndORao73xOZgyX7IvlIIOynMoeEP5Ds9YAG-zOfMMBlNepLdP3e7T7k9K94X8TdvuxplN5DXLolbF9jFFsYDD-1V0V8HO6B3swaPOvJaonKeiFNdAuWsXg)](https://www.star-history.com/?type=date&legend=top-left&repos=classfang%2Fssh-mcp-server) diff --git a/images/ssh-mcp-server-logo-v2.png b/images/ssh-mcp-server-logo-v2.png new file mode 100644 index 0000000..a124871 Binary files /dev/null and b/images/ssh-mcp-server-logo-v2.png differ diff --git a/images/wechat.jpg b/images/wechat.jpg new file mode 100644 index 0000000..dbb5f87 Binary files /dev/null and b/images/wechat.jpg differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd5a2bb --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "@fangjunjie/ssh-mcp-server", + "version": "1.9.0", + "description": "SSH-based MCP Server (基于 SSH 的 MCP 服务器)", + "main": "build/index.js", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/classfang/ssh-mcp-server.git" + }, + "bugs": { + "url": "https://github.com/classfang/ssh-mcp-server/issues" + }, + "homepage": "https://github.com/classfang/ssh-mcp-server#readme", + "bin": { + "ssh-mcp-server": "build/index.js" + }, + "scripts": { + "test": "node scripts/run-tests.js", + "test:watch": "node --test --watch test/**/*.test.js", + "build": "node scripts/build.js", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "ssh", + "mcp", + "server", + "cli" + ], + "author": "Junki", + "license": "ISC", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.0", + "socks": "^2.8.7", + "ssh2": "^1.17.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "@types/ssh2": "^1.15.5", + "typescript": "^5.8.2" + }, + "files": [ + "build/**/*" + ] +} diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..c2fb0fe --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import { execSync } from "node:child_process"; +import { chmodSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const rootDir = join(__dirname, ".."); +const buildFile = join(rootDir, "build", "index.js"); + +// Run TypeScript compiler +console.log("Building TypeScript..."); +execSync("tsc", { stdio: "inherit", cwd: rootDir }); + +// Make executable on Unix-like systems (Linux, macOS, etc.) +if (process.platform !== "win32") { + try { + chmodSync(buildFile, 0o755); + console.log("Made build/index.js executable"); + } catch (error) { + console.warn( + "Warning: Could not set executable permissions:", + error.message + ); + } +} else { + console.log("Skipping chmod on Windows"); +} + +console.log("Build complete!"); diff --git a/scripts/run-tests.js b/scripts/run-tests.js new file mode 100644 index 0000000..b3f35bc --- /dev/null +++ b/scripts/run-tests.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +/** + * 测试运行器 + * 使用 Node.js 内置的测试框架运行所有测试 + */ + +import { execSync } from 'child_process'; + +console.log('🧪 运行测试...\n'); + +try { + execSync('node scripts/build.js', { + stdio: 'inherit', + cwd: new URL('..', import.meta.url).pathname + }); + execSync('node --test test/**/*.test.js', { + stdio: 'inherit', + cwd: new URL('..', import.meta.url).pathname + }); +} catch (err) { + process.exit(1); +} diff --git a/skills/ssh-mcp-helper/SKILL.md b/skills/ssh-mcp-helper/SKILL.md new file mode 100644 index 0000000..36322ce --- /dev/null +++ b/skills/ssh-mcp-helper/SKILL.md @@ -0,0 +1,166 @@ +--- +name: ssh-mcp-helper +description: Use when 用户希望安装、配置或新增 ssh-mcp-server 的 MCP 连接(如「帮我装一下 ssh-mcp-server」「给 Cursor/Claude Code 配置 SSH MCP」「在已有 MCP 里加一台远程主机」「ssh-mcp-server 的 mcp.json 怎么写」)。技能通过逐步问答收集主机、认证、传输模式、命令限制等参数,并把生成的 mcpServers JSON 片段写入对应客户端的配置文件。 +--- + +# ssh-mcp-helper + +## 概述 + +帮助用户通过交互式问答完成 `@fangjunjie/ssh-mcp-server` 的安装预检与 MCP 客户端配置。技能本身**不代替用户输入凭据**,而是逐项确认认证方式、连接参数与安全策略,最终产出可直接写入 MCP 客户端配置文件的 `mcpServers` JSON 片段。 + +**核心准则:** 所有可枚举的选项(MCP 客户端类型、认证方式、传输模式、是/否开关)必须使用 AskUserQuestion 让用户选择;只有不可枚举的输入(host、用户名、私钥路径、密码、自定义白名单正则等)才允许自由文本提问。 + +## 何时使用 + +- 用户明确要安装/配置/新增 ssh-mcp-server +- 用户提到为 Cursor / Claude Code / Cline / Continue 等客户端添加 SSH MCP +- 用户希望在已有 `mcpServers` 中追加一台 SSH 主机 +- 用户问「ssh-mcp-server 的 mcp.json 怎么写」 + +## 何时不使用 + +- 用户要修改 ssh-mcp-server 源码 → 直接编辑代码,不进入向导 +- 用户只是想跑某条 SSH 命令 → 直接调用已存在的 ssh-mcp-server 工具 +- 用户在问 SSH 协议本身的概念 → 解释即可,无需走流程 + +## 工作流程 + +```dot +digraph ssh_mcp_helper { + "0. 前置环境检查" [shape=box]; + "1. 选择 MCP 客户端" [shape=box]; + "2. 单台 vs 多台" [shape=diamond]; + "3. 选择认证方式" [shape=box]; + "4. 询问连接参数" [shape=box]; + "5. 询问高级选项" [shape=box]; + "6. 生成 JSON 片段" [shape=box]; + "7. 合并写入配置" [shape=box]; + "8. 提示重启与验证" [shape=doublecircle]; + + "0. 前置环境检查" -> "1. 选择 MCP 客户端"; + "1. 选择 MCP 客户端" -> "2. 单台 vs 多台"; + "2. 单台 vs 多台" -> "3. 选择认证方式" [label="单台"]; + "2. 单台 vs 多台" -> "3. 选择认证方式" [label="多台 → 写 ssh-config.json"]; + "3. 选择认证方式" -> "4. 询问连接参数"; + "4. 询问连接参数" -> "5. 询问高级选项"; + "5. 询问高级选项" -> "6. 生成 JSON 片段"; + "6. 生成 JSON 片段" -> "7. 合并写入配置"; + "7. 合并写入配置" -> "8. 提示重启与验证"; +} +``` + +### Step 0:前置环境检查 +- 运行 `node -v` 与 `npx --version` 确认本机有 Node.js(推荐 v18+) +- 缺失则先提示用户安装 Node.js,再继续后续步骤 + +### Step 1:选择 MCP 客户端(AskUserQuestion 多选一) + +| 客户端 | 默认配置位置 | +|---|---| +| Claude Code(全局) | `~/.claude.json` 的 `mcpServers` 字段 | +| Claude Code(项目级) | 项目根 `.mcp.json` | +| Cursor | `~/.cursor/mcp.json` | +| Cline / Continue / 其他 | 让用户提供具体路径 | + +### Step 2:单台 vs 多台(AskUserQuestion 二选一) +- **单台**:直接使用命令行参数(`--host` 等) +- **多台**:生成 `ssh-config.json` 并使用 `--config-file` + +### Step 3:选择认证方式(AskUserQuestion 多选一) +- `password` — 账号 + 密码 +- `privateKey` — 账号 + 私钥;再用 AskUserQuestion 确认是否带 passphrase +- `ssh-config` — 复用 `~/.ssh/config` 中的 Host 别名(只需 `--host `,可选 `--ssh-config-file`) +- `ssh-agent` — 使用 `--agent` 指向 socket +- `2fa` — 密码 + 私钥 + 键盘交互,追加 `--try-keyboard` + +### Step 4:连接参数(自由文本) +- host / port / username(port=22 可省略) +- 按 Step 3 的结果追问密码、私钥路径、passphrase、agent socket 等 + +### Step 5:高级选项(每项独立用 AskUserQuestion 询问是/否) +1. SOCKS 代理:是 → 追问 `--socksProxy` 字符串 +2. 命令白名单:是 → 追问逗号分隔正则(**生产环境强烈建议开启**) +3. 命令黑名单:是 → 追问逗号分隔正则 +4. 命令模板:是 → 追问含 `` 占位符的模板 +5. 传输模式:默认 `exec`;若用户标记目标为堡垒机/跳板机,改 `shell` 并追问 `--shell-ready-timeout` +6. 路径白名单:是 → 追问 `--allowed-local-paths` / `--allowed-remote-paths` +7. 启动时预连接:是 → 追加 `--pre-connect` + +### Step 6:生成 JSON 片段 +装配规则: +- `command` 固定为 `"npx"` +- `args` 第一项 `"-y"`,第二项 `"@fangjunjie/ssh-mcp-server"` +- **每个命令行参数与值必须是 args 数组中独立的两个元素**,绝不能写成 `"--host 192.168.1.1"` +- 多连接场景:把每个连接写入 `ssh-config.json`(数组或对象格式皆可),客户端配置里只放 `--config-file <绝对路径>` + +### Step 7:合并写入配置 +- 先用 Read 读取目标 JSON 配置文件 +- 合并到既存 `mcpServers` 下;若存在同名 key,**先 AskUserQuestion 让用户选择覆盖 / 改名 / 取消** +- 写入前把最终片段展示给用户确认 +- 写入后输出该配置文件的绝对路径 + +### Step 8:收尾 +- 提示用户重启对应 MCP 客户端使配置生效 +- 给出验证方式:调用 `list-servers`,或对该连接执行 `execute-command "whoami"` + +## 速查表 + +| 场景 | 关键参数 | +|---|---| +| 账号密码 | `--host --port --username --password` | +| 私钥(可带 passphrase) | `--host --port --username --privateKey [--passphrase]` | +| 复用 ssh config 别名 | `--host ` (+可选 `--ssh-config-file`) | +| SOCKS 代理 | `--socksProxy socks://user:pwd@host:port` | +| 堡垒机 / 跳板机 | `--transport-mode shell --shell-ready-timeout 15000` | +| 多连接 | `--config-file /abs/path/ssh-config.json` | +| 2FA / MFA | `--try-keyboard`(搭配密码 + 私钥) | +| 命令白名单 | `--whitelist "^ls( .*)?,^cat .*"` | +| 命令黑名单 | `--blacklist "^rm .*,^shutdown.*"` | +| 命令模板 | `--command-template "su root -c ''"` | +| 路径白名单 | `--allowed-local-paths` / `--allowed-remote-paths` | + +## 常见坑 + +- ❌ 把 `"--host 192.168.1.1"` 当作一个 args 元素 → ✅ 拆成两个元素 `"--host", "192.168.1.1"` +- ❌ 密码含 `{ } = ,` 等字符却用旧式 `--ssh "name=...,password=..."` → ✅ 改用 `--config-file` 或 JSON 形式 `--ssh` +- ❌ `shell` 模式下还想用 `upload`/`download` → 该模式禁用 SFTP,需切回 `exec` +- ❌ 直接覆盖用户既有 `mcpServers` 中的同名 key → 必须先读后合并,覆盖前显式确认 +- ❌ 直连生产环境却未配置 `--whitelist` / `--blacklist` → 必须主动提醒安全风险 +- ❌ 把私钥内容粘进配置 → 配置里应填**私钥文件路径**,凭据留在本地 + +## 输出示例 + +最简单的账号密码场景产出: + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": [ + "-y", + "@fangjunjie/ssh-mcp-server", + "--host", "192.168.1.1", + "--port", "22", + "--username", "root", + "--password", "pwd123456", + "--whitelist", "^ls( .*)?,^cat .*" + ] + } + } +} +``` + +多连接场景产出 `ssh-config.json` + 简化的客户端配置: + +```json +{ + "mcpServers": { + "ssh-mcp-server": { + "command": "npx", + "args": ["-y", "@fangjunjie/ssh-mcp-server", "--config-file", "/abs/path/ssh-config.json"] + } + } +} +``` diff --git a/src/cli/command-line-parser.ts b/src/cli/command-line-parser.ts new file mode 100644 index 0000000..30fe082 --- /dev/null +++ b/src/cli/command-line-parser.ts @@ -0,0 +1,483 @@ +import { parseArgs } from "node:util"; +import { SSHConfig, SshConnectionConfigMap, ParsedArgs } from "../models/types.js"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { lookupSshConfig } from "../utils/ssh-config-parser.js"; + +/** + * Command line argument parser class + */ +export class CommandLineParser { + private static readonly DEFAULT_TRANSPORT_MODE: SSHConfig["transportMode"] = "exec"; + private static readonly DEFAULT_SHELL_READY_TIMEOUT_MS = 10000; + + private static parseBoolean(value: unknown): boolean | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + } + return Boolean(value); + } + + private static parseTransportMode( + value: unknown, + ): SSHConfig["transportMode"] | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + + if (value === "exec" || value === "shell") { + return value; + } + + throw new Error( + `transportMode must be either 'exec' or 'shell', got: ${String(value)}`, + ); + } + + private static parseTimeout( + value: unknown, + fieldName: string, + ): number | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + + const parsed = + typeof value === "number" ? value : parseInt(String(value), 10); + + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${fieldName} must be a positive number, got: ${String(value)}`); + } + + return parsed; + } + + private static parseMaxOutputBytes(value: unknown): number | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + + const parsed = typeof value === "number" ? value : Number(String(value)); + + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error( + `maxOutputBytes must be a non-negative integer, got: ${String(value)}`, + ); + } + + return parsed; + } + + /** + * Parse command line arguments + */ + public static parseArgs(): ParsedArgs { + const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + "config-file": { type: "string" }, + "ssh-config-file": { type: "string" }, + ssh: { type: "string", multiple: true }, + // Compatible with single connection legacy parameters + host: { type: "string", short: "h" }, + port: { type: "string", short: "p" }, + username: { type: "string", short: "u" }, + password: { type: "string", short: "w" }, + privateKey: { type: "string", short: "k" }, + passphrase: { type: "string", short: "P" }, + agent: { type: "string", short: "a" }, + "password-from-env": { type: "string" }, + whitelist: { type: "string", short: "W" }, + blacklist: { type: "string", short: "B" }, + proxy: { type: "string" }, + socksProxy: { type: "string", short: "s" }, + "allowed-local-paths": { type: "string" }, + "allowed-remote-paths": { type: "string" }, + "transport-mode": { type: "string" }, + "shell-ready-timeout": { type: "string" }, + "command-template": { type: "string" }, + pty: { type: "boolean" }, + "try-keyboard": { type: "boolean" }, + "pre-connect": { type: "boolean" }, + }, + allowPositionals: true, + }); + + const configMap: SshConnectionConfigMap = {}; + + // Priority 1: Load from config file if specified + if (values["config-file"]) { + const configFilePath = path.resolve(values["config-file"]); + if (!fs.existsSync(configFilePath)) { + throw new Error(`Config file not found: ${configFilePath}`); + } + try { + const configContent = fs.readFileSync(configFilePath, "utf-8"); + const fileConfig = JSON.parse(configContent); + + // Support both array format and object format + if (Array.isArray(fileConfig)) { + // Array format: [{name: "dev", host: "...", ...}, ...] + for (const config of fileConfig) { + if (!config.name || !config.host || !config.port || !config.username) { + throw new Error("Each config in array must include name, host, port, username"); + } + configMap[config.name] = this.normalizeConfig(config); + } + } else if (typeof fileConfig === "object" && fileConfig !== null) { + // Object format: {"dev": {host: "...", ...}, "prod": {...}} + for (const [name, config] of Object.entries(fileConfig)) { + const normalizedConfig = this.normalizeConfig(config as any); + normalizedConfig.name = name; + configMap[name] = normalizedConfig; + } + } else { + throw new Error("Config file must contain an array or object of SSH configurations"); + } + } catch (err) { + if (err instanceof SyntaxError) { + throw new Error(`Invalid JSON in config file: ${(err as Error).message}`); + } + throw err; + } + } + + // Priority 2: Parse --ssh parameters (only if no config file was loaded) + if (Object.keys(configMap).length === 0) { + const sshParams: string[] = Array.isArray(values.ssh) + ? values.ssh + : values.ssh + ? [values.ssh] + : []; + + for (const sshStr of sshParams) { + let conf: SSHConfig; + + // Try to parse as JSON first + if (sshStr.trim().startsWith("{")) { + try { + const jsonConfig = JSON.parse(sshStr); + conf = this.normalizeConfig(jsonConfig); + if (!conf.name) { + throw new Error("JSON config must include 'name' field"); + } + } catch (err) { + throw new Error(`Invalid JSON format in --ssh parameter: ${(err as Error).message}`); + } + } else { + // Fallback to legacy comma-separated format for backward compatibility + conf = this.parseLegacySshFormat(sshStr); + } + + if (!conf.name || !conf.host || !conf.port || !conf.username) { + throw new Error("Each --ssh must include name, host, port, username"); + } + configMap[conf.name] = conf; + } + } + + // Priority 3: Compatible with single connection legacy parameters + if (Object.keys(configMap).length === 0) { + const host = values.host || positionals[0]; + + // 尝试从 SSH config 读取配置 + let sshConfigEntry = null; + if (host) { + try { + sshConfigEntry = lookupSshConfig(host, values["ssh-config-file"]); + } catch (err) { + // 显式指定配置文件但读取失败时抛错 + throw err; + } + } + + const portStr = values.port || positionals[1] || sshConfigEntry?.port?.toString() || "22"; + const username = values.username || positionals[2] || sshConfigEntry?.user; + const password = + values.password || + (values["password-from-env"] + ? (process.env[values["password-from-env"]] ?? undefined) + : undefined) || + positionals[3]; + const privateKey = values.privateKey || sshConfigEntry?.identityFile; + const passphrase = values.passphrase || process.env.SSH_MCP_PASSPHRASE; + const resolvedAgent = values.agent !== undefined + ? values.agent + : !password && !privateKey + ? process.env.SSH_AUTH_SOCK + : undefined; + const whitelist = values.whitelist; + const blacklist = values.blacklist; + const allowedLocalPaths = values["allowed-local-paths"]; + const allowedRemotePaths = values["allowed-remote-paths"]; + const commandTemplate = values["command-template"]; + const pty = values.pty; + const tryKeyboard = values["try-keyboard"]; + + // 实际连接地址:优先使用 SSH config 的 HostName + const actualHost = sshConfigEntry?.hostName || host; + + // No connection args provided at all: run in dynamic mode where each + // tool call may supply host/username/password inline. An empty config + // is valid and lets the MCP server start without any pre-saved server. + if (!actualHost) { + return { + configs: configMap, + preConnect: values["pre-connect"] === true, + }; + } + + if (!portStr || !username || (!password && !privateKey && !resolvedAgent)) { + throw new Error( + "Missing required parameters, need to provide host, port, username and password, private key or agent" + ); + } + + const port = parseInt(portStr, 10); + if (isNaN(port)) { + throw new Error("Port must be a valid number"); + } + + configMap["default"] = this.normalizeConfig({ + name: "default", + host: actualHost, + port, + username, + password, + privateKey, + passphrase, + agent: resolvedAgent, + proxy: values.proxy, + socksProxy: values.socksProxy, + pty: pty !== undefined ? pty : undefined, + tryKeyboard: tryKeyboard !== undefined ? tryKeyboard : undefined, + transportMode: values["transport-mode"], + shellReadyTimeoutMs: values["shell-ready-timeout"], + commandTemplate, + commandWhitelist: whitelist + ? whitelist + .split(",") + .map((pattern) => pattern.trim()) + .filter(Boolean) + : undefined, + commandBlacklist: blacklist + ? blacklist + .split(",") + .map((pattern) => pattern.trim()) + .filter(Boolean) + : undefined, + allowedLocalPaths: allowedLocalPaths + ? allowedLocalPaths + .split(",") + .map((allowedPath) => allowedPath.trim()) + .filter(Boolean) + : undefined, + allowedRemotePaths: allowedRemotePaths + ? allowedRemotePaths + .split(",") + .map((allowedPath) => allowedPath.trim()) + .filter(Boolean) + : undefined, + }); + } + + return { + configs: configMap, + preConnect: values["pre-connect"] === true, + }; + } + + /** + * Parse legacy comma-separated format: name=dev,host=1.2.3.4,port=22,user=alice,password=xxx + * @private + */ + private static parseLegacySshFormat(sshStr: string): SSHConfig { + const conf: any = {}; + const parts = sshStr.split(","); + + for (const part of parts) { + // Only split on the first '=' to handle values containing '=' + const equalIndex = part.indexOf("="); + if (equalIndex > 0) { + const k = part.substring(0, equalIndex).trim(); + const v = part.substring(equalIndex + 1).trim(); + if (k && v) { + conf[k] = v; + } + } + } + + const port = parseInt(conf.port, 10); + if (isNaN(port)) { + throw new Error( + `Port for connection ${conf.name || "unknown"} must be a valid number` + ); + } + + return this.normalizeConfig(conf); + } + + /** + * Normalize SSH config object to ensure proper types and structure + * @private + */ + private static normalizeConfig(config: any): SSHConfig { + const port = typeof config.port === "number" + ? config.port + : parseInt(config.port, 10); + + if (isNaN(port)) { + throw new Error(`Port must be a valid number, got: ${config.port}`); + } + + return { + name: config.name, + host: config.host, + port, + username: config.username || config.user, + password: config.password, + privateKey: config.privateKey + ? this.normalizeLocalPath(String(config.privateKey)) + : undefined, + passphrase: config.passphrase || process.env.SSH_MCP_PASSPHRASE, + agent: config.agent, + algorithms: config.algorithms, + proxy: config.proxy, + socksProxy: config.socksProxy, + pty: this.parseBoolean(config.pty), + tryKeyboard: this.parseBoolean(config.tryKeyboard), + transportMode: + this.parseTransportMode(config.transportMode) || + this.DEFAULT_TRANSPORT_MODE, + shellReadyTimeoutMs: + this.parseTimeout( + config.shellReadyTimeoutMs, + "shellReadyTimeoutMs", + ) || this.DEFAULT_SHELL_READY_TIMEOUT_MS, + shellCommandTimeoutMs: this.parseTimeout( + config.shellCommandTimeoutMs, + "shellCommandTimeoutMs", + ), + commandTimeoutMs: this.parseTimeout( + config.commandTimeoutMs, + "commandTimeoutMs", + ), + connectionTimeoutMs: this.parseTimeout( + config.connectionTimeoutMs, + "connectionTimeoutMs", + ), + sftpTimeoutMs: this.parseTimeout(config.sftpTimeoutMs, "sftpTimeoutMs"), + maxOutputBytes: this.parseMaxOutputBytes(config.maxOutputBytes), + keepaliveIntervalMs: this.parseTimeout( + config.keepaliveIntervalMs, + "keepaliveIntervalMs", + ), + keepaliveCountMax: this.parseTimeout( + config.keepaliveCountMax, + "keepaliveCountMax", + ), + commandWhitelist: Array.isArray(config.commandWhitelist) + ? config.commandWhitelist + : config.whitelist + ? typeof config.whitelist === "string" + ? config.whitelist.split("|").map((s: string) => s.trim()).filter(Boolean) + : config.whitelist + : undefined, + commandBlacklist: Array.isArray(config.commandBlacklist) + ? config.commandBlacklist + : config.blacklist + ? typeof config.blacklist === "string" + ? config.blacklist.split("|").map((s: string) => s.trim()).filter(Boolean) + : config.blacklist + : undefined, + allowedLocalPaths: Array.isArray(config.allowedLocalPaths) + ? config.allowedLocalPaths + .map((allowedPath: unknown) => + this.normalizeLocalPath(String(allowedPath)), + ) + .filter(Boolean) + : typeof config.allowedLocalPaths === "string" + ? config.allowedLocalPaths + .split("|") + .map((allowedPath: string) => + this.normalizeLocalPath(allowedPath.trim()), + ) + .filter(Boolean) + : undefined, + allowedRemotePaths: Array.isArray(config.allowedRemotePaths) + ? config.allowedRemotePaths + .map((allowedPath: unknown) => + this.normalizeRemotePath(String(allowedPath)), + ) + : typeof config.allowedRemotePaths === "string" + ? config.allowedRemotePaths + .split("|") + .map((allowedPath: string) => + this.normalizeRemotePath(allowedPath.trim()), + ) + .filter(Boolean) + : undefined, + commandTemplate: this.parseCommandTemplate(config.commandTemplate), + }; + } + + private static parseCommandTemplate( + value: unknown, + ): string | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + + const template = String(value); + if (!template.includes("") && !template.includes("")) { + throw new Error( + `commandTemplate must contain '' or '' placeholder, got: ${template}`, + ); + } + + return template; + } + + private static normalizeLocalPath(localPath: string): string { + return path.resolve(this.expandHomePath(localPath)); + } + + private static expandHomePath(localPath: string): string { + if (localPath === "~") { + return os.homedir(); + } + if (localPath.startsWith("~/")) { + return path.join(os.homedir(), localPath.slice(2)); + } + return localPath; + } + + private static normalizeRemotePath(remotePath: string): string { + if (!remotePath) { + return ""; + } + if (!path.posix.isAbsolute(remotePath)) { + throw new Error( + `allowedRemotePaths entries must be absolute POSIX paths, got: ${remotePath}`, + ); + } + const normalized = path.posix.normalize(remotePath); + if (normalized.length > 1 && normalized.endsWith("/")) { + return normalized.slice(0, -1); + } + return normalized; + } +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..168be76 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,4 @@ +/** + * Export all configurations + */ +export * from './server.js'; \ No newline at end of file diff --git a/src/config/server.ts b/src/config/server.ts new file mode 100644 index 0000000..bce0579 --- /dev/null +++ b/src/config/server.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; + +function readPackageVersion(): string { + const packageJson = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { version?: unknown }; + + if (typeof packageJson.version !== "string") { + throw new Error("package.json must include a string version"); + } + + return packageJson.version; +} + +/** + * MCP Server configuration + */ +export const SERVER_CONFIG = { + name: "ssh-mcp-server", + version: readPackageVersion(), +}; diff --git a/src/core/mcp-server.ts b/src/core/mcp-server.ts new file mode 100644 index 0000000..73a1fb9 --- /dev/null +++ b/src/core/mcp-server.ts @@ -0,0 +1,133 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { SSHConnectionManager } from "../services/ssh-connection-manager.js"; +import { CommandLineParser } from "../cli/command-line-parser.js"; +import { Logger } from "../utils/logger.js"; +import { registerAllTools } from "../tools/index.js"; +import { SERVER_CONFIG } from "../config/server.js"; + +/** + * MCP Server class + */ +export class SshMcpServer { + private server: McpServer; + private sshManager: SSHConnectionManager; + private shutdownHandlersRegistered = false; + private shutdownPromise?: Promise; + + constructor() { + this.server = new McpServer(SERVER_CONFIG); + + this.sshManager = SSHConnectionManager.getInstance(); + } + + /** + * Register tools + */ + private registerTools(): void { + registerAllTools(this.server); + } + + private async shutdown(reason: string, exitCode?: number): Promise { + if (!this.shutdownPromise) { + this.shutdownPromise = (async () => { + Logger.log(`Received ${reason}, shutting down SSH MCP server...`, "info"); + + this.sshManager.disconnect(); + + try { + await this.server.close(); + } catch (error) { + Logger.log( + `Failed to close MCP server cleanly: ${(error as Error).message}`, + "error", + ); + } + })(); + } + + await this.shutdownPromise; + + if (exitCode !== undefined) { + process.exit(exitCode); + } + } + + private registerShutdownHandlers(): void { + if (this.shutdownHandlersRegistered) { + return; + } + + const handleSignal = (signal: NodeJS.Signals) => { + void this.shutdown(signal, 0); + }; + + process.once("SIGINT", handleSignal); + process.once("SIGTERM", handleSignal); + process.stdin.resume(); + process.stdin.once("end", () => void this.shutdown("stdin end", 0)); + process.stdin.once("close", () => void this.shutdown("stdin close", 0)); + + this.shutdownHandlersRegistered = true; + } + + /** + * Run the server + */ + public async run(): Promise { + // Initialize SSH configuration + const parsedArgs = CommandLineParser.parseArgs(); + this.sshManager.setConfig(parsedArgs.configs); + this.registerShutdownHandlers(); + + // Register tools before accepting MCP requests. + this.registerTools(); + + // Create transport instance and connect. + const transport = new StdioServerTransport(); + await this.server.connect(transport); + + Logger.log("MCP server connection established"); + + // Security warning + const allConfigs = Object.values(parsedArgs.configs); + if ( + allConfigs.some( + (c) => !c.commandWhitelist || c.commandWhitelist.length === 0 + ) + ) { + Logger.log( + "WARNING: Running without a command whitelist is strongly discouraged. Please configure a whitelist to restrict the commands that can be executed.", + "info" + ); + } + if ( + allConfigs.some( + (c) => + (c.transportMode || "exec") === "exec" && + (!c.allowedRemotePaths || c.allowedRemotePaths.length === 0) + ) + ) { + Logger.log( + "WARNING: Running without allowedRemotePaths is strongly discouraged. SFTP upload/download can read or write any path on the remote server. Configure allowedRemotePaths to restrict the SFTP surface.", + "info" + ); + } + + // Pre-connect to all servers if flag is set + if (parsedArgs.preConnect) { + Logger.log("Pre-connecting to all configured SSH servers...", "info"); + void this.sshManager + .connectAll() + .then(() => { + Logger.log("Successfully pre-connected to all SSH servers", "info"); + }) + .catch((error) => { + Logger.log( + `Warning: Some SSH connections failed during pre-connect: ${(error as Error).message}`, + "error" + ); + }); + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ce7a369 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +import { SshMcpServer } from "./core/mcp-server.js"; +import { SERVER_CONFIG } from "./config/server.js"; +import { Logger } from "./utils/logger.js"; + +const HELP_TEXT = `Usage: ssh-mcp-server [options] [host port username password] + +Options: + --config-file Load SSH server configs from a JSON file + --ssh-config-file Read host aliases from SSH config (default: ~/.ssh/config) + --ssh Add an SSH config as JSON or legacy key=value pairs (repeatable) + -h, --host SSH host or SSH config alias for single-host mode + -p, --port SSH port for single-host mode + -u, --username SSH username for single-host mode + -w, --password SSH password for single-host mode + --password-from-env Read the SSH password for single-host mode from the named environment variable (avoids exposing it in process args) + -k, --privateKey SSH private key path for single-host mode + -P, --passphrase SSH private key passphrase + -a, --agent SSH agent socket path or pageant on Windows + -W, --whitelist Command whitelist regexes, comma-separated + -B, --blacklist Command blacklist regexes, comma-separated + --proxy Proxy URL (SOCKS5, HTTP, or HTTPS) + -s, --socksProxy Legacy SOCKS5 proxy URL + --allowed-local-paths Extra allowed local paths, comma-separated + --allowed-remote-paths Allowed remote POSIX absolute paths, comma-separated + --transport-mode SSH transport mode: exec or shell (default: exec) + --shell-ready-timeout Shell readiness probe timeout (default: 10000) + --command-template