当用户要求小智把声音调低时,服务端收到 MCP 工具返回的“true”,很容易直接把它解释为操作成功。但从结构化请求进入固件,到回调执行、状态更新和扬声器实际产生变化,中间存在多个独立环节。要判断回复是否可靠,需要沿着工具调用链逐层确认这个返回值究竟证明了什么。
场景:小智(xiaozhi-esp32)调节音量时,服务端收到
text: "true"。结论:true 至多说明回调走到了SetOutputVolume()之后的 return 语句,硬件动作是否完成要看具体回调做了什么。产出:6 步责任分解 + 5 行验收记录模板 + 16 行错误速查卡。 适用版本:78/xiaozhi-esp32,固定 commit6240b777aaa2bc0cad43a4ce25b30de23f36ad00(核验日期 2026-09-11);ESP-IDF v6.0 / 6.0.1 主线。 平台版本:PV-20260911-0001(csdn),draft DRAFT-20260911-0001,package PKG-20260911-0001,production PROD-20260911-XIAOZHI-MCP-VISUAL-A1。
self.audio_speaker.set_volume 拿到 text: "true",能不能向用户回复"音量已经调小了"。SetOutputVolume() 之后的 return 语句;具体效果由实际板卡的 codec 实现决定,没有声学测量。user_only 标记只在默认工具列表里过滤,DoToolCall() 内部不再做拦截——"模型看不到"不等于"设备强制禁止"。| 项目 | 状态 | 说明 |
|---|---|---|
| 78/xiaozhi-esp32 仓库存在 | ✅ 已验证 | GitHub HTTP 200,29.5k stars |
固定 commit 6240b777aaa2bc0cad43a4ce25b30de23f36ad00 可访问 | ✅ 已验证 | main/mcp_server.cc 等 6 个源码文件 blob URL 均 HTTP 200 |
| ESP-IDF 主线 v6.0 / v6.0.1 | ✅ 已验证 | README 明确 |
McpServer 是工具提供方,后端是 MCP 客户端 | ✅ 已验证 | 角色划分见原文解读 |
Application::AddCommonTools() / AddUserOnlyTools() 启动注册 | ✅ 已验证 | application.cc L104-106 / L264-271 引用 |
音量工具名 self.audio_speaker.set_volume | ✅ 已验证 | mcp_server.cc L128-169 引用 |
音量工具参数 volume 为整数,范围 0-100 | ✅ 已验证 | mcp_server.cc L128-169 / h L109-120 引用 |
protocolVersion: "2024-11-05" 与 tools/list 分页 | ✅ 已验证 | 原文 + mcp_server.cc L33-78 |
DoToolCall() 按工具名查找 | ✅ 已验证 | mcp_server.cc L361-571 引用 |
业务返回布尔值被编码为 text 字符串而非 JSON 布尔 | ✅ 已验证 | mcp_server.h L290-291 / L303-304 贴图忠实摘录 |
整数参数先 cJSON_IsNumber() 再读 valueint | ✅ 已验证 | mcp_server.cc L361-571 引用 |
亮度 / 主题 / 摄像头工具受 HAVE_LVGL 等编译条件控制 | ✅ 已验证 | mcp_server.cc L33-78 引用 |
AudioCodec::SetOutputVolume() 是虚函数(L40-46) | ✅ 已验证 | audio_codec.cc L40-46 贴图忠实摘录 |
WifiBoard::GetDeviceStatusJson() 从 codec->output_volume() 读音量 | ✅ 已验证 | wifi_board.cc L303-308 贴图忠实摘录 |
| 6 张配图 URL 实际可访问 | ✅ 已验证 | img.wzk.icu/articles/20…... 6 个 URL 全部 HTTP 200 |
拿到 text: "true" 就能向用户回复"音量已调小" | ❌ 已驳斥 | 回调只走到 SetOutputVolume() 之后的 return,未做声学测量 |
| "默认列表里不出现"等于"设备已强制禁止调用" | ❌ 已驳斥 | DoToolCall() 内部不再做 user_only 拦截;不构成强制保证 |
描述里写 must call ... first 等于设备强制校验顺序 | ❌ 已驳斥 | 描述是建议,强制由外部编排/调用记录验证 |
| Schema 写 integer 就能严格拒绝所有小数 | ❌ 已驳斥 | 当前实现先 cJSON_IsNumber(),小数路径未在本文核验 |
| 把 true 推广为"硬件动作已完成" | ❌ 已驳斥 | 软件回读 ≠ 声学测量;不同 board 的 codec 实现可能不同 |
self.reboot 返回 true 就代表设备已重启 | ❌ 已驳斥 | 回调只安排 1 秒后的重启任务;实际重启完成需设备重新上线证据 |
| 把响应已发出和远端已收到当成同一观测点 | ❌ 已驳斥 | ReplyResult 也走 app.Schedule 队列;需用同 id 响应配对 |
你对小智说"声音调小一点",服务端调用了音量工具,拿到一段 text: "true"。这时可以回答"音量已经调小"了吗?
先看这个 true 从哪里来。在本文核验的小智固件里,音量工具取出板卡的 AudioCodec,调用 SetOutputVolume(),随后直接返回 true。它没有在这个回调里测量扬声器声压,也没有比较调整前后的实际声音。工具调用可以把语言意图送到硬件接口,但返回值能证明到哪一步,仍由具体回调决定。
这也是设备接入 Agent 最容易被压缩掉的一段:工具名字出现在列表里、请求被接受、回调执行结束、物理效果发生,是不同的事实。下面沿小智的一次音量调用把它们接起来。
本文基于 78/xiaozhi-esp32 固定提交 6240b777aaa2bc0cad43a4ce25b30de23f36ad00,核验日期为 2026-09-11。证据来自官方客户端源码;示例请求是依据实现编写的说明,并非抓包或设备测试。这里不评测当前 MCP 规范兼容性,也不推断未公开的云端权限和编排实现。
在小智这条链路里,ESP32 固件里的 McpServer 是工具提供方,后端是发现、调用工具的 MCP 客户端。这个角色划分很关键:设备不是拿到一句自然语言后,自行运行大模型决定该调哪个寄存器;设备收到的是结构化调用,随后执行注册好的 C++ 回调。
Application 启动时调用 AddCommonTools() 和 AddUserOnlyTools()。一个工具注册同时绑定名称、描述、参数和回调。音量工具的名字是 self.audio_speaker.set_volume;它的 volume 参数声明为整数,范围 0 到 100;回调通过 Board::GetInstance() 对应的板卡对象取得音频接口。
这个范围描述的是该工具的音量参数,不是分贝数。换一块板,底层 codec 和驱动可能不同。例如固定版本的 bread-compact-wifi 板卡依据编译配置选择 NoAudioCodecSimplex 或 NoAudioCodecDuplex。一个共同的工具名覆盖了不同实现,但没有让它们获得相同的声学输出特性。
工具目录也不是所有开发板完全相同。AddCommonTools() 只有取得非空 backlight 时才注册亮度工具;主题和摄像头相关工具还处于 HAVE_LVGL 编译条件及各自对象判断之内。接入后看不到一个工具,首先应该检查实际固件注册了什么,不能直接归因于模型没理解指令。
后端发起 initialize 时,这份实现返回 protocolVersion: "2024-11-05"、tools 能力以及板卡名称、固件版本。随后用 tools/list 获取工具定义。列表带有 nextCursor 时,需要继续请求下一页;仅检查第一页不足以证明工具不存在。这个分页限制来自固件构造返回消息的大小控制,并不是"设备最多只能有多少个工具"的固定数量上限。
因此,一次可追溯的接入,应先留下板卡、固件版本和完整工具列表。后面的调用结果才有明确的实现对象。
音量注册里有一个容易误读的细节。以下两段分别是官方源码中的描述和回调摘录:
"Set the volume of the audio speaker. If the current volume is unknown, you must call `self.get_device_status` tool first and then call this tool."
[&board](const PropertyList& properties) -> ReturnValue {
auto codec = board.GetAudioCodec();
codec->SetOutputVolume(properties["volume"].value<int>());
return true;
}
第一段告诉调用方:当前音量未知时,应先查设备状态。这对于"调小一点"有实际意义,因为工具接收的是目标值,不是相对变化量。假设状态读取为 50,再决定设为 40,是一种调用方可以实施的解释过程;50 和 40 只是本文示例,不是固件固定步长。
但第二段没有验证"前面是否查过状态",也没有自动执行那次查询。描述提供调用指导,回调负责执行;如果后端需要确保这个顺序,应在实际编排和调用记录中验证,不能只凭描述中的 must 就认为设备强制保证了。
参数范围则不同。DoToolCall() 按注册的参数逐个读取请求;必需参数缺少有效值时返回错误,整数值随后经 Property::set_value<int>() 检查上下界。音量请求为 101 时,会走超过最大值的异常路径,回调尚未执行。这里说的是源码分支推演,没有运行这次请求。
也不要把这套检查泛称为完整 JSON Schema 校验器。这个版本对整数参数先使用 cJSON_IsNumber(),然后读取 valueint;因此,"Schema 写了 integer"不能直接推出"实现严格拒绝所有小数"。评估自定义工具时,应该同时读参数声明和实际解析代码。

