0. 简介
RobotMCP 面向的是"让通用大模型不改机器人源码就能发现、订阅、调用一套现成 ROS/ROS2 系统"这个具体场景,它要处理的核心矛盾是:过去把 LLM 接进机器人,要么给每台机器人手写一层适配代码,要么把 Agent 逻辑焊死在 ROS 工作空间里,换机器人、换仿真环境就得重来一遍。它没有走"给模型再训练一个机器人控制头"的路线,而是让rosbridge 的 WebSocket 通道与MCP 的工具协议对接,把 ROS 图谱里的 topic、service、action、parameter 和传感器图像,运行时动态暴露成一组标准 MCP 工具。从robotmcp/ros-mcp-server的真实代码看,最值得关注的是它整套能力都建立在advertise → publish → unadvertise这种无状态 JSON 往返之上,没有为任何一台具体机器人写死消息结构。本文承接我此前那篇偏生态介绍的文章(RobotMCP:把 ROS 图谱变成 Agent 的身体接口),这次不谈产品全景,只沿着依赖链往下钻:rosbridge 协议怎么转译、MCP 工具怎么注册、图像怎么解码、以及它相比 ROSA / ROSGPT 这类"SDK 嵌入式"方案到底强在哪里。
1. 为什么"接口层"值得单独拆一篇
1.1 这篇要回答的不是"是什么"而是"凭什么"
上一篇文章把 RobotMCP 当成一个正在平台化的生态来介绍,讲了三层架构、模块体系、机器人规格文件和几个跑通的 demo。那是"它是什么"。这篇要回答的是完全不同的问题:“它凭什么能做到不改源码就接管一台陌生机器人”。这两个问题的答案不在同一层——前者靠产品设计,后者靠底层的协议选型和一段段具体的 Python 实现。
1.2 两条互不相同的技术路线
进一步看,把机器人接入大模型这件事,业内其实分成了两条互不相同的技术路线。一条是"SDK 嵌入式":以 NASA-JPL 的 ROSA 为代表,你要把一个 Python 库pip install进自己的 ROS 工作空间,然后在代码里实例化一个 Agent 类,用 LangChain 的@tool装饰器给每种机器人能力写一个工具函数。另一条是"协议桥接式":以 RobotMCP 为代表,你不动机器人代码,只在旁边起一个 rosbridge 节点,让外部 Agent 通过标准协议自己去探索这台机器人。
这里的关键是,两条路线的差别不是"用哪个框架"这么表层,而是谁承担适配成本、适配成本随机器人数量怎么增长。后面第 9 节会用一个简单的成本模型把这件事量化。要看懂那个模型,得先把协议桥接这条路的每一个环节讲清楚,所以本文的顺序是:先看依赖链全貌,再逐层下钻到协议和代码,最后才做横向对照。
2. 整体框架:一条从自然语言到电机的依赖链
2.1 五个环节,任何一环断了整条链就不通
RobotMCP 在运行时其实是一条相当长的依赖链,任何一环断了整条链就不通。从上到下依次是:MCP 客户端(Claude Desktop / Gemini CLI / Cursor 等)通过 stdio 或 HTTP 与ros-mcp-server通信;ros-mcp-server内部用 FastMCP 框架把一批 Python 函数注册成 MCP 工具;这些工具通过一个WebSocketManager与rosbridge_server建立 WebSocket 连接;rosbridge_server再把 JSON 消息翻译成真正的 ROS 1 或 ROS 2 API 调用;最后才落到机器人硬件或仿真器上的节点。
2.2 关键的不对称设计:云端不在数据路径上
这条链里有一个不对称设计特别关键:云端不在数据路径上。RobotMCP 的云服务只负责初次 OAuth 登录和 DNS/隧道管理,一旦连接建立,MCP 消息和 ROS 数据走的是本地 Server 到 rosbridge 的直连,不经过任何第三方服务器。换句话说,机器人的实时控制流量不会因为云端抖动而卡顿,这对物理系统的安全性是硬要求。这里要点出的是,很多"云端机器人 Agent"方案恰恰是把控制指令绕了一圈公网,RobotMCP 在架构上主动回避了这一点。
直觉理解(三层架构在解决什么):可以把 rosbridge 想成机器人的"翻译官兼前台",把 ros-mcp-server 想成"懂业务的秘书"。大模型这个老板不会说 ROS 的方言(DDS、话题类型、QoS),秘书把老板的白话需求整理成前台能听懂的标准表格(JSON),前台再进后厨(ROS Core)真正下单。老板换了、后厨换了,中间这套表格格式都不用改。
3. 依赖底座:rosbridge v2 协议把 ROS 抽象成七个动词
3.1 用七个 JSON 动词抹平 ROS 1 与 ROS 2
整个方案能成立,第一块基石是rosbridge_suite这个已经存在十多年的成熟依赖。它做的事情,是把 ROS 里那套基于 DDS / TCPROS 的强类型通信,抽象成一组极其简单的 JSON 操作。核心问题在于:ROS 的原生接口是语言绑定的(rclcpp、rclpy),外部进程想接进来必须编译对应的消息类型,这对一个只会说 JSON 的大模型来说是不可逾越的。rosbridge 用一个 WebSocket 服务端解决了这件事。
rosbridge v2 协议把机器人的全部交互压缩成几个动词,每条消息都是一个带op字段的 JSON 对象。advertise声明要在某话题上发布,publish真正推消息,subscribe/unsubscribe管理订阅,call_service调用服务,外加/rosapi/*这一组内省服务用来查询图谱元数据。直观理解是,任何一台 ROS 机器人,无论跑的是 ROS 1 还是 ROS 2,在 rosbridge 眼里都被抹平成"能收发这七类 JSON 消息的黑盒"。这正是不改源码的前提——rosbridge_server 内部的RosbridgeProtocol类会根据自己链接的 ROS 版本,把同一条 JSON 翻译成 rclpy 或 rospy 的具体调用,上层完全无感。
3.2 三级内省:从话题名到消息字段
以查询全部话题为例,客户端只要发一条call_service打到/rosapi/topics这个内省服务,rosbridge 就会把当前 ROS 图谱里所有话题名和类型回传。这一步是"探索"能力的根,因为 Agent 一开始对机器人一无所知,它必须先问"你有哪些话题",再问"这个话题是什么消息类型",再问"这个消息类型有哪些字段"。这三级内省对应/rosapi/topics、/rosapi/topic_type、/rosapi/message_details三个服务,是后面所有感知和执行的入口。
# ros-mcp-server/server.py — get_topics 工具(节选)@mcp.tool(description=("Fetch available topics from the ROS bridge.\nExample:\nget_topics()"))defget_topics()->dict:# 构造一条 rosbridge 内省服务调用:问 /rosapi/topics 要全量话题清单message={"op":"call_service","service":"/rosapi/topics","type":"rosapi/Topics","id":"get_topics_request_1",}# 通过上下文管理器发起请求并等待响应withws_manager:response=ws_manager.request(message)# 先判服务是否失败(result 为假),再从 values 里取真正的话题数据ifresponseand"result"inresponseandnotresponse["result"]:error_msg=response.get("values",{}).get("message","Service call failed")return{"error":f"Service call failed:{error_msg}"}ifresponseand"values"inresponse:returnresponse["values"]else:return{"warning":"No topics found"}这段代码把协议的朴素性暴露得很清楚:发现一台机器人有哪些话题,本质上只是发一条op: call_service的 JSON。这里的关键是它没有 import 任何 ROS 消息类型,没有链接任何机器人特定的库——get_topics对一台工业臂和一只四足狗是同一份代码。它靠的是/rosapi/topics这个 rosbridge 提供的通用内省服务,而不是任何针对具体机器人的适配。换句话说,适配成本被彻底推给了 rosbridge 这个通用依赖,而 rosbridge 又是机器人厂商本来就会装的东西。
难点提示(内省服务为什么是关键):传统写机器人 Agent,开发者得在 prompt 或代码里手动列出"本机有 /cmd_vel、/scan、/camera 这几个话题"。这就像去一家陌生餐厅前必须先背下整本菜单。内省服务相当于餐厅门口有块电子屏,进门先扫一眼就知道今天供应什么——菜单换了,你的行为不用改,扫屏这个动作本身是通用的。
/rosapi/*就是机器人的这块电子屏。
4. 连接层:WebSocketManager 是唯一的有状态组件
如果说 rosbridge 是协议的另一端,那ros-mcp-server这一端真正干活的就是utils/websocket_manager.py里的WebSocketManager。它是整个 server 里几乎唯一有状态的组件——持有一条 WebSocket 长连接、一把可重入锁,负责所有 JSON 消息的收发。理解它,就理解了 MCP 工具是怎么"落到线上"的:上面每个工具函数看似独立,其实都共享这同一条连接和同一把锁,所有并发访问都在这里被串行化。
4.1 惰性且自愈的连接
先看建立连接。connect方法用一把threading.RLock保证线程安全,只有在当前没有活连接时才新建,避免重复握手。它把 IP 和端口拼成ws://ip:port交给websocket.create_connection,成功返回None(无错误),失败则把连接置空并回传错误字符串。这种"用返回值表达错误、不抛异常"的写法,在 Agent 场景里其实很重要——因为大模型需要拿到一个能读懂的错误消息去决定下一步,而不是让整个工具调用崩掉。
# ros-mcp-server/utils/websocket_manager.py — 连接与发送(节选)defconnect(self)->Optional[str]:"""尝试建立 WebSocket 连接;成功返回 None,失败返回错误字符串。"""withself.lock:ifself.wsisNoneornotself.ws.connected:try:url=f"ws://{self.ip}:{self.port}"self.ws=websocket.create_connection(url,timeout=self.default_timeout)print(f"[WebSocket] Connected ({self.default_timeout}s timeout)")returnNone# 无错误exceptExceptionase:error_msg=f"[WebSocket] Connection error:{e}"self.ws=Nonereturnerror_msgreturnNone# 已连接,无需重连defsend(self,message:dict)->Optional[str]:"""把 dict 序列化成 JSON 发出;发送前先确保连接可用。"""withself.lock:conn_error=self.connect()ifconn_error:returnconn_error# 连接失败直接把错误往上抛ifself.ws:try:json_msg=json.dumps(message)# 保证消息可 JSON 序列化self.ws.send(json_msg)returnNoneexceptExceptionase:self.close()# 任何发送异常都主动关连接,避免半死状态returnf"[WebSocket] Send error:{e}"return"[WebSocket] Not connected, send aborted."这段代码里有两个工程细节值得说。第一,send每次都先调connect(),也就是说连接是"惰性且自愈"的——断线之后下一次发送会自动重连,Agent 不需要显式管理连接生命周期。第二,任何发送异常都会触发self.close()主动断连,宁可下次重连也不留一条状态可疑的 WebSocket。核心问题在于,物理机器人的通信链路本来就不稳定,这种"出错即重置"的策略比"努力复用旧连接"更安全。
4.2 request 把脏活兜住,工具才能写得薄
在send和receive之上还有一层request方法,把"发一条消息、等一条响应、解析成 dict"打包成一次调用,并且约定失败时返回{"error": "..."}这种结构化字典。这就是前面get_topics里ws_manager.request(message)的由来。这里要厘清的是,MCP 工具之所以能写得那么薄,正是因为脏活都沉到了WebSocketManager:工具函数只管拼 JSON 和解读结果,连接、重连、加锁、序列化全在下面这一层兜住了。
5. 执行层:为什么发一条控制指令要 advertise 三步走
5.1 advertise → publish → unadvertise 的三步语义
发现和感知是只读的,真正会改变物理世界的是"执行"。这里 RobotMCP 的做法看起来有点绕,但每一步都有理由。以最基础的publish_once(往一个话题发一条消息)为例,它并不是简单地把消息塞进 WebSocket,而是严格走advertise → publish → unadvertise三步。核心问题在于,rosbridge 要在 ROS 图谱里创建一个真正的 publisher 才能发消息,而这个 publisher 需要提前声明话题名和消息类型(advertise),发完之后再注销(unadvertise),避免在图谱里留下一堆僵尸发布者。
# ros-mcp-server/server.py — publish_once 的 publish→unadvertise 段(节选)publish_msg={"op":"publish","topic":topic,"msg":msg}send_error=ws_manager.send(publish_msg)ifsend_error:# 即使发布失败,也要尝试注销,避免图谱里留下僵尸 publisherws_manager.send({"op":"unadvertise","topic":topic})return{"error":f"Failed to publish message:{send_error}"}# 检查 rosbridge 是否回了 status:error 级别的响应response=ws_manager.receive(timeout=1.0)ifresponse:try:msg_data=json.loads(response)ifmsg_data.get("op")=="status"andmsg_data.get("level")=="error":ws_manager.send({"op":"unadvertise","topic":topic})return{"error":f"Publish failed:{msg_data.get('msg','Unknown error')}"}exceptjson.JSONDecodeError:pass# 发布场景下非 JSON 响应通常是正常的# 正常路径:发完主动注销话题ws_manager.send({"op":"unadvertise","topic":topic})return{"success":True,"note":"Message published using advertise → publish → unadvertise pattern"}5.2 连续控制与破坏性工具标注
这段代码的工程价值在于它对失败路径的处理:无论 publish 成功还是失败,都要把unadvertise补上。进一步看,在这套发布语义之上,社区还加了publish_for_durations——它接收一串消息和一串时长,按节奏依次发布,用来实现"以 10Hz 持续发速度指令 2 秒"这种连续控制。这类会改变机器人状态的工具,在 MCP 里被标注为 destructive(破坏性)工具,和只读的发现/感知工具区分开,客户端可以据此决定是否需要人工确认。这意味着,"能不能发"和"该不该发"在协议层就被显式分开了,为上层加安全网留了挂点。
工程价值(三步走不是啰嗦):直接
publish而不advertise,在 rosbridge 里会触发一次隐式声明,类型推断容易出错;发完不unadvertise,ROS 图谱里会累积一堆没有实际发布者的话题,用ros2 topic list一看全是幽灵。三步走换来的是"每次控制都干净、可追溯",这在调试一台真实机器人时能省下大量排查时间。
6. 感知层:多模态模型是怎么真正"看见"摄像头的
6.1 base64 图像消息怎么还原成 PNG
执行能力再强,Agent 若看不见环境也是瞎子。RobotMCP 最容易被低估的一块,是它把 ROS 的图像话题直接接到了多模态大模型的眼睛上。难点在于,ROS 的图像消息(sensor_msgs/Image)是一段带height、width、encoding的原始字节,经 rosbridge 传过来时被 base64 编码塞进 JSON,大模型没法直接理解。parse_image这个函数负责把它还原成一张真正的图片。
# ros-mcp-server/utils/websocket_manager.py — parse_image(节选)defparse_image(raw:Optional[Union[str,bytes]])->Optional[dict]:"""解码带 base64 数据的图像消息并存成 PNG。"""try:result=json.loads(raw)msg=result["msg"]except(json.JSONDecodeError,KeyError):print("[Image] Invalid JSON or missing 'msg' field.")returnNoneheight,width,encoding=msg.get("height"),msg.get("width"),msg.get("encoding")data_b64=msg.get("data")ifnotall([height,width,encoding,data_b64]):returnNone# base64 → 字节 → numpy 一维数组image_bytes=base64.b64decode(data_b64)img_np=np.frombuffer(image_bytes,dtype=np.uint8)# 按 encoding 决定 reshape 形状与色彩空间转换ifencoding=="rgb8":img_cv=img_np.reshape((height,width,3))img_cv=cv2.cvtColor(img_cv,cv2.COLOR_RGB2BGR)# OpenCV 用 BGRelifencoding=="bgr8":img_cv=img_np.reshape((height,width,3))elifencoding=="mono8":img_cv=img_np.reshape((height,width))else:print(f"[Image] Unsupported encoding:{encoding}")returnNoneifnotos.path.exists("./camera"):os.makedirs("./camera")success=cv2.imwrite("./camera/received_image.png",img_cv)returnresultifsuccesselseNone6.2 图像与数据共用一条通道,靠类型分流
这段代码把三种最常见的编码(rgb8、bgr8、mono8)分别处理:彩色图 reshape 成三通道,其中rgb8还要做一次 RGB→BGR 转换,因为 OpenCV 默认按 BGR 存图;灰度图 reshape 成单通道。换句话说,它替大模型把 ROS 那套图像内存布局翻译成了标准 PNG。存盘之后,server.py 里另有一个analyze_previously_received_image工具,把这张 PNG 编码成 MCP 的ImageContent格式回传给模型。这里的关键是,图像和结构化数据走的是同一条 rosbridge WebSocket,靠消息类型里带不带 “Image” 来分流,Agent 因此既能读激光雷达点云的数字,也能"看"到 RGB 相机的画面,真正具备了多模态感知。
难点提示(encoding 为什么必须显式分支):同样一串字节,按 rgb8 解读是彩色照片,按 mono8 解读就是黑白图,形状对不上还会直接 reshape 报错。这就像同一段摩尔斯电码,不告诉你用哪本密码本就翻译不出正确内容。
encoding字段就是那本密码本,代码必须按它显式选分支,不能猜。
7. 扩展层:模块体系如何用 FastMCP 装饰器把能力"贴"上去
7.1 一行装饰器就是一种新能力
前面所有工具——get_topics、publish_once、call_service——之所以能被大模型调用,靠的是一行@mcp.tool装饰器。server 启动时执行mcp = FastMCP("ros-mcp-server"),之后每个带@mcp.tool的函数都会被 FastMCP 自动登记进工具表,连函数的 docstring 和description都会变成给模型看的工具说明。这套机制的价值在于:新增一种机器人能力,等于新增一个带装饰器的 Python 函数,不需要改动协议、连接层或任何客户端配置。
# ros-mcp-server/server.py — FastMCP 初始化与工具声明(节选)mcp=FastMCP("ros-mcp-server")@mcp.tool(description=("Connect to a robot by setting IP/port and testing connectivity."))defconnect_to_robot(ip:Optional[str]=None,port:Optional[int]=None,ping_timeout:float=2.0,port_timeout:float=2.0,)->dict:...这里的关键是描述字符串。大模型不是靠读源码来理解工具的,它读的是description和函数签名里的类型标注。connect_to_robot的描述写着"设置 IP/端口并测试连通性",模型就知道在需要换机器人时调它;call_service的描述里甚至直接给了call_service('/rosapi/topics', 'rosapi/Topics', {})这样的调用样例。换句话说,工具的自然语言描述本身就是给 Agent 的 API 文档,写得越准,模型误调的概率越低。