xiaozhi-esp32: MCP流程详解
本文档详细描述了 xiaozhi-esp32 项目中 MCP(Model Context Protocol)的完整运行流程,包括 WebSocket 握手后 LLM 如何发现 MCP 工具,以及如何调用 MCP 工具。
概述
MCP 是本项目中用于后台 API(MCP 客户端)与 ESP32 设备(MCP 服务器)之间通信的协议。它基于 JSON-RPC 2.0 规范,承载在 WebSocket 或 MQTT 等基础通信协议之上。
核心角色:
- ESP32 设备:作为 MCP Server,注册并暴露工具(Tool),响应后台的工具发现和调用请求
- 后台 API:作为 MCP Client,负责发现设备工具、将工具信息提供给 LLM、并代理 LLM 的工具调用请求
- LLM(大语言模型):通过后台 API 间接感知设备工具,并在对话中决定是否调用
一、连接建立与能力通告
1.1 WebSocket 连接
设备启动后,通过 WebsocketProtocol::OpenAudioChannel() 建立 WebSocket 连接。连接时会在 HTTP Header 中携带认证信息:
Authorization: Bearer <token>
Protocol-Version: <version>
Device-Id: <mac_address>
Client-Id: <uuid>
关键代码路径:main/protocols/websocket_protocol.cc → OpenAudioChannel()
1.2 Hello 握手
WebSocket 连接成功后,设备立即发送 Hello 消息,其中通过 features.mcp: true 告知后台"本设备支持 MCP 协议":
{
"type": "hello",
"version": 1,
"features": {
"mcp": true
},
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 20
}
}
关键代码路径:main/protocols/websocket_protocol.cc → GetHelloMessage()
1.3 服务端 Hello 响应
后台收到设备 Hello 后,返回服务端 Hello,包含 session_id 和音频参数等信息。设备解析后保存 session_id,后续所有消息都会携带此 ID。
关键代码路径:main/protocols/websocket_protocol.cc → ParseServerHello()
二、MCP 会话初始化
后台确认设备支持 MCP 后,开始 MCP 会话的初始化流程。
2.1 Initialize 请求
后台发送 initialize 方法,可携带客户端能力(如摄像头视觉处理地址):
{
"session_id": "...",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"capabilities": {
"vision": {
"url": "http://...",
"token": "..."
}
}
},
"id": 1
}
}
2.2 设备处理 Initialize
设备收到后,McpServer::ParseMessage() 解析 JSON-RPC 消息:
- 验证
jsonrpc版本为"2.0" - 识别
method为"initialize" - 解析
capabilities(如设置摄像头的图片处理 URL) - 返回设备信息和协议版本
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "xiaozhi-board-name",
"version": "1.0.0"
}
}
}
关键代码路径:main/mcp_server.cc → ParseMessage() 中 method_str == "initialize" 分支
三、LLM 如何知道有哪些 MCP 工具
这是整个流程的核心环节。LLM 本身不直接与设备通信,而是通过后台 API 间接获取工具信息。
3.1 工具注册(设备启动时)
设备在 Application::Initialize() 中调用 McpServer::AddCommonTools() 和 McpServer::AddUserOnlyTools() 注册所有工具:
// main/application.cc → Initialize()
auto& mcp_server = McpServer::GetInstance();
mcp_server.AddCommonTools(); // 注册通用工具(对 AI 可见)
mcp_server.AddUserOnlyTools(); // 注册仅用户可见的工具(对 AI 不可见)
通用工具(AI 可见) 包括:
| 工具名 | 功能 |
|---|---|
self.get_device_status | 获取设备实时状态(音量、电池、网络等) |
self.audio_speaker.set_volume | 设置音量 |
self.screen.set_brightness | 设置屏幕亮度 |
self.screen.set_theme | 设置主题(light/dark) |
self.camera.take_photo | 拍照并解释 |
仅用户可见工具(AI 不可见) 包括:
| 工具名 | 功能 |
|---|---|
self.get_system_info | 获取系统信息 |
self.reboot | 重启设备 |
self.upgrade_firmware | 固件升级 |
self.screen.get_info | 获取屏幕信息 |
self.screen.snapshot | 屏幕截图 |
user_only 机制:通过
annotations.audience: ["user"]标记,后台在将工具列表传递给 LLM 时会过滤掉这些工具,确保 AI 不会主动调用敏感操作。
3.2 后台获取工具列表(tools/list)
后台通过 tools/list 方法向设备请求工具列表:
{
"session_id": "...",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"method": "tools/list",
"params": {
"cursor": ""
},
"id": 2
}
}
设备返回工具列表,每个工具包含 name、description 和 inputSchema:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "self.get_device_status",
"description": "Provides the real-time information of the device...",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "self.audio_speaker.set_volume",
"description": "Set the volume of the audio speaker...",
"inputSchema": {
"type": "object",
"properties": {
"volume": {
"type": "integer",
"minimum": 0,
"maximum": 100
}
},
"required": ["volume"]
}
}
],
"nextCursor": ""
}
}
关键代码路径:main/mcp_server.cc → GetToolsList()
3.3 分页机制
由于 ESP32 内存有限,工具列表的 payload 大小限制为 8000 字节。如果工具过多,会通过 nextCursor 实现分页:
const int max_payload_size = 8000;
// 如果添加这个 tool 会超出大小限制,设置 next_cursor 并退出循环
if (json.length() + tool_json.length() + 30 > max_payload_size) {
next_cursor = (*it)->name();
break;
}
后台收到非空的 nextCursor 后,需要再次请求 tools/list 并带上 cursor 参数。
3.4 后台将工具信息注入 LLM
后台拿到设备的工具列表后,将其转换为 LLM 的 function calling / tool use 格式,注入到 LLM 的 system prompt 或 tools 参数中。这样 LLM 在对话时就"知道"了设备有哪些可用工具。
这一步发生在后台服务端,不在 ESP32 设备代码中。
四、MCP 工具调用流程
当用户通过语音对话触发 LLM 决定调用某个工具时,完整的调用链路如下:
4.1 完整调用链路
用户语音 → 设备采集音频 → WebSocket 发送到后台
→ 后台 ASR 转文字 → 发送给 LLM
→ LLM 决定调用工具(function call)
→ 后台构造 MCP tools/call 请求 → WebSocket 发送到设备
→ 设备 McpServer 执行工具 → 返回结果
→ 后台将结果返回给 LLM → LLM 生成回复
→ 后台 TTS 合成语音 → WebSocket 发送到设备播放
4.2 消息路由
设备端收到 WebSocket 消息后的路由流程:
WebSocket 收到 JSON 消息
→ websocket_protocol.cc: OnData 回调
→ 判断 type 字段
→ "hello" → ParseServerHello()
→ 其他 → on_incoming_json_ 回调
→ application.cc: OnIncomingJson 处理
→ type == "tts" → 语音播放控制
→ type == "stt" → 语音识别结果显示
→ type == "llm" → 表情控制
→ type == "mcp" → McpServer::ParseMessage(payload)
关键代码(application.cc 中的 MCP 消息路由):
protocol_->OnIncomingJson([this, display](const cJSON* root) {
auto type = cJSON_GetObjectItem(root, "type");
// ...
if (strcmp(type->valuestring, "mcp") == 0) {
auto payload = cJSON_GetObjectItem(root, "payload");
if (cJSON_IsObject(payload)) {
McpServer::GetInstance().ParseMessage(payload);
}
}
});
4.3 tools/call 请求处理
后台发送工具调用请求:
{
"session_id": "...",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.audio_speaker.set_volume",
"arguments": {
"volume": 50
}
},
"id": 3
}
}
设备端 McpServer::ParseMessage() 处理流程:
- 验证 JSON-RPC 格式:检查
jsonrpc、method、id字段 - 查找工具:在
tools_列表中按name查找对应的McpTool - 解析参数:将
arguments中的值填充到工具的PropertyList中,并进行类型和范围校验 - 调度执行:通过
Application::Schedule()将工具调用调度到主线程执行,确保线程安全 - 返回结果:执行完成后通过
ReplyResult()返回结果
关键代码路径:main/mcp_server.cc → DoToolCall()
void McpServer::DoToolCall(int id, const std::string& tool_name, const cJSON* tool_arguments) {
// 1. 查找工具
auto tool_iter = std::find_if(tools_.begin(), tools_.end(), ...);
// 2. 解析参数并校验
PropertyList arguments = (*tool_iter)->properties();
for (auto& argument : arguments) {
// 类型匹配、范围校验...
}
// 3. 调度到主线程执行
app.Schedule([this, id, tool_iter, arguments]() {
try {
ReplyResult(id, (*tool_iter)->Call(arguments));
} catch (const std::exception& e) {
ReplyError(id, e.what());
}
});
}
4.4 工具执行结果
成功响应:
{
"session_id": "...",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{ "type": "text", "text": "true" }
],
"isError": false
}
}
}
错误响应:
{
"session_id": "...",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"id": 3,
"error": {
"message": "Unknown tool: self.non_existent_tool"
}
}
}
4.5 结果返回类型
工具的返回值支持多种类型(ReturnValue = std::variant<bool, int, std::string, cJSON*, ImageContent*>):
| 返回类型 | 序列化方式 |
|---|---|
bool | "true" 或 "false" |
int | 数字字符串,如 "42" |
std::string | 原始字符串 |
cJSON* | JSON 格式字符串 |
ImageContent* | Base64 编码的图片数据 |
五、完整交互时序图
sequenceDiagram
participant User as 用户
participant Device as ESP32 设备
participant Backend as 后台 API
participant LLM as 大语言模型
Note over Device, Backend: 阶段一:连接建立
Device->>Backend: WebSocket 连接(携带 Auth Header)
Device->>Backend: Hello 消息(features.mcp: true)
Backend->>Device: Server Hello(session_id)
Note over Device, Backend: 阶段二:MCP 会话初始化
Backend->>Device: MCP initialize(capabilities)
Device->>Backend: initialize 响应(serverInfo, protocolVersion)
Backend->>Device: MCP tools/list(cursor: "")
Device->>Backend: tools/list 响应(工具列表 + nextCursor)
opt 分页获取
Backend->>Device: MCP tools/list(cursor: "...")
Device->>Backend: tools/list 响应(更多工具)
end
Backend->>LLM: 注入工具定义到 LLM(function calling 格式)
Note over User, LLM: 阶段三:对话与工具调用
User->>Device: 语音输入:"帮我把音量调到50"
Device->>Backend: 音频流(Opus 编码)
Backend->>Backend: ASR 语音识别
Backend->>LLM: 用户文本 + 可用工具列表
LLM->>Backend: 决定调用 self.audio_speaker.set_volume(volume=50)
Backend->>Device: MCP tools/call(name, arguments)
Device->>Device: McpServer 执行工具(主线程)
Device->>Backend: 工具执行结果
Backend->>LLM: 工具执行结果
LLM->>Backend: 生成回复:"已将音量调整到50"
Backend->>Device: TTS 音频流
Device->>User: 播放语音回复
六、消息封装格式
所有 MCP 消息都封装在基础协议的消息体中,外层包含 session_id 和 type,内层 payload 是标准的 JSON-RPC 2.0 消息:
{
"session_id": "abc123",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": { ... },
"id": 1
}
}
发送路径(设备 → 后台):
McpServer::ReplyResult() / ReplyError()
→ Application::SendMcpMessage(payload)
→ Protocol::SendMcpMessage(payload)
→ 封装为 {"session_id":"...", "type":"mcp", "payload": ...}
→ WebSocket / MQTT SendText()
接收路径(后台 → 设备):
WebSocket OnData 回调
→ Protocol::on_incoming_json_
→ Application: 判断 type == "mcp"
→ McpServer::ParseMessage(payload)
→ 根据 method 分发:initialize / tools/list / tools/call
七、关键设计要点
7.1 工具优先级与 Prompt Cache
通用工具(AddCommonTools)被放在工具列表的最前面,利用 LLM 的 prompt cache 特性加速响应:
void McpServer::AddCommonTools() {
// 备份原有工具列表,先添加通用工具,再恢复原有工具
auto original_tools = std::move(tools_);
// ... 添加通用工具 ...
tools_.insert(tools_.end(), original_tools.begin(), original_tools.end());
}
7.2 线程安全
工具的实际执行通过 Application::Schedule() 调度到主线程(FreeRTOS 事件循环),避免多线程并发问题:
app.Schedule([this, id, tool_iter, arguments]() {
try {
ReplyResult(id, (*tool_iter)->Call(arguments));
} catch (const std::exception& e) {
ReplyError(id, e.what());
}
});
7.3 自定义工具扩展
各硬件板子可以在自己的 InitializeTools() 函数中注册自定义工具,例如机器人控制、灯光控制等:
// 示例:注册一个控制机器人前进的工具
mcp_server.AddTool("self.dog.forward", "机器人向前移动",
PropertyList(),
[this](const PropertyList&) -> ReturnValue {
servo_dog_ctrl_send(DOG_STATE_FORWARD, NULL);
return true;
});
7.4 参数类型与校验
工具参数支持三种类型,并可指定范围和默认值:
| 类型 | 枚举值 | 支持范围 | 支持默认值 |
|---|---|---|---|
| 布尔 | kPropertyTypeBoolean | 否 | 是 |
| 整数 | kPropertyTypeInteger | 是(min/max) | 是 |
| 字符串 | kPropertyTypeString | 否 | 是 |
八、相关源码文件
| 文件 | 说明 |
|---|---|
main/mcp_server.h | MCP 服务器头文件,定义 McpTool、Property、McpServer 等类 |
main/mcp_server.cc | MCP 服务器实现,工具注册、消息解析、工具调用 |
main/protocols/protocol.h | 协议基类,定义 SendMcpMessage 等接口 |
main/protocols/protocol.cc | 协议基类实现,MCP 消息封装 |
main/protocols/websocket_protocol.cc | WebSocket 协议实现,Hello 握手、消息收发 |
main/application.cc | 应用主逻辑,MCP 消息路由、工具初始化 |
评论区