news 2026/10/2 3:23:36

纯Python手写SSH MCP Server:让AI安全连接服务器

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯Python手写SSH MCP Server:让AI安全连接服务器

作为一个常年折腾自动化工具的人,我一直在思考一个问题:AI 再聪明,如果只能停留在对话框里输出文字,那它的价值就折损了大半。真正让 AI 从“顾问”变成“执行者”的,是让它能直接操作真实环境——比如连上你的服务器,执行命令、读取日志、修改配置。这篇文章要聊的,就是如何用纯 Python 手写一个原生 SSH MCP Server,让 AI 通过标准化的 MCP 协议安全地接入 SSH,拥有真正意义上的“手脚”。

内容会覆盖三块:MCP 协议的核心通信机制、原生 SSH 客户端的封装思路、以及如何把两者组装成一个可以被 Claude Desktop、Cline、Trae IDE 等任意 MCP 客户端直接调用的服务器。适合已经跑通过 MCP Hello World、但想深入源码级理解,或者对依赖各种重型框架感到厌倦的开发者。


1. 为什么选择原生实现而非 FastMCP 等现成框架

1.1 我对“原生”的理解和对框架的“偏见”

先坦白一个立场:FastMCP、MCP SDK 这些框架确实好用,几行代码就能注册一个工具。但如果你只用框架,你学到的是“怎么调 API”,而不是“协议是怎么设计的”。我个人的习惯是,第一版工具尽量用标准库手写,跑通之后再引入框架优化。好处是:出了问题你知道往哪一层去排查——是 JSON-RPC 层的错误,还是 SSH 执行层的问题,还是 stdio 传输层的解析错误。

原生实现还有一个实际收益:依赖极少。在这个项目里,除了paramiko这一个 SSH 库之外,其余全部用 Python 标准库完成。这意味着你可以在任何装有 Python 3.10+ 的机器上直接运行,不挑虚拟环境,不挑操作系统,也不容易被依赖冲突折磨。

1.2 MCP 协议的核心拆分:传输层与协议层

MCP(Model Context Protocol)本质上是一个基于 JSON-RPC 2.0 的请求-响应协议。整个通信可以拆成两层理解:

传输层(Transport Layer):负责把 JSON 消息从一端搬到另一端。官方支持 stdio(标准输入输出管道)和 SSE(Server-Sent Events)两种模式,本项目用 stdio。原因是它最简单、最安全——客户端启动服务器子进程,然后通过管道读写消息,不需要开端口,不需要处理 CORS。

协议层(Protocol Layer):定义消息的格式。每条消息是一个 JSON 对象,包含jsonrpc、id、method、params等字段。你不需要实现全部 MCP 方法,只需实现客户端会调用的核心几个:initialize(握手)、tools/list(上报工具清单)、tools/call(执行工具调用)。

用生活类比来解释:stdio 是水管,JSON-RPC 是水,MCP 协议决定了水往哪流。我这个项目做的就是自己铺设水管,并且实现水流的阀门逻辑。


2. SSH 工具调用的核心设计:从命令拼接到执行回传

2.1 安全地构造远程命令:不要“字符串拼接”

SSH MCP Server 最危险的操作就是执行远程命令。如果你允许 AI 传入任意 shell 命令并直接拼接到ssh user@host "command"里,那基本等于给了 AI 一把万能钥匙——它会执行rm -rf还是读取/etc/passwd,完全取决于它的幻觉有多严重。

我的做法是:预先定义白名单指令集。server 启动时从配置文件加载允许执行的命令模板,例如list_files、read_file、run_command三类。其中run_command只允许执行用户明确指定的少数命令(如df -h、free -m、uptime),并且通过shlex模块进行参数解析和转义,杜绝 shell 注入。

2.2 关键点:SSH 连接复用与会话保持

每次调用都新建 SSH 连接是不现实的——握手要花时间,而且会导致大量的 TIME_WAIT 端口堆积。我在 server 内部做了一个连接池:首次调用时建立 SSHClient 连接,之后复用同一个 Transport。为了处理连接断开的情况,每次执行命令前先执行一个exec_command('echo ok')探活,失败则重连。

还有一个细节是Channel 的超时控制。AI 可能会发出一个永远执行不完的命令(比如ping google.com),这会卡住整个工具调用。我的解决策略是给channel.settimeout设置默认 10 秒,超出时间会抛出socket.timeout,server 捕获后向客户端返回结构化错误,而不是让请求一直挂起。


