第 6 章 设备端 MCP 协议:mcp_server.h / .cc
909 行(345 头 + 564 实现),这是本项目"让大模型能直接控制硬件"的桥梁。所有 LLM 通过云端协议下发的工具调用都从这里进入。
6.1 MCP 是什么、为什么要 MCP
MCP(Model Context Protocol) 是 2024 年底由社区推出的开放协议,规范了"工具调用"在 LLM 客户端和服务器之间的统一接口。设备端实现 MCP server 后,云端的 LLM(甚至来自不同厂商)只要支持 MCP 就能调用本设备的功能,比如:
- 调节扬声器音量
- 拍照并解释(Vision)
- 重启设备
- 切换屏幕主题
- 设备厂商扩展的"打开灯""开空调"等私有工具
不用 MCP 之前,云端要单独对接每个厂商私有的 JSON RPC;用了 MCP 之后,工具列表由设备端动态注册并通过 tools/list 返回,LLM 自己决定调用哪个 + 怎么传参。
为什么放在设备端而不是云端?因为有些动作(控制本地硬件、读取实时传感器)必须在设备端执行,绕一圈云就太慢。
底层传输怎么走?MCP 是个 JSON-RPC 2.0 协议,本项目把它封进 WebSocket / MQTT 现有通道——所有 MCP 消息都包在 {type: "mcp", payload: {...}} 的外层信封里。
┌─────────────────────┐
│ 云端 LLM │
│ (GPT/DeepSeek/...) │
│ + MCP Client │
└──────────┬──────────┘
│ MCP messages (JSON-RPC)
│ 包在 type:"mcp"
▼
┌─────────────────────┐
│ 设备代理(云上) │
│ Application │
│ server-side │
└──────────┬──────────┘
│ WebSocket / MQTT
▼
┌─────────────────────┐
│ ESP32 设备端 │
│ Application │
│ on_incoming_json │
│ "mcp" → payload │
│ ↓ │
│ McpServer │
│ ParseMessage │
│ 工具调用 │
└─────────────────────┘
设备端 McpServer 的工作 = 接收 JSON-RPC 报文 + 路由到工具 + 返回结果。
6.2 文件分层结构
mcp_server.h 把 4 个层级的类全堆在一个头文件里(350 行):
┌──────────────────────────────────────────────────┐
│ McpServer (单例) │
│ - 管理 tools_ │
│ - 解析 initialize / tools/list / tools/call │
│ - 通过 Application::SendMcpMessage 回复 │
└────────────────────┬─────────────────────────────┘
│ 拥有 vector
▼
┌──────────────────────────────────────────────────┐
│ McpTool │
│ name / description / PropertyList / callback │
│ to_json() —— 描述自己,给 LLM 看 │
│ Call() —— 执行 callback 并包装返回结果 │
└────────────────────┬─────────────────────────────┘
│ 拥有
▼
┌──────────────────────────────────────────────────┐
│ PropertyList │
│ vector<Property> │
│ GetRequired() / 下标访问 │
└────────────────────┬─────────────────────────────┘
│ 拥有
▼
┌──────────────────────────────────────────────────┐
│ Property │
│ name / type (bool/int/string) / │
│ value (variant) / min/max / default │
│ to_json() —— 输出 JSON Schema 片段 │
└──────────────────────────────────────────────────┘
附加:
┌──────────────────────────────────────────────────┐
│ ImageContent │
│ mime_type + base64 编码的图像数据 │
│ 用于 Vision 类工具返回结果 │
└──────────────────────────────────────────────────┘
6.3 Property 类逐行讲解
6.3.1 字段
class Property {
private:
std::string name_;
PropertyType type_;
std::variant<bool, int, std::string> value_;
bool has_default_value_;
std::optional<int> min_value_;
std::optional<int> max_value_;
};
std::variant<bool, int, std::string> —— C++17 引入,是类型安全的 union。能容纳 3 种类型之一,编译器追踪当前是哪种。配合 std::get<T>(value_) 和 std::holds_alternative<T>(value_) 做类型分发。
std::optional<int> —— C++17 引入,表达"可能没有值"的整型(用 has_value() 检查)。比 sentinel value(-1 表示无)干净得多。
PropertyType 是枚举三选一:boolean / integer / string。故意不支持 float——LLM 给浮点参数太容易出 corner case,整型 + 缩放因子更稳。
6.3.2 4 个构造函数
// 1) 必填字段 (无默认值, 无范围)
Property(const std::string& name, PropertyType type)
: name_(name), type_(type), has_default_value_(false) {}
// 2) 模板化的"有默认值"构造(不带范围限制)
template<typename T>
Property(const std::string& name, PropertyType type, const T& default_value)
: name_(name), type_(type), has_default_value_(true) {
value_ = default_value;
}
// 3) 整数必填 + 范围限制
Property(const std::string& name, PropertyType type, int min_value, int max_value)
: name_(name), type_(type), has_default_value_(false),
min_value_(min_value), max_value_(max_value) {
if (type != kPropertyTypeInteger) {
throw std::invalid_argument("Range limits only apply to integer properties");
}
}
// 4) 整数 + 默认值 + 范围
Property(const std::string& name, PropertyType type, int default_value, int min_value, int max_value)
: name_(name), type_(type), has_default_value_(true),
min_value_(min_value), max_value_(max_value) {
if (type != kPropertyTypeInteger) {
throw std::invalid_argument("Range limits only apply to integer properties");
}
if (default_value < min_value || default_value > max_value) {
throw std::invalid_argument("Default value must be within the specified range");
}
value_ = default_value;
}
四种语义:
| # | 用途 | 例子 |
|---|---|---|
| 1 | 必填、无范围 | Property("question", kPropertyTypeString) |
| 2 | 选填(带默认)、无范围 | Property("url", kPropertyTypeString, "http://default") |
| 3 | 必填、有范围 | Property("volume", kPropertyTypeInteger, 0, 100) |
| 4 | 选填、有默认 + 范围 | Property("quality", kPropertyTypeInteger, 80, 1, 100) |
构造函数里抛 std::invalid_argument 是编译期 + 运行时双重保护。
6.3.3 模板化 set_value + 范围检查
template<typename T>
inline void set_value(const T& value) {
if constexpr (std::is_same_v<T, int>) {
if (min_value_.has_value() && value < min_value_.value()) {
throw std::invalid_argument("Value is below minimum allowed: " + std::to_string(min_value_.value()));
}
if (max_value_.has_value() && value > max_value_.value()) {
throw std::invalid_argument("Value exceeds maximum allowed: " + std::to_string(max_value_.value()));
}
}
value_ = value;
}
if constexpr (std::is_same_v<T, int>) 是 C++17 的编译期 if——T 不是 int 时整个分支被编译器丢掉,零运行时开销。这种"模板里只对部分类型做事"的写法干净(旧 C++ 要写特化 / SFINAE)。
std::invalid_argument 让 tools/call 解析阶段就能捕获 + 上报错误。
6.3.4 to_json() —— 生成 JSON Schema 片段
std::string to_json() const {
cJSON *json = cJSON_CreateObject();
if (type_ == kPropertyTypeBoolean) {
cJSON_AddStringToObject(json, "type", "boolean");
if (has_default_value_) cJSON_AddBoolToObject(json, "default", value<bool>());
} else if (type_ == kPropertyTypeInteger) {
cJSON_AddStringToObject(json, "type", "integer");
if (has_default_value_) cJSON_AddNumberToObject(json, "default", value<int>());
if (min_value_.has_value()) cJSON_AddNumberToObject(json, "minimum", min_value_.value());
if (max_value_.has_value()) cJSON_AddNumberToObject(json, "maximum", max_value_.value());
} else if (type_ == kPropertyTypeString) {
cJSON_AddStringToObject(json, "type", "string");
if (has_default_value_) cJSON_AddStringToObject(json, "default", value<std::string>().c_str());
}
// ...
}
输出形如:
{ "type": "integer", "default": 80, "minimum": 1, "maximum": 100 }
这是 JSON Schema 的最小子集——LLM 拿到后就知道该参数的取值约束,能自动校验和生成合法的调用。
6.4 PropertyList 容器
class PropertyList {
private:
std::vector<Property> properties_;
public:
PropertyList() = default;
PropertyList(const std::vector<Property>& properties) : properties_(properties) {}
void AddProperty(const Property& property) { properties_.push_back(property); }
const Property& operator[](const std::string& name) const {
for (const auto& property : properties_) {
if (property.name() == name) return property;
}
throw std::runtime_error("Property not found: " + name);
}
auto begin() { return properties_.begin(); }
auto end() { return properties_.end(); }
std::vector<std::string> GetRequired() const {
std::vector<std::string> required;
for (auto& property : properties_) {
if (!property.has_default_value()) required.push_back(property.name());
}
return required;
}
std::string to_json() const { /* 调用每个 property.to_json() */ }
};
关键设计:
- 下标按 name 而不是 index 访问——可读性 > 性能(属性数通常 1-3 个,线性查找够用);
begin/end让PropertyList可以for (auto& argument : arguments)(C++ range-for 仅需要 begin/end);GetRequired()把没有默认值的属性名挑出来,放进inputSchema.required字段(这是 MCP 强制要求的格式);- 构造支持初始化列表风格
PropertyList({ Property(...), Property(...) })。
6.5 McpTool —— 工具元数据 + 执行器
6.5.1 字段与构造
class McpTool {
private:
std::string name_;
std::string description_;
PropertyList properties_;
std::function<ReturnValue(const PropertyList&)> callback_;
bool user_only_ = false;
public:
McpTool(const std::string& name,
const std::string& description,
const PropertyList& properties,
std::function<ReturnValue(const PropertyList&)> callback)
: name_(name), description_(description), properties_(properties), callback_(callback) {}
};
注意 ReturnValue 这个别名:
using ReturnValue = std::variant<bool, int, std::string, cJSON*, ImageContent*>;
5 种可能的返回类型:
bool:成功 / 失败的工具(set_volume 之类);int:返回数值(如电量百分比);std::string:返回文本(如 OCR 结果、状态描述);cJSON*:返回结构化数据(如 GetDeviceStatusJson);ImageContent*:返回图片(如拍照)。
注意是裸指针 cJSON* 和 ImageContent*——所有权转给 McpTool::Call,由 Call 内部释放(这是有点"反 RAII"的设计,但因为返回类型不固定+性能考虑,用 std::unique_ptr 跟 variant 混合会有些麻烦)。
6.5.2 user_only_ 含义
void set_user_only(bool user_only) { user_only_ = user_only; }
inline bool user_only() const { return user_only_; }
user_only_ = true 的工具对 LLM 不可见——只有客户端的"用户控制台"能调用(reboot / upgrade_firmware / screen.snapshot 等)。安全考虑:不能让 LLM 自己决定重启 / 升级固件。在 to_json() 里通过 annotations.audience = ["user"] 标注:
if (user_only_) {
cJSON *annotations = cJSON_CreateObject();
cJSON *audience = cJSON_CreateArray();
cJSON_AddItemToArray(audience, cJSON_CreateString("user"));
cJSON_AddItemToObject(annotations, "audience", audience);
cJSON_AddItemToObject(json, "annotations", annotations);
}
而且在 GetToolsList 里默认不返回这些工具(除非显式带 withUserTools: true)。
6.5.3 to_json() —— 完整工具描述
std::string to_json() const {
std::vector<std::string> required = properties_.GetRequired();
cJSON *json = cJSON_CreateObject();
cJSON_AddStringToObject(json, "name", name_.c_str());
cJSON_AddStringToObject(json, "description", description_.c_str());
cJSON *input_schema = cJSON_CreateObject();
cJSON_AddStringToObject(input_schema, "type", "object");
cJSON *properties = cJSON_Parse(properties_.to_json().c_str());
cJSON_AddItemToObject(input_schema, "properties", properties);
if (!required.empty()) {
cJSON *required_array = cJSON_CreateArray();
for (const auto& property : required) {
cJSON_AddItemToArray(required_array, cJSON_CreateString(property.c_str()));
}
cJSON_AddItemToObject(input_schema, "required", required_array);
}
cJSON_AddItemToObject(json, "inputSchema", input_schema);
// ... (user_only annotations)
}
输出形如:
{
"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"]
}
}
LLM 读到这段就知道:要传 volume 整数、范围 0-100、必填。
6.5.4 Call() —— 执行工具并包装结果
std::string Call(const PropertyList& properties) {
ReturnValue return_value = callback_(properties);
cJSON* result = cJSON_CreateObject();
cJSON* content = cJSON_CreateArray();
if (std::holds_alternative<ImageContent*>(return_value)) {
auto image_content = std::get<ImageContent*>(return_value);
cJSON* image = cJSON_CreateObject();
cJSON_AddStringToObject(image, "type", "image");
cJSON_AddStringToObject(image, "image", image_content->to_json().c_str());
cJSON_AddItemToArray(content, image);
delete image_content;
} else {
cJSON* text = cJSON_CreateObject();
cJSON_AddStringToObject(text, "type", "text");
if (std::holds_alternative<std::string>(return_value)) {
cJSON_AddStringToObject(text, "text", std::get<std::string>(return_value).c_str());
} else if (std::holds_alternative<bool>(return_value)) {
cJSON_AddStringToObject(text, "text", std::get<bool>(return_value) ? "true" : "false");
} else if (std::holds_alternative<int>(return_value)) {
cJSON_AddStringToObject(text, "text", std::to_string(std::get<int>(return_value)).c_str());
} else if (std::holds_alternative<cJSON*>(return_value)) {
cJSON* json = std::get<cJSON*>(return_value);
char* json_str = cJSON_PrintUnformatted(json);
cJSON_AddStringToObject(text, "text", json_str);
cJSON_free(json_str);
cJSON_Delete(json);
}
cJSON_AddItemToArray(content, text);
}
cJSON_AddItemToObject(result, "content", content);
cJSON_AddBoolToObject(result, "isError", false);
// ...
}
要点:
- 调用 callback 拿到 ReturnValue;
- 根据 variant 当前持有的类型分发包装:
- ImageContent →
{type:"image", image:"..."}并delete image_content(消费所有权); - 其它 →
{type:"text", text:"..."};
- ImageContent →
- 包装成 MCP 标准的
content数组(注意是数组,可以多个内容混排); - 加
isError: false标记成功; - 返回完整的 JSON 字符串。
异常路径在 DoToolCall 里 catch(见 6.7.6)。
6.6 ImageContent —— 图像返回结果封装
class ImageContent {
private:
std::string encoded_data_;
std::string mime_type_;
static std::string Base64Encode(const std::string& data) {
size_t dlen = 0, olen = 0;
// 第一次调用:传 nullptr 让 mbedtls 计算需要多大 buffer,写到 dlen
mbedtls_base64_encode((unsigned char*)nullptr, 0, &dlen,
(const unsigned char*)data.data(), data.size());
std::string result(dlen, 0);
// 第二次调用:真正写入 buffer
mbedtls_base64_encode((unsigned char*)result.data(), result.size(), &olen,
(const unsigned char*)data.data(), data.size());
return result;
}
public:
ImageContent(const std::string& mime_type, const std::string& data) {
mime_type_ = mime_type;
encoded_data_ = Base64Encode(data);
}
std::string to_json() const { /* {"type":"image","mimeType":...,"data":<base64>} */ }
};
两次调用 mbedtls_base64_encode 的惯用法:
- 第一次:传空 buffer → mbedtls 把所需字节数写进
dlen,但不写数据(这是 mbedtls 标准模式:alloc-size-first); - 第二次:分配好 buffer 再调用真正写入。
避免预估错 buffer 大小(base64 输出长度大致是输入的 4/3,再加换行符)。
6.7 McpServer —— 核心调度器
6.7.1 单例 + 构造析构
class McpServer {
public:
static McpServer& GetInstance() {
static McpServer instance;
return instance;
}
private:
McpServer();
~McpServer();
std::vector<McpTool*> tools_;
};
McpServer::McpServer() {}
McpServer::~McpServer() {
for (auto tool : tools_) delete tool;
tools_.clear();
}
Meyers 单例(line 316-319):static 局部变量在 C++11 起线程安全。
工具用裸指针 + 手动 delete——是历史代码 / 性能权衡的产物。如果重写,会用 std::vector<std::unique_ptr<McpTool>>。
6.7.2 AddCommonTools() —— 注册"对 LLM 可见"的工具
被 Application::Initialize() 在启动末尾调用:
void McpServer::AddCommonTools() {
auto original_tools = std::move(tools_); // ★ 先把已注册的板级私有工具备份
auto& board = Board::GetInstance();
// 工具 1: 设备状态查询
AddTool("self.get_device_status",
"Provides the real-time information of the device, ...",
PropertyList(),
[&board](const PropertyList& properties) -> ReturnValue {
return board.GetDeviceStatusJson(); // 返回 cJSON*
});
// 工具 2: 音量控制
AddTool("self.audio_speaker.set_volume",
"Set the volume of the audio speaker. ...",
PropertyList({ Property("volume", kPropertyTypeInteger, 0, 100) }),
[&board](const PropertyList& properties) -> ReturnValue {
auto codec = board.GetAudioCodec();
codec->SetOutputVolume(properties["volume"].value<int>());
return true;
});
auto backlight = board.GetBacklight();
if (backlight) {
AddTool("self.screen.set_brightness", ...);
}
#ifdef HAVE_LVGL
auto display = board.GetDisplay();
if (display && display->GetTheme() != nullptr) {
AddTool("self.screen.set_theme", ...);
}
auto camera = board.GetCamera();
if (camera) {
AddTool("self.camera.take_photo", ...);
}
#endif
// ★ 把板级私有工具 append 到末尾
tools_.insert(tools_.end(), original_tools.begin(), original_tools.end());
}
两段式注册的精妙(line 39 / line 125):
- 板级
InitializeTools()已经把板子私有工具加进tools_(在 board.cc 里); AddCommonTools先用std::move(tools_)把它们暂存到original_tools;- 注册通用工具(按顺序 push 到现在空的
tools_); - 最后把暂存的 append 回去。
为什么把通用工具放前面?注释明确说了——利用 prompt cache。大模型给同样的 system prompt 时会缓存计算,工具列表越前面越固定,缓存命中率越高,响应越快。私有工具因板而异,放后面就只破坏后面的缓存。
take_photo 工具特别有意思:
[camera](const PropertyList& properties) -> ReturnValue {
TaskPriorityReset priority_reset(1); // ★ 临时降低优先级
if (!camera->Capture()) {
throw std::runtime_error("Failed to capture photo");
}
auto question = properties["question"].value<std::string>();
return camera->Explain(question); // 返回 string(云端的视觉结果)
}
TaskPriorityReset 是 2 章 2.5.3 见过的 RAII helper——拍照耗时的 JPEG 编码不阻塞主任务和音频任务。camera->Explain 内部走 HTTP 上传图到 vision URL(ParseCapabilities 见 6.7.4 设置的),返回 LLM 看图描述。
6.7.3 AddUserOnlyTools() —— 注册"对用户客户端可见"的工具
被设备厂商的"用户控制台 APP"调用(不是 LLM):
void McpServer::AddUserOnlyTools() {
// 系统信息
AddUserOnlyTool("self.get_system_info", "Get the system information",
PropertyList(),
[this](const PropertyList& properties) -> ReturnValue {
return Board::GetInstance().GetSystemInfoJson();
});
// 重启
AddUserOnlyTool("self.reboot", "Reboot the system",
PropertyList(),
[this](const PropertyList& properties) -> ReturnValue {
auto& app = Application::GetInstance();
app.Schedule([&app]() {
vTaskDelay(pdMS_TO_TICKS(1000)); // 给响应回去的时间
app.Reboot();
});
return true;
});
// 固件升级
AddUserOnlyTool("self.upgrade_firmware", "Upgrade firmware from a specific URL. ...",
PropertyList({ Property("url", kPropertyTypeString, "...") }),
[this](const PropertyList& properties) -> ReturnValue {
auto url = properties["url"].value<std::string>();
auto& app = Application::GetInstance();
app.Schedule([url, &app]() {
bool success = app.UpgradeFirmware(url);
if (!success) ESP_LOGE(TAG, "Firmware upgrade failed");
});
return true;
});
// 屏幕信息 / 截图 / 预览(仅 LVGL)
#ifdef HAVE_LVGL
auto display = dynamic_cast<LvglDisplay*>(Board::GetInstance().GetDisplay());
if (display) {
AddUserOnlyTool("self.screen.get_info", ...);
#if CONFIG_LV_USE_SNAPSHOT
AddUserOnlyTool("self.screen.snapshot", ...);
AddUserOnlyTool("self.screen.preview_image", ...);
#endif
}
#endif
// 资源下载 URL
auto& assets = Assets::GetInstance();
if (assets.partition_valid()) {
AddUserOnlyTool("self.assets.set_download_url", "Set the download url for the assets",
PropertyList({ Property("url", kPropertyTypeString) }),
[](const PropertyList& properties) -> ReturnValue {
auto url = properties["url"].value<std::string>();
Settings settings("assets", true);
settings.SetString("download_url", url);
return true;
});
}
}
要点:
- 重启 / 升级用
app.Schedule推到主循环——MCP 调用线程不能直接重启自己; dynamic_cast<LvglDisplay*>检测是不是 LVGL 显示(不是的话 SnapshotToJpeg 不存在);snapshot工具实现了完整的multipart/form-dataHTTP POST:手写 boundary、文件字段头、JPEG 数据、尾部分隔符;preview_image先 HTTP GET 拿 JPEG/PNG 数据到 PSRAM(heap_caps_malloc(content_length, MALLOC_CAP_8BIT)),再交给 LVGL 显示。
6.7.4 ParseCapabilities() —— 服务器告诉设备它能怎么用 Vision
void McpServer::ParseCapabilities(const cJSON* capabilities) {
auto vision = cJSON_GetObjectItem(capabilities, "vision");
if (cJSON_IsObject(vision)) {
auto url = cJSON_GetObjectItem(vision, "url");
auto token = cJSON_GetObjectItem(vision, "token");
if (cJSON_IsString(url)) {
auto camera = Board::GetInstance().GetCamera();
if (camera) {
std::string url_str = std::string(url->valuestring);
std::string token_str;
if (cJSON_IsString(token)) token_str = std::string(token->valuestring);
camera->SetExplainUrl(url_str, token_str);
}
}
}
}
在 initialize 消息里,云端会传 capabilities.vision.url 和 token 给设备——这样 take_photo 工具内部就知道 JPEG 该上传到哪里。
6.7.5 ParseMessage() —— MCP 主入口
被 Application 在 on_incoming_json_ 收到 type=mcp 时调用:
void McpServer::ParseMessage(const cJSON* json) {
// 1. 校验 JSONRPC 版本
auto version = cJSON_GetObjectItem(json, "jsonrpc");
if (version == nullptr || !cJSON_IsString(version) || strcmp(version->valuestring, "2.0") != 0) {
ESP_LOGE(TAG, "Invalid JSONRPC version");
return;
}
// 2. method 必填
auto method = cJSON_GetObjectItem(json, "method");
if (method == nullptr || !cJSON_IsString(method)) return;
auto method_str = std::string(method->valuestring);
// 3. notifications 类(无 id)直接忽略
if (method_str.find("notifications") == 0) return;
// 4. params 可选但若存在必须是 object
auto params = cJSON_GetObjectItem(json, "params");
if (params != nullptr && !cJSON_IsObject(params)) return;
// 5. id 必须是 number
auto id = cJSON_GetObjectItem(json, "id");
if (id == nullptr || !cJSON_IsNumber(id)) return;
auto id_int = id->valueint;
// 6. 路由
if (method_str == "initialize") {
if (cJSON_IsObject(params)) {
auto capabilities = cJSON_GetObjectItem(params, "capabilities");
if (cJSON_IsObject(capabilities)) ParseCapabilities(capabilities);
}
auto app_desc = esp_app_get_description();
std::string message = "{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"" BOARD_NAME "\",\"version\":\"";
message += app_desc->version;
message += "\"}}";
ReplyResult(id_int, message);
} else if (method_str == "tools/list") {
std::string cursor_str = "";
bool list_user_only_tools = false;
if (params != nullptr) {
auto cursor = cJSON_GetObjectItem(params, "cursor");
if (cJSON_IsString(cursor)) cursor_str = cursor->valuestring;
auto with_user_tools = cJSON_GetObjectItem(params, "withUserTools");
if (cJSON_IsBool(with_user_tools)) list_user_only_tools = with_user_tools->valueint == 1;
}
GetToolsList(id_int, cursor_str, list_user_only_tools);
} else if (method_str == "tools/call") {
if (!cJSON_IsObject(params)) { ReplyError(id_int, "Missing params"); return; }
auto tool_name = cJSON_GetObjectItem(params, "name");
if (!cJSON_IsString(tool_name)) { ReplyError(id_int, "Missing name"); return; }
auto tool_arguments = cJSON_GetObjectItem(params, "arguments");
if (tool_arguments != nullptr && !cJSON_IsObject(tool_arguments)) {
ReplyError(id_int, "Invalid arguments");
return;
}
DoToolCall(id_int, std::string(tool_name->valuestring), tool_arguments);
} else {
ReplyError(id_int, "Method not implemented: " + method_str);
}
}
支持的 3 个 method:
| method | 用途 |
|---|---|
initialize | 客户端首次握手,交换协议版本和能力 |
tools/list | 列出所有可用工具(支持分页) |
tools/call | 调用某个工具 |
忽略 notifications/*——MCP 规范里 notifications 是单向无响应消息,设备端不处理任何(因为目前没有需要主动接收的通知场景)。
initialize 返回的内容:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"serverInfo": {
"name": "<BOARD_NAME>",
"version": "<app_desc->version>"
}
}
}
宏 BOARD_NAME 由 Kconfig 注入(如 "xiaozhi-s3-cardputer"),app_desc->version 是 esp_app_format 自动生成的固件版本(git tag)。
6.7.6 GetToolsList() —— 分页输出工具清单
void McpServer::GetToolsList(int id, const std::string& cursor, bool list_user_only_tools) {
const int max_payload_size = 8000;
std::string json = "{\"tools\":[";
bool found_cursor = cursor.empty();
auto it = tools_.begin();
std::string next_cursor = "";
while (it != tools_.end()) {
if (!found_cursor) {
if ((*it)->name() == cursor) {
found_cursor = true;
} else {
++it;
continue;
}
}
if (!list_user_only_tools && (*it)->user_only()) {
++it;
continue;
}
std::string tool_json = (*it)->to_json() + ",";
if (json.length() + tool_json.length() + 30 > max_payload_size) {
next_cursor = (*it)->name();
break;
}
json += tool_json;
++it;
}
if (json.back() == ',') json.pop_back();
if (json.back() == '[' && !tools_.empty()) {
ESP_LOGE(TAG, "tools/list: Failed to add tool %s because of payload size limit", next_cursor.c_str());
ReplyError(id, "Failed to add tool " + next_cursor + " because of payload size limit");
return;
}
if (next_cursor.empty()) {
json += "]}";
} else {
json += "],\"nextCursor\":\"" + next_cursor + "\"}";
}
ReplyResult(id, json);
}
为什么要分页?
- WebSocket / MQTT 单次消息体积有上限(这里硬限 8000 字节);
- 一些板子注册了 20+ 工具,全发会爆栈或被网关丢弃;
- MCP 协议规范里
nextCursor是标准分页字段。
算法:
cursor空 → 从头开始;cursor非空 → 跳到name == cursor的位置开始(继续上次未完成的列表);- 边累加 JSON 边算长度,预留 30 字节给结尾结构;
- 加之前发现要超 → 设
nextCursor = 当前工具名并停; - 如果一个工具都没加上去(单个工具大到超 8000)→ 报错;
- 加
nextCursor字段或不加。
LLM 收到带 nextCursor 的响应会再发一次 tools/list 带这个 cursor 拉下一页。
6.7.7 DoToolCall() —— 工具调用 + 异步执行
void McpServer::DoToolCall(int id, const std::string& tool_name, const cJSON* tool_arguments) {
auto tool_iter = std::find_if(tools_.begin(), tools_.end(),
[&tool_name](const McpTool* tool) {
return tool->name() == tool_name;
});
if (tool_iter == tools_.end()) {
ReplyError(id, "Unknown tool: " + tool_name);
return;
}
PropertyList arguments = (*tool_iter)->properties();
try {
for (auto& argument : arguments) {
bool found = false;
if (cJSON_IsObject(tool_arguments)) {
auto value = cJSON_GetObjectItem(tool_arguments, argument.name().c_str());
if (argument.type() == kPropertyTypeBoolean && cJSON_IsBool(value)) {
argument.set_value<bool>(value->valueint == 1);
found = true;
} else if (argument.type() == kPropertyTypeInteger && cJSON_IsNumber(value)) {
argument.set_value<int>(value->valueint);
found = true;
} else if (argument.type() == kPropertyTypeString && cJSON_IsString(value)) {
argument.set_value<std::string>(value->valuestring);
found = true;
}
}
if (!argument.has_default_value() && !found) {
ReplyError(id, "Missing valid argument: " + argument.name());
return;
}
}
} catch (const std::exception& e) {
ReplyError(id, e.what());
return;
}
// Use main thread to call the tool
auto& app = Application::GetInstance();
app.Schedule([this, id, tool_iter, arguments = std::move(arguments)]() {
try {
ReplyResult(id, (*tool_iter)->Call(arguments));
} catch (const std::exception& e) {
ReplyError(id, e.what());
}
});
}
完整流程:
- 找工具:
std::find_if按 name 查;找不到立即回错。 - 拷贝默认参数模板:
PropertyList arguments = (*tool_iter)->properties();——拷贝一份模板,再覆盖具体值。为什么拷贝?避免多个同时调用互相覆盖,并发安全。 - 填充参数 + 类型校验:遍历每个参数,从
tool_arguments里按名字找,类型必须严格匹配(boolean ↔ bool / integer ↔ number / string ↔ string);未找到且无默认值 → 报错。set_value内部还会做范围检查,越界抛std::invalid_argument。 - catch + ReplyError:任何参数异常立即回错,不进入 callback。
- 异步执行:用
Schedule推到主循环跑 callback。arguments = std::move(arguments)是 C++14 init capture——把 lambda 内的 arguments 用 move 构造,避免再拷贝一份。 - callback 内异常也 catch:例如
take_photo拍照失败抛 runtime_error。
为什么所有 callback 在主循环跑?
- 工具可能动 LVGL / 显示 / Audio 这些非线程安全的子系统;
- 多个工具调用要串行化,不能并发(比如同时调两个
set_volume); - 错误日志和上报统一在一处。
6.7.8 ReplyResult() / ReplyError()
void McpServer::ReplyResult(int id, const std::string& result) {
std::string payload = "{\"jsonrpc\":\"2.0\",\"id\":";
payload += std::to_string(id) + ",\"result\":";
payload += result;
payload += "}";
Application::GetInstance().SendMcpMessage(payload);
}
void McpServer::ReplyError(int id, const std::string& message) {
std::string payload = "{\"jsonrpc\":\"2.0\",\"id\":";
payload += std::to_string(id);
payload += ",\"error\":{\"message\":\"";
payload += message;
payload += "\"}}";
Application::GetInstance().SendMcpMessage(payload);
}
手写字符串拼接而不是 cJSON:因为 result 已经是合法 JSON 字符串,直接嵌入比 cJSON 解析再序列化快得多。Application::SendMcpMessage 内部再包一层 {type:mcp, payload: ...} 信封发出去。
6.8 一次完整的工具调用时序
以"调音量到 70"为例:
LLM (云端) Application McpServer
│ │ │
│ 1. user: "音量调到70" │ │
│ (语音 → ASR → text) │ │
│ │ │
│ 2. LLM 决定调用工具: │ │
│ tools/call name=set_volume │ │
│ arguments={volume:70} │ │
│ │ │
│── MCP JSON-RPC ─────────────────►│ │
│ {type:"mcp",payload:{...}} │ │
│ │ │
│ │ 3. on_incoming_json: │
│ │ "mcp" → ParseMessage() │
│ │──────────────────────────────► │
│ │ │
│ │ │ 4. find tool "set_volume"
│ │ │ 5. 拷贝 properties 模板
│ │ │ 6. 解析 volume=70
│ │ │ 7. set_value(70)
│ │ │ - 范围检查:0<=70<=100
│ │ │ 8. Application.Schedule(...)
│ │ ◄─────────────────────────────│
│ │ │
│ │ 9. 主循环跑 callback: │
│ │ codec->SetOutputVolume(70) │
│ │ → 写 ES8311 寄存器 │
│ │ → return true │
│ │ │
│ │ 10. Call() 把 true 包成 │
│ │ {content:[{type:"text", │
│ │ text:"true"}], │
│ │ isError:false} │
│ │ │
│ │ 11. ReplyResult → │
│ │ SendMcpMessage payload │
│ ◄── {type:"mcp",payload: │ │
│ {jsonrpc:2.0,id:N, │ │
│ result:{content:[...]}}} ──── │ │
│ │ │
│ 12. LLM 收到 success → │ │
│ 生成回复 "已调到70" │ │
│ → TTS → 设备说话 │ │
6.9 本章用到的关键 C++ 技术
| 技术 | 应用 |
|---|---|
std::variant<...> + std::holds_alternative + std::get<T> | Property::value_, ReturnValue 多类型容器 |
std::optional<int> | min/max 可空范围限制 |
std::function<ReturnValue(const PropertyList&)> | 工具回调统一签名 |
if constexpr (std::is_same_v<T, int>) | 编译期分支 |
| 模板构造 + SFINAE 替代 | Property 的多 ctor 重载 |
| range-for + begin/end | for (auto& arg : arguments) |
std::find_if + lambda | 工具查找 |
std::move(tools_) + insert | AddCommonTools 两段式注册 |
init capture arguments = std::move(arguments) | C++14 lambda 移动捕获 |
| try/catch 双层 | 参数解析期 + callback 执行期分别捕获 |
| mbedtls Base64 encode (两步法) | 图像 base64 |
| dynamic_cast 检测子类型 | 区分 LvglDisplay / OledDisplay |
heap_caps_malloc(MALLOC_CAP_8BIT) | PSRAM 上分配大块图像缓冲 |
| 手写 multipart/form-data | snapshot 工具的 HTTP 上传 |
| Meyers 单例(静态局部) | McpServer::GetInstance |
6.10 设计哲学小结
- JSON Schema 描述自己:每个 Property → 一片 schema;每个 Tool → 完整 inputSchema。让 LLM 自助理解工具能力,不需要 prompt 里硬编码。
- prompt cache 友好:通用工具放前面,板级工具放后面,最大化命中云端 LLM 的 prompt cache。
- user-only 权限分级:危险操作(reboot / upgrade)对 LLM 不可见,只暴露给厂商客户端。
- 异步执行 + 主线程串行化:所有工具 callback 都 Schedule 到主循环跑,避免并发问题。
- 强类型 + 范围限制 + 默认值:边界全在 Property 类内固化,调用方不可能传出错的值进 callback。
- 分页 + 8000 字节硬限:兼顾大型工具集 + 受限传输通道。
- 错误两阶段捕获:参数解析期 / callback 执行期两次 try/catch,错误码精准。
6.11 看完本章你应该掌握的
- MCP 是什么、为什么放在设备端
- 4 层类结构(Property → PropertyList → McpTool → McpServer)
- Property 4 种构造场景对应的语义
std::variant/std::optional的实战用法- ReturnValue 5 种类型的包装方式
user_only_工具的权限隔离AddCommonTools两段式注册的精妙(prompt cache)- initialize / tools/list / tools/call 三种 method 完整处理
- 分页机制 + 8000 字节硬限
- DoToolCall 的两阶段异常捕获
- ImageContent 用两步 base64 编码
- snapshot 工具的 multipart/form-data 手写实现
下一章覆盖 ota / assets / settings / system_info 系统服务层。