假设后端已经完成发现,并决定把音量设为 40。发送给设备的 MCP 内层请求可以写成:
{"jsonrpc":"2.0","id":17,"method":"tools/call","params":{"name":"self.audio_speaker.set_volume","arguments":{"volume":40}}}
这里展示的是内层 payload,不是完整 WebSocket 或 MQTT 消息。小智协议使用外层 type: "mcp" 携带它;Application 收到这一类型后,把 payload 交给 McpServer::ParseMessage()。
解析器检查 JSON-RPC 版本、方法、参数等字段。这个版本还要求请求 id 是数字。进入 DoToolCall() 后,固件先按名字查找工具,再构造并校验参数,最后把调用安排到应用主任务:
app.Schedule([this, id, tool_iter, arguments = std::move(arguments)]() {
try {
ReplyResult(id, (*tool_iter)->Call(arguments));
} catch (const std::exception& e) {
ESP_LOGE(TAG, "tools/call: %s", e.what());
ReplyError(id, e.what());
}
});
这段代码把调用安排到主任务执行,没有把每个工具放到独立的后台工作线程。应用主循环取出排队的任务并执行。读自定义工具时,要继续检查回调实际做了什么:同步做完,还是又安排了一项稍后才执行的工作。
对音量工具而言,McpTool::Call() 取得回调返回的布尔值后,将它编码成文本内容,所以结果里是字符串 "true",不是顶层 JSON 布尔值。结果还带有 isError: false。同一包装函数也会把布尔 false 编成文本 "false",同时保留 isError: false;业务返回值与包装层错误标记不能混为一谈。
ReplyResult() 随后调用 Application::SendMcpMessage(),发送本身也被安排到主任务里。源码中"已经构造结果"不等于远端已经收到结果。对一次真实联调,应把 id 为 17 的请求与远端实际收到的同 id 响应配对;本文没有这样的网络实测记录。
这一条链路能具体说明硬件如何成为 Agent 工具:名称和参数让调用方能表达动作,注册回调把动作接到板卡接口,响应把该回调提供的结果送回来。是否达到了用户想要的效果,还要继续读最后两端。