3. 手写 JSON-RPC 调度核心:initialize、tools/list、tools/call 的实现

3.1 消息循环:从 stdin 读取,按 id 分发

原生实现的骨架是一个无限循环:从sys.stdin.buffer.readline()读取一行 JSON,解析后根据method分发到对应处理函数,处理结果通过sys.stdout.write()写回。注意必须用write + flush,不能依赖 print 的默认缓冲,否则客户端会一直等不到响应。

这里直接给出核心代码结构:

import sys import json import shlex class McpSshServer: def __init__(self, ssh_config: dict): self.ssh_config = ssh_config self.client = None self._ensure_conn() def _ensure_conn(self): if self.client is not None: try: self.client.exec_command("echo ok", timeout=5) return except Exception: self.client.close() self.client = _create_ssh_client(self.ssh_config) def handle_message(self, line: str) -> dict | None: msg = json.loads(line) method = msg.get("method") msg_id = msg.get("id") if method == "initialize": return self._handle_initialize(msg_id, msg.get("params", {})) elif method == "tools/list": return self._handle_list_tools(msg_id) elif method == "tools/call": return self._handle_call_tool(msg_id, msg.get("params", {})) elif method == "notifications/initialized": return None # 通知类消息不需要响应 else: return { "jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": f"Method not found: {method}"} }

你可能会问:为什么tools/call的返回要这样包一层content数组?因为 MCP 协议规定工具调用结果使用content数组 +is_error标志位的方式表达。即使你的工具本质上是文本输出,也必须遵守协议规定的“信封”格式。

3.2 工具注册表:让 AI 知道它能“碰”什么

tools/list返回的是一个 JSON 数组,每个元素描述一个工具的名称、描述和输入参数 schema。这块值得花心思打磨:

  • 描述要写清楚边界:比如run_command的 description 里要写明“仅支持系统状态类命令,禁止写操作”,这能有效降低 AI 误用工具的概率。
  • 参数 schema 要严格:required字段要明确,枚举值要列出。AI 在调用工具前会读取这个 schema 来规划参数,你写得越清晰,它越不会乱来。

举个例子,文件读取工具的参数定义:

{ "name": "read_file", "description": "读取远程服务器上的文本文件(禁止读取敏感文件)", "inputSchema": { "type": "object", "properties": { "path": {"type": "string", "description": "文件的绝对路径"} }, "required": ["path"] } }

4. SSH 连接层的工程化封装:paramiko 的高级用法

4.1 为什么要选 paramiko 而不是直接 subprocess 调系统 ssh 命令

第一版我确实是用subprocess去调用本机的ssh和scp命令,但很快就放弃了。原因有三个:

  1. 密钥管理混乱:ssh 命令依赖~/.ssh下的密钥文件和 known_hosts,多主机切换时很容易串。
  2. 输出解析困难:命令混合了 stderr、stdout、交互式提示符,不好区分。
  3. 超时控制脆弱:ssh 进程可能挂死,需要额外写 watchdog。

paramiko 是一个纯 Python 实现的 SSHv2 协议库,不需要系统安装 OpenSSH 客户端就能工作。它提供了exec_command、open_sftp等高层 API,让我能把连接管理、命令执行、错误处理全部统一在一个进程内。

连接参数配置示例:

import paramiko def _create_ssh_client(cfg: dict) -> paramiko.SSHClient: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect( hostname=cfg["host"], port=cfg.get("port", 22), username=cfg["user"], password=cfg.get("password"), key_filename=cfg.get("key_filename"), timeout=cfg.get("timeout", 10), allow_agent=False, look_for_keys=False, ) return client

关键细节在最后两个参数:allow_agent=False和look_for_keys=False。这是为了确保不会意外加载本机的 SSH agent 或默认密钥,避免在服务器上把当前用户的凭据暴露给 AI。

4.2 执行命令并“结构化”返回输出

paramiko 的exec_command返回三个文件对象:stdin、stdout、stderr。注意一个坑:如果你先读 stdout 再读 stderr,可能会死锁。因为 channel buffer 是共享的,正确做法是用recv_exit_status配合并发读取,或者简单粗暴地先读满两个流再收状态。

以下是完善的执行函数:

def run_remote_command(self, command: str, timeout: int = 10) -> dict: self._ensure_conn() stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout) out = stdout.read().decode("utf-8", errors="replace") err = stderr.read().decode("utf-8", errors="replace") status = stdout.channel.recv_exit_status() return { "exit_code": status, "stdout": out, "stderr": err, }

errors="replace"很重要。远程服务器可能输出非 UTF-8 内容(比如 locale 是 GBK),直接.decode()会抛异常,用 errors 参数能保证不会因为一个乱码就中断整个调用。


5. 组装与手工测试:在没有客户端的情况下验证 server 逻辑

5.1 用echo模拟 JSON-RPC 请求,测试握手全流程

写完代码,第一件事不是接入 AI 客户端,而是先用文本 JSON 模拟请求,验证 server 的响应结构是否符合 MCP 规范。测试方式如下:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-test","version":"1.0"}}}

在 bash 里执行echo '{...}' | python ssh_mcp_server.py,观察返回结果。我第一次跑的时候就在这一步发现了一个 bug:initialize 响应里漏了protocolVersion字段,导致部分客户端直接拒绝握手。这种问题如果不手测,等到接客户端时排查会非常痛苦。

5.2 在真实客户端里的完整效果

以 Claude Desktop 或 Cline 为例,配置一个 MCP server 只需在 JSON 配置里指定启动命令:

{ "mcpServers": { "ssh-server": { "command": "python", "args": ["/path/to/ssh_mcp_server.py"], "env": { "SSH_HOST": "your-server-ip", "SSH_USER": "root", "SSH_PASSWORD": "your-password" } } } }

启动后,AI 在对话中就能调用list_files("/etc")、read_file("/etc/hostname")这类操作。实测下来,Claude 能准确根据我的自然语言指令选择正确的工具,并且会主动询问权限边界。

我建议你在配置里允许 AI 执行的第一批命令是:df -h(查看磁盘),free -m(查看内存),uptime(查看负载)。这些命令没有破坏性,又能让 AI 真正“有用”,体验感极佳。


6. 避开这三个坑,你才算是真正跑通了 SSH MCP Server

6.1 坑一:stdout 被非 JSON 输出污染

如果服务器在运行过程中不小心 print 了一行调试信息到 stdout,那整条消息流就断了。因为客户端在按行解析 JSON 时,遇到非 JSON 字符串会直接报错。

规避方案:所有调试日志一律走sys.stderr或logging模块,绝不能往 stdout 写。我在代码里封了一个_log()方法,统一把日期、级别、消息写到 stderr,这才避免了“线上静默崩溃、找不到原因”的尴尬。

6.2 坑二:AI 幻觉出参引发 SSH 异常连锁

AI 可能生成一个不存在的路径参数,也可能生成一个跨平台不一致的路径(比如 Windows 风格的C:),这在 Linux 服务器上会直接报错。我的处理方式是在工具层面把异常包起来,所有 SSH 执行错误统一返回is_error: true的 MCP 响应,而不是让异常抛出到主循环导致 server 崩溃。

6.3 坑三:长期保持的回话连接被防火墙切断

SSH 连接如果长时间闲置,会被 NAT 网关或服务端的ClientAliveInterval掐断。要解决这个问题,paramiko 的Transport可以设置 keepalive:

self.client.get_transport().set_keepalive(30)

这样每 30 秒会发送一个 keepalive 包,保证连接长期活跃。我在实测中让 server 挂机一夜,第二天仍然能正常执行命令,靠的就是这个参数。


7. 把工具变成真正的“助手”:使用场景演进与进阶排查思路

7.1 从“查询”到“操作”的边界控制

当你确认基础版稳定之后,可以做几个进阶扩展:

  1. SFTP 文件传输:paramiko 的open_sftp可以读取、上传、下载文件,扩展之后 AI 就能做日志备份。
  2. 命令回放审计:在run_command里把每次执行的命令和结果追加到本地日志文件,方便回溯 AI 的行为。
  3. 多服务器配置:配置文件支持多个 host 的数组,用环境变量切换当前目标,实现“一个服务、多台机器”。

7.2 如果接上不,怎么快速定位是哪一层的问题

