最近在调试小智的 MCP 工具时,我遇到一个特别容易让人误判的现象:工具返回了true,我满心以为硬件动作已经完成,结果设备根本没动。后来我翻日志才发现,这个true只是说"MCP Server 成功收到了请求,函数不报错地返回了",并不等于"硬件已经执行到位"。这个坑在智能硬件接入 MCP 的场景里非常常见,今天就从"MCP 工具返回 true"这个现象说起,聊聊 MCP 协议里工具返回值的真实语义,以及怎么设计才能让 AI 真正确认硬件动作完成。
如果你是正在做小智 AI 控制台、小智 AI 服务器镜像,或者任何用 MCP 协议去控制电机、灯、机械臂的开发者,这篇文章应该能帮你省下好几个晚上的排错时间。我会从链路原理、状态拆解、实操改造、问题排查四个部分展开,尽量把每一步都说明白,哪怕你刚接触 MCP 也能直接照着做。
1. 先搞清楚:MCP 工具返回值的真实身份
1.1 一次 MCP 工具调用,到底走过了什么路
很多人把 MCP(Model Context Protocol)理解成"AI 直接调用我的函数",这句话方向没错,但太笼统了。一次完整的 MCP 工具调用,中间隔着至少四个角色:AI 模型、MCP Host、MCP Server、硬件驱动。以你用小智控制台接一个智能灯为例:用户说"把灯打开",模型会决定调用一个名为turn_on_light的工具,这个调用以 JSON-RPC 的形式发给 MCP Host,Host 再根据工具名转发给注册好的 MCP Server,最后你的工具函数才真正执行。
也就是说,当 MCP 工具函数执行完并返回一个值,这个值要先变成 JSON 字符串,再沿着 Server -> Host 的路径传回给模型。模型看到的不是一个 Python 的True,而是一个 JSON 里的"true"。所以严格说,工具返回true只是"这个函数体走完了,没有抛异常,按约定返回了一个布尔值"。至于函数体里到底做了什么事、硬件到底动没动,MCP 协议本身完全不关心。
这也是为什么很多 SDK 会鼓励 MCP 工具返回结构化对象而不是裸布尔值。FastMCP、官方 Python SDK 都支持返回 dict,MCP 会自动序列化成 JSON。返回true在语法上没问题,但在语义上丢失了太多信息。你只是告诉 AI:"调用成功了",但没有告诉它"成功到什么程度"。
1.2 true 的本意是"函数执行成功",不是"硬件完成"
我在小智的硬件控制层里,最开始写的工具函数就是这样:
@mcp.tool() def move_servo(angle: int) -> bool: """将舵机转到指定角度""" hardware.send_command(f"SERVO:{angle}") return True看起来没毛病,开个串口发送指令,然后返回 True。但问题在于,hardware.send_command只是把数据写进了串口缓冲,串口驱动什么时候把数据发出去、舵机电路有没有正确执行、角度是否真的到位,这个函数完全不知道。更麻烦的是,串口发送失败时,如果代码没检查返回值,这个函数还是会返回 True。
这里的true实际上只代表了"我调用了发送函数,没有立刻崩溃"。用专业一点的话说,这叫"指令受理成功",而不是"动作完成成功"。你调用一个 HTTP API 时,服务器返回 200,也只能代表请求被接收了,不能代表业务处理完了。MCP 工具返回 true 是一样的道理,本质上只是一个"请求已受理"的信号。
我自己调试小智的 MCP Server 时,还在工具函数里特意加了一句日志,把返回前的状态打出来:
@mcp.tool() def move_servo(angle: int) -> bool: result = hardware.send_command(f"SERVO:{angle}") print(f"[MCP] servo command sent, result={result}") return bool(result)结果发现send_command返回的是串口写入字节数,哪怕只写了 0 字节,在 Python 里bool(0)是 False,但只要字节数大于 0,返回 True。可这能代表舵机动了吗?不能。它只代表串口驱动收到了这几个字符。
1.3 为什么这么容易踩坑:同步思维碰上异步硬件
做软件的人特别容易默认"函数返回了,事情就做完了",但硬件世界是异步的。你把指令发给电机驱动器,驱动器需要时间执行;你把开灯指令发给继电器,继电器线圈吸合需要几毫秒,灯丝点亮还需要时间。更不用说那些带缓启动、带减速过程的电机了,执行时间可能是秒级甚至更长。
MCP 工具调用本身是同步的,AI 模型会一直等着你的工具返回结果。如果你在工具函数里同步等待硬件真正完成,那就要阻塞住这个调用。比如舵机转到 180 度需要 2 秒,如果你在工具里time.sleep(2)等它到位,MCP 调用就会挂起 2 秒。这在简单场景下没问题,但遇到机械臂多关节联动、一次调用要执行好几秒的任务,模型那边很容易超时。
所以很多人图省事,工具函数里只把指令发出去,立刻返回 True。这就是"同步思维碰上异步硬件"的典型妥协:函数是同步的,但我没时间等硬件,那我只能撒谎说已经完成了。这个谎言短期能跑通 demo,长期就是各种诡异 bug 的来源。AI 模型会根据你的返回结果继续规划下一步,它以为动作完成了,马上让用户去做下一件事,结果硬件还在半路,整个交互体验就全崩了。
2. 把"完成"拆成三个阶段,你就明白了
2.1 指令下发、动作执行中、动作完成
要彻底搞明白 true 够不够用,我建议你先把硬件动作的状态拆成三个阶段:SUBMITTED、RUNNING、COMPLETED,这三个阶段里的任意一个,都可能被一个粗糙的工具函数用 true 带过去。
SUBMITTED:指令已经成功写入硬件控制链路,比如串口数据发出去了,I2C 写入完成了。这时候硬件还没开始动作,或者刚开始动作。RUNNING:硬件正在执行动作,比如电机正在转动、舵机正在移动、机械臂正在抓取。COMPLETED:硬件已经执行完成,并且你能确认它到达了目标状态,比如编码器反馈的角度到位了,电流检测显示继电器吸合了,传感器读到了目标数值。
所以说,MCP 工具返回 true,最准确的说法是"达到了 SUBMITTED 状态"。即使你的硬件 API 是同步的、调用后确实执行完了,你也需要在工具函数里拿到硬件反馈状态,再做判断,而不是默认返回 true。比如你调一个支持阻塞到位的电机库,它在执行完才返回,那你这时返回 true 才有意义;否则一律按"指令已下发"处理。
2.2 三种最常见的"伪完成"现场
我见过特别多的 MCP 工具返回 true,但硬件实际没完成的现场,总结下来主要是这三种。
第一种是"指令根本没发出去"的伪完成。工具函数里调了一个硬件 API,但这个 API 内部出错时只是打印日志,没有往上抛异常,也没有返回错误码。函数走到 return True,外面完全看不出来其实串口很早就断开了。这类问题最隐蔽,因为你只看 MCP 返回结果会觉得一切正常。
第二种是"指令发出去了,硬件没执行完"的伪完成。这个最典型,就是前面说的异步执行。你发了一个MOTOR:START指令,电机需要 5 秒才能转到位,但你的工具函数 10 毫秒就返回了 true。AI 模型立刻告诉用户"已经转到位了",实际上电机还在嗡嗡响。等电机到位时,用户可能已经在问下一件事了。
第三种是"硬件执行出错了,但工具没感知到"的伪完成。比如机械臂去抓一个物体,抓空了,但机械臂控制器只上报了"移动命令执行完毕",没有上报"夹爪内有没有物体"。如果你的 MCP 工具只确认了移动完成,没有去查夹爪传感器,它依然会返回 true。这种完成是片面的,只完成了"动作",没完成"目标"。
所以,不要迷信 true,要从硬件反馈闭环来定义"完成"。没有反馈闭环的 true,本质上都是盲猜。
2.3 从 MCP Server 到硬件驱动,每一环都可能丢状态
一个完整的链路里,状态可能在你没注意的环节悄悄丢掉。我调试小智控制台对接摄像头云台时,就是从这条链路上找问题:AI 模型 -> MCP Host -> MCP Server -> WebSocket 网关 -> 嵌入式设备 -> 电机驱动芯片 -> 编码器反馈。如果只看 MCP 工具函数,它确实向 WebSocket 网关发了一条消息并拿到了 ACK,于是返回 true。
但仔细看,WebSocket 网关只是把这条消息转给了嵌入式设备。嵌入式设备可能断电了、网络断了、或者正在执行上一个任务,这条消息根本没到电机驱动芯片。网关收到 ACK 只能说明网关收到了消息,不能说明设备收到了。设备收到也不代表电机执行了,电机执行了也不代表编码器计数到位了。所以你看,从软件到硬件,每一层都有可能把"完成状态"吞掉。
这就带出一个设计原则:一个 MCP 工具要敢返回"硬件动作完成",它必须拿到至少一层能反映硬件真实状态的反馈。可以是设备上报的完成事件,可以是传感器读值,也可以是电机驱动器的到位信号。如果拿不到,请老实返回"已下发,待确认"。
3. 实操改造:让小智的 MCP 工具返回真实完成状态
3.1 方案选型:轮询、回调还是事件推送?
既然不能直接返回 true,那我们怎么才能确认硬件动作真正完成?有三种常见方案:轮询、回调、事件推送。
轮询是最简单直接的。MCP 工具不负责等待,它把任务 ID 返回给模型;模型再调用另一个查询工具,不停去查这个任务的状态,直到查询结果为COMPLETED。适合硬件侧没有主动上报能力的情况,你用查询指令去读状态寄存器、读传感器,都能做。缺点是白白占用模型多次工具调用,而且查询间隔要控制好,太频繁浪费资源,太慢又不够实时。
回调方案是 MCP Server 把自己留成一个 Web 服务,给底层硬件一个回调地址。硬件执行完以后,通过 HTTP 请求回调 Server,Server 更新任务状态为 completed。这个方案实时性好,但需要硬件端或者网关支持回调,而且涉及内网穿透、防火墙配置,在本地调试时特别麻烦。
事件推送本质上和回调类似,只是把 HTTP 换成了 WebSocket、MQTT 这类长连接。当前很多智能硬件网关本身就是用 MQTT 上报状态的。如果小智硬件设备已经接入了 MQTT,那么 MCP Server 订阅相应 topic,收到finished事件时更新任务状态,这是最干净的。但前提是设备端要真的会发这个事件,而不是发一个笼统的 ACK。
我建议你按硬件能力来选。如果硬件已经具备状态反馈接口,优先用轮询,因为它实现最简单,而且不依赖网络环境。如果硬件能主动上报,就上 MQTT/WebSocket 事件,体验更好。下面我给一个轮询方案的完整示例。
3.2 给每个动作发一张"任务单":task_id
我不管用哪种方案,都强烈建议你先引入一个 task_id(任务 ID)。每个硬件动作在被 MCP 工具接收时,立刻生成一个唯一 ID,并把这个 ID 作为返回结果的一部分。后面查状态、确认完成、对账,全靠这个 ID。
为什么要这么做?因为一次硬件动作从开始到结束可能隔几秒甚至更久,你不能靠"角度 90 度"这种参数去区分是不是同一次任务。task_id 就是这次动作的身份证。你还能在 Server 里维护一张任务表,记录每个 task_id 的状态变化历史、时间戳、错误信息,排错的时候会非常舒服。
生成 task_id 不需要太复杂,时间戳加递增序号就行。比如:
import time _task_seq = 0 def new_task_id(prefix: str = "task") -> str: global _task_seq _task_seq += 1 return f"{prefix}_{int(time.time() * 1000)}_{_task_seq}"这样做出来的 ID 在程序生命周期内不会重复。如果你有多个 MCP Server 实例,还可以在前面加个实例名,避免并发重复。
3.3 代码改造示例:FastMCP 下的 move 工具
我假设你的硬件是一台能接收串口指令、并且能查询运动状态的电机。用 Python 和 FastMCP 来演示怎么把"返回 true"改成"返回任务状态"。
先看一个改造后的控制器类,它维护任务字典,模拟硬件异步执行:
from fastmcp import FastMCP import time import threading mcp = FastMCP("xiao_zhi_hardware") class MotorController: def __init__(self): self._tasks = {} self._lock = threading.Lock() def submit_move(self, position: int, duration: float) -> str: task_id = new_task_id("move") with self._lock: self._tasks[task_id] = { "status": "SUBMITTED", "target_position": position, "duration": duration, "create_time": time.time(), "error": None, } # 模拟把指令发给硬件,并启动一个线程模拟异步执行 threading.Thread(target=self._simulate_execution, args=(task_id, duration), daemon=True).start() return task_id def _simulate_execution(self, task_id: str, duration: float): # 这里在真实项目里会阻塞等待硬件反馈 time.sleep(duration) with self._lock: if task_id in self._tasks: self._tasks[task_id]["status"] = "COMPLETED" def get_status(self, task_id: str) -> dict: with self._lock: task = self._tasks.get(task_id) if not task: return {"status": "NOT_FOUND", "error": "task not found"} return dict(task) controller = MotorController() @mcp.tool() def move_motor(position: int, duration: float = 1.0) -> dict: """移动电机到指定位置。该工具会立即返回任务ID,执行完成后状态会变为COMPLETED。 Args: position: 目标位置,整数值 duration: 预计执行时长(秒) """ task_id = controller.submit_move(position, duration) return { "success": True, "task_id": task_id, "status": "SUBMITTED", "message": "移动指令已受理,请调用 query_motor_status 查询执行结果" } @mcp.tool() def query_motor_status(task_id: str) -> dict: """查询电机移动任务的状态。 Args: task_id: move_motor 返回的任务ID """ return controller.get_status(task_id)在这个例子里,move_motor的返回值已经不是裸 true 了,而是一个结构化对象,包含了task_id和当前状态SUBMITTED。AI 模型看到这个返回值,就能明确知道"动作还没有完成",我需要再查询。
模型侧的调用逻辑大概是:先调move_motor,拿到 task_id,然后循环调query_motor_status,直到状态变成COMPLETED。为了不让模型乱猜,我建议你在工具描述里写清楚:"查询状态为 COMPLETED 时表示动作完成,SUBMITTED/RUNNING 表示还在执行中。"这样模型就知道要等。
如果你还是希望 MCP 工具本身能阻塞到完成再返回,也可以实现一个move_motor_and_wait工具,内部轮询状态,直到 complete 或超时再返回。这样业务上更直观,但要注意不要把超时时间设置得太长,免得模型等待太久。我一般控制在 30 秒以内。
3.4 对 AI 模型侧的提示词与工具描述优化
很多时候工具本身已经返回了任务状态,但 AI 模型还是告诉你"已经完成了",原因出在工具描述写得不够清楚。MCP 工具的描述是给模型看的重要上下文,你要明确告诉它这个工具是"立即返回",还是"等待完成"。
比如move_motor的描述里,我特意写了"立即返回任务ID",query_motor_status的描述里写了"查询任务状态"。这能让模型意识到需要多一步查询。还可以在系统提示词里加一句:"调用硬件控制工具后,必须确认状态为 COMPLETED,才能向用户报告完成。"
我在小智控制台里就加过这样一条规则,效果很明显:模型不再急着说"好了",而是会回答"正在移动,请稍等",然后继续查询。
4. 常见问题与排查技巧实录
4.1 明明返回 true,硬件却没有动,先查哪一环
这种问题我在群里见过太多次了。排查顺序我一般建议从底层往外走:先看硬件本身能不能独立工作,比如用串口助手直接发指令,看设备动不动。如果设备不动,说明问题在 MCP 工具和硬件之间的通信层,比如串口权限、端口被占用、WiFi 网络不通。如果设备动,说明硬件正常,再去看 MCP 工具调用时是否传对了参数、是否真的调到了硬件 API。
有个特别容易忽略的坑:MCP Server 进程启动时可能没有打开硬件端口的权限,但代码里捕获了异常,然后还是返回了 true。所以排查时先在 MCP Server 日志里找PermissionError、SerialException、ConnectionRefused这类关键词,不要只看 MCP 返回的布尔值。我就是靠这个找到了问题:我的小智 MCP Server 以 systemd 服务运行时,/dev/ttyUSB0的权限不对,连串口都打不开,但旧代码里send_command内部吞掉了异常,表面一切正常。
另外,如果 MCP Server 是通过 HTTP 远程部署的,硬件控制指令走网络链路,一定要确认网络延迟和丢包情况。我遇到过 WebSocket 网关偶发断了重连,重连期间 MCP 工具调用返回 true 了,但指令已经丢失。后来我在代码里检查了网关是否处于连接状态,不连接就直接返回错误,才把问题压住。
4.2 返回 false 但硬件还在跑,问题出在哪
有朋友反过来说:我的 MCP 工具已经返回 false 了,结果硬件还在继续执行,这不是更危险?这种情况一般是因为工具函数里先向硬件发了一条不能中断的指令,然后因为某个后续步骤出错,返回了 false。比如你发了一个"电机开始转动"的指令,电机已经动起来了,紧接着你查状态时超时了,函数就 return False。但电机没有收到停止指令,自然还在转。
这暴露了一个设计问题:不要把"指令是否成功发出"和"执行结果是否成功"混在一个返回值里。处理办法是,工具函数里在发指令之前,先检查参数和硬件状态,检查不过就直接返回 false,不要发指令;发完指令之后,即使后续状态查询失败,也只应返回"查询失败",而不是 return False。更稳妥的是给硬件一个安全策略:比如执行任何长时间动作时,先发送"开始",再发送"结束/停止";如果整个过程出错,至少补发一个停止指令。
4.3 超时、幂等、并发:三个绕不开的深坑
先说超时。如果你的 MCP 工具在函数内部阻塞等待硬件完成,一定要设置超时。FastMCP 本身没有强制限制工具执行时间,但 AI 模型侧往往有自己的 request timeout。你在工具里干等 60 秒,模型早断了。我的经验是:单次硬件动作同步等待不超过 20-30 秒,超过就要用任务 ID + 查询模式解耦。
再说幂等。模型在生成回复时可能出现重复工具调用,或者你前端重试机制导致同一个开关指令被发两次。硬件动作不一定是幂等的,比如"开灯"重复执行没问题,但"电机转到 90 度"重复执行可能因为已经到 90 度而不动,也可能因为参数理解问题又转一圈。所以任务 ID 的另一个作用就是去重:同一个 task_id 只允许执行一次。
最后是并发。多个 MCP 工具同时操作同一个硬件设备,特别是如果你给多个 AI 客户端共用一个小智 MCP Server,并发冲突的概率非常高。两个指令同时往串口写,数据会交错导致解析错误。解决方法是给硬件发送模块加一个全局锁,同一时间只允许一个工具函数进入发送区。代码里可以用threading.Lock或者asyncio.Lock,简单粗暴但有效。
4.4 排查清单速查表
| 现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 工具返回 true,硬件没动 | 硬件 API 吞异常 | 查看 MCP Server 日志和硬件连接状态 | 检查串口权限、网络连接,让异常向上抛 |
| 工具返回 true,硬件动了一部分 | 指令下发后立即返回 | 查设备是否支持状态反馈 | 引入 task_id 和轮询/回调机制 |
| 工具返回 false,硬件还在动 | 发出指令后才发现错误 | 检查指令发送与错误判断顺序 | 先验证参数再发指令,出错时补发停止指令 |
| 模型总说完成了,但查状态还没完成 | 工具描述没提示需要二次确认 | 查看模型调用链和工具描述 | 在描述和提示词中明确要求查询 COMPLETED |
| 多次调用出现重复动作 | 缺少幂等控制 | 看是否同一 task_id 重复执行 | 加任务表判断去重 |
| 并发调用导致串口数据乱码 | 多个工具同时写串口 | 看日志时间戳与线程信息 | 加全局锁,串行化硬件发送 |
这个小表基本覆盖了我在小智 MCP 项目里遇到的高频问题,你可以把它贴在项目文档的 FAQ 里,后面别人踩坑了直接查。
最后再分享一个小技巧:无论你的 MCP 工具设计得多完善,在调试阶段都尽量把每一步返回值和硬件状态日志打印出来,然后串起来看一遍完整调用链。我习惯在 MCP Server 启动时加一个--debug参数,把工具入参、返回结果、任务状态变化都输出到单独的文件。这样一旦出现"返回 true 但硬件没动"这种问题,不用靠猜,直接拉日志就能定位。等你把"完成状态"真正做实了,会发现在小智控制台上和 AI 对话时,它的回答靠谱多了——因为它不再把一个空头支票式的 true 当成事实了。