音量回调里的 true,至多说明它调用 SetOutputVolume() 后正常走到了返回语句。SetOutputVolume() 是虚函数,具体效果由实际板卡使用的实现决定,不能只看一个基类就概括所有设备。
官方 AudioCodec 基类的实现更新 output_volume_ 并写入设置。相应地,本文核验的 WifiBoard::GetDeviceStatusJson() 中,音量状态来自 codec->output_volume()。这给联调提供了一项便宜的回读:调用后再查询设备状态,检查软件记录值是否符合预期。
但软件回读仍不是声学测量。它没有证明扬声器接线、功放状态、声学增益或用户实际听到的音量。如果需求只是"设置设备音量值",回读已经比一个裸 true 更有区分力;如果验收目标是"用户能够听到更小的声音",还需要在明确硬件和播放条件下验证实际输出。两种需求需要的证据不同。
重启工具让这种区别更明显。self.reboot 的回调先通过 app.Schedule() 安排一项任务:等待一秒,再调用 app.Reboot();回调本身随后返回 true。这个 true 表达的是回调已安排重启工作,不能证明设备完成了重启。
这里还不能继续推成"调用方一定先收到 true,再看到设备重启"。前面已经看到,响应发送也需要排队;重启工作和发送工作的实际先后、连接是否还能送出结果,都要沿调度路径或用设备记录验证。对这个动作,更有意义的完成证据是设备重新上线及预期运行状态,而不是只等一个成功文本。