遇到“AI 无法连接 server”这类问题,按这个顺序排查:

  1. 启动阶段:直接在终端运行python ssh_mcp_server.py,看有没有异常退出。注意 stdout 有没有非 JSON 输出。
  2. 握手阶段:用前面提到的手工 JSON 模拟 initialize,看响应包结构。
  3. 工具发现阶段:模拟tools/list,确认返回的工具 schema 格式正确。
  4. 执行阶段:模拟tools/call,传入固定参数,观察 paramiko 是否连接成功、命令是否执行、响应是否封装正确。

这四层如果都通了,那剩下只可能是客户端配置格式的问题。这个排查思路适用于任何 MCP server 开发,不只是 SSH 类工具。

7.3 最后的进阶:从纯执行到“有理解力”

等 core 功能稳定后,我给 server 加了一层轻量级“意图路由”:AI 传入自然语言指令,server 本地用正则和关键词匹配,映射到不同的安全工具上。

比如用户(也就是 AI 客户端)传入:“看看服务器磁盘是不是满了”,server 会优先匹配到df -h工具,而不是把这句话直接当作 shell 命令执行。这种思路能有效减少“幻觉命令”带来的不可控风险,还能让没有接受过工具调用训练的模型也能稳妥使用。


我实际使用下来最深的体会是:让 AI 拥有“手脚”是一回事,让它安全地拥有“手脚”又是另一回事。这套原生实现的 SSH MCP Server 让我能在完全不依赖重量级框架的情况下,看清 MCP 的每一字节流转,也让我在接入不同客户端(Claude Desktop、Cline、Trae IDE)时,能自信地告诉别人:“如果它不能跑,那一定是配置的问题,而不是协议的问题。” 最后再分享一个技巧:每次改动代码后,先用那套手工 JSON 模拟流程跑一遍全部工具,五分钟以内就能确认有没有回归——这份时间绝对花得值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 3:22:37

微博情感分析源码实战:从预处理到模型部署完整拆解

简介:基于机器学习的微博情感分析项目源码包,面向计算机相关专业学生在课程设计、期末大作业或毕业设计阶段完成中文文本情感分类任务。项目集成 NLPIR/ICTCLAS 分词组件,包含完整 Java 工程源码、模型数据与词典文件,并附项目说明…

作者头像 李华
网站建设 2026/10/2 3:22:37

Rhino打不开?全版本启动故障排查:授权/显卡/运行库/插件

Rhino 打不开,这事说大不大,说小能让人一整天活全停。我做工业设计和参数化建模这些年,从 Rhino 5 一路用到 Rhino 8,见过的"无法打开"没有一百种也有几十种:双击图标光标转两下就没反应、卡在启动画面死活不…

作者头像 李华
网站建设 2026/10/2 3:22:04

HarmonyOS智慧农业成本核算系统开发实战

刚把第十二篇的代码合进主分支,我坐在电脑前对着屏幕缓了口气。做这个 HarmonyOS 智慧农业系列到第十二篇,从环境搭建写到设备管理、农事记录、生长监控,说实话前几篇更多是在搭骨架、铺流程,写起来相对“顺”。但这篇成本核算系统…

作者头像 李华
网站建设 2026/10/2 3:22:04

Windows下MySQL启动服务报错排查:从1067到1053的完整方案

装MySQL装到第四步,启动服务报错,这事我见得太多了。不管是新手第一次装MySQL,还是老手帮同事善后,mysql安装过程中十有八九的问题都堆在“启动服务”这一哆嗦上——前面的解压、配置、注册服务都顺利过关,结果net sta…

作者头像 李华
网站建设 2026/10/2 3:21:49

共享单车时空数据分析实战:从GPS清洗到H3热力图渲染

简介:本资源是一套完整可运行的毕业设计项目源码,面向计算机相关专业本科生及前端/后端初学者,聚焦共享单车场景下的时空数据分析与管理功能实现。系统采用Python(Django/Flask类框架)构建后端服务,Vue.js开…

作者头像 李华
网站建设 2026/10/2 3:21:07

频率域图像处理核心梳理:从傅里叶变换到滤波器设计实战

频率域图像处理大概是整门数字图像处理课里最“劝退”的一章,很多同学学到傅里叶变换就开始懵,往后越听越像天书。但有意思的是,这一章在考试里占分不小,而且在工程实践里非常有用。我当年复习这一章的时候,踩过不少坑…

作者头像 李华