有些硬件动作不应该由模型自由选择。固定版本把重启、升级固件等工具注册为 user-only,并在工具定义中添加 audience: ["user"] 注解。默认工具列表会跳过这些工具:
if (!list_user_only_tools && (*it)->user_only()) {
++it;
continue;
}
当 tools/list 请求带 withUserTools: true 时,列表会包含它们。不过,继续查看 DoToolCall(),其查找条件是工具名相等;在这条方法内部,没有依据 user_only() 再做一次拒绝检查。
这个观察的范围必须收紧:它只说明,到达这份设备端分发函数的调用,没有在此处被该标记拦截。它不能证明外部任意人能连上设备,也不能证明云端没有身份校验、界面确认或调用限制。那些控制需要各自的服务端和接入证据。
对准备增加电机、继电器等工具的开发者,直接可用的结论是:不要把"默认不给模型列出"写成"设备已经强制禁止模型调用"。如果动作需要操作者确认或硬件互锁,应该指出具体在哪里执行检查、什么条件会拒绝,再验证请求确实经过那里。本文源码中没有新增设备的实现,更没有验证电机或继电器动作安全。
拿音量工具做首个接入样例,可以留下下面这张记录。它是依据前面源码编写的验收模板,尚未执行;每一行都回答一个具体问题。
| 检查位置 | 需要留下什么 | 能回答什么 |
|---|---|---|
| 实际固件与发现 | 板卡、版本、完整 tools/list、音量参数定义 | 当前设备是否注册并公开了目标工具 |
| 调用方决策 | 用户要求、此前状态、目标音量、请求 id | "调小一点"如何被解释成一个目标值 |
| 设备参数与回调 | 合法值和越界值的处理记录、实际 codec 类型 | 请求是否到达预期实现,越界是否在执行前被拒绝 |
| 响应与软件回读 | 同 id 的实际响应、后续状态中的音量值 | 回调报告了什么,设备软件状态是否符合预期 |
| 产品要求的效果 | 在指定板卡和播放条件下观察实际输出;未验证时明确留空 | 软件设置是否达到了这次需求的物理效果 |
如果工具没有出现在完整列表里,回到注册和板卡条件;如果越界参数仍然执行,回到解析与检查;如果 true 已返回但软件状态不符,检查实际 codec 实现和状态读取路径;如果软件值正确而听感不符,再去看硬件输出条件。这样,每一步失败都有具体的下一站。
以后换成其他硬件工具,先重写"回调返回时已经完成了什么"和"什么证据算动作完成"这两格。把它们写清楚,Agent 才知道什么时候可以向用户报告完成,什么时候只能报告请求已经交给设备。

以下链接均固定到本文核验提交;本地保存了原始文件和 SHA-256,未使用在线 main 代替版本证据。
| 症状 | 根因 | 定位 | 修复 |
|---|---|---|---|
工具返回 text: "true" 就向用户回复"音量已调小" | true 只到 SetOutputVolume() 之后的 return 语句 | 看 mcp_server.cc L60-64 注册回调 | 区分"回调完成"与"硬件动作完成",按需追加软件回读 / 声学验证 |
| 看到完整列表里没有目标工具就归因于模型没理解 | 工具受编译条件与板卡对象状态影响 | 看 mcp_server.cc L33-78 注册条件 | 先查完整 tools/list(含 nextCursor 分页),再核对实际固件 |
只看第一页 tools/list 就宣布工具不存在 | 列表分页由消息大小控制 | 看 mcp_server.cc L33-78 nextCursor 返回 | 收到 nextCursor 继续请求下一页 |
bread-compact-wifi 板卡声学输出符合预期 | 同一工具名覆盖不同 codec 实现 | 看 compact_wifi_board.cc L172-183 codec 选择 | 按实际 board 与编译配置核对;同工具名 ≠ 相同声学输出 |
描述里写 must call ... first 就当设备强制顺序 | 描述是建议,强制由外部编排/调用记录验证 | 看 mcp_server.cc L128-169 回调实际行为 | 描述里 must 不构成设备强制;按需求另加检查 |
| 整数 101 直接执行了回调 | DoToolCall 按参数上下界提前拒 | 看 mcp_server.cc L361-571 解析路径 | 越界走异常分支,回调未执行;先记录请求 id + 参数值 |
| "Schema 写 integer"就严格拒绝所有小数 | 当前实现先 cJSON_IsNumber() 再读 valueint | 看 mcp_server.cc L361-571 解析代码 | 自定义工具评估要同时读参数声明和解析代码 |
业务返回 false 推出"工具出错" | 业务返回值与包装层错误标记不同 | 看 mcp_server.h L290-291 / L303-304 包装 | 检查 isError 字段;false 也可 isError: false |
text: "true" 被当作 JSON 布尔 | 布尔被编码为文本字符串 | 看 mcp_server.h L290-291 编码 | 解析时把 text 当字符串读,不要用 JSON 布尔解码 |
| 写完响应就以为远端已收到 | ReplyResult 也走 app.Schedule 队列 | 看 application.cc L676-680 / L1316-1326 发送路径 | 用同 id 请求-响应配对验证网络 |
| 回调返回值就认定动作完成 | 回调可能只安排了后续工作 | 看 mcp_server.cc L128-169 回调实现 | 区分同步做完与"安排了一项稍后工作";看后续状态 |
self.reboot 返回 true 就报告"设备已重启" | 回调只安排 1 秒后的 app.Reboot() 任务 | 看 reboot 工具注册回调 | 完成证据应是设备重新上线 + 预期运行状态 |
user_only 工具默认列表里看不到就视为"已禁止" | DoToolCall 内部不再做拦截 | 看 mcp_server.cc L361-571 分发函数 | 仅说明到达该函数的调用没被拦截;强制需在编排/服务端 |
| 默认列表里不出现 = "云端已做限制" | 列表过滤只控制模型可见性 | 看 mcp_server.cc L33-78 列表构造 | 不能用列表可见性代替云端身份校验 / 操作者确认 |
加电机/继电器工具直接复用 user_only 就行 | 硬件互锁应在另一层执行 | 看 mcp_server.cc L361-571 内部 | 指出互锁具体在哪里、条件如何,再验证请求经过 |
| 仅看"回调成功"就宣布 Agent 完成 | 缺软件回读与效果验证 | 看 audio_codec.cc L40-46 / wifi_board.cc L303-308 状态读取 | 增加 5 行验收表中的"响应与软件回读"和"产品要求的效果"两行 |
作者:武子康的个人博客
发布日期:2026-09-14(核验 commit 日期 2026-09-11)
核查依据:78/xiaozhi-esp32 仓库固定 commit 6240b777aaa2bc0cad43a4ce25b30de23f36ad00(HTTP 200),mcp_server.cc / mcp_server.h / application.cc / audio_codec.cc / audio_codec.h / wifi_board.cc / compact_wifi_board.cc / mcp-protocol.md 8 个文件 blob 全部可访问;ESP-IDF v6.0 / v6.0.1 主线;仓库 star 数 29.5k,最新 main 提交 2026-08-30;6 张配图 URL 全部 HTTP 200。
平台版本元数据:PV-20260911-0001(csdn),draft DRAFT-20260911-0001,package PKG-20260911-0001,production PROD-20260911-XIAOZHI-MCP-VISUAL-A1。