1. 从零写好的 C++ MCP 服务器,为什么 endpoint 一改就报错
你大概率已经写过一遍 MCP 服务器了:std::variant装内容块、register_tool注册工具、stdio 或 HTTP 传输层二选一,本地跑起来initialize握手也过了。然后你想把它接到一个统一的模型通道上,让自研的 MCP 服务不用为每个模型平台各写一套适配,于是动手改 endpoint——结果要么是initialize直接超时,要么是工具调用返回reading 'choices'之类的字段错误,要么干脆连不上。
这个场景我太熟了。MCP 本身是「AI 与外部世界的 USB-C 接口」,协议层是 JSON-RPC 2.0,传输层可以是 stdio 也可以是 HTTP。问题往往不出在协议实现,而出在你把 endpoint 指向哪里、用什么鉴权头、模型 ID 怎么传这三件事上。C++ 侧不像 Python/Node 有现成的 SDK 帮你兜底,HTTP 客户端、header 拼装、超时重试全得自己写,任何一个字段错了,表现都是「连不上」或者「连上了但模型不认」。
这篇就聚焦一件事:用 C++ 从零实现的 MCP 服务器,如何把服务端 endpoint 稳定指向 TaoToken 的统一 Key/API 通道,并跑通一次完整请求。适合谁?适合已经能编译运行一个 MCP server、手里有 CMake 工程、想把它接进本地工具链联调的人。如果你还没写过 MCP server,也能跟着走,因为我会把 endpoint 配置、编译、验证拆成可复制的步骤。
先说清楚 TaoToken 在这里扮演什么角色。它是一个统一的模型 API 通道,对外暴露 OpenAI 兼容的/v1/chat/completions等接口,你拿一个 Key 就能调用多个模型。对 C++ MCP 服务器来说,它的价值在于:你的 server 内部如果要调用模型(比如某个 tool 需要让模型做一次推理),不用为每个模型厂商写不同的 HTTP 客户端和鉴权逻辑,统一走一个 Base URL + 一个 Key + 一个 Model ID 就行。官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
核心检索词先摆出来:C++ MCP 服务器 endpoint 配置、MCP 服务器接入统一 API 通道、C++ HTTP 客户端调用模型接口。这三个词贯穿全文,你搜的时候也大概率是这几个方向。
我踩过的坑里,最常见的是把 endpoint 写成了带/v1又拼了一次/v1,变成/v1/v1/chat/completions,服务端返回 404,但 C++ 的 HTTP 库只给你一个空 body,你以为是网络问题。还有一种是把Authorization写成了Bearer: sk-xxx(多了冒号),或者 header 名写成api-key,结果 401。这些都会在第五节详细对照。
下面按「原问题 → 前置准备 → 可复制配置 → 验证 → 排错 → 下一步」的顺序走。你可以直接跳到第 3 节拿配置片段,但建议先看完第 2 节,因为 Key 和 Model ID 的获取方式决定了你后面配置里填什么。
2. 接入前的前置准备:Key、Model ID 与 C++ 工程依赖
在改 endpoint 之前,有三样东西必须先拿到手,否则配置片段里全是占位符,编译过了也跑不通。
第一样是 API Key。打开 https://taotoken.net/api-keys ,登录后创建一个 Key。注意两点:一是 Key 只在创建时完整显示一次,复制下来存好;二是不要把它硬编码进提交到 git 的源码里,后面我会给一个从环境变量读取的写法。Key 的格式通常是sk-开头的一串字符。
第二样是 Model ID。这个不是随便填的,必须是你账号下可用的模型标识。去 https://taotoken.net/models 或者模型对话页面 https://taotoken.net/chat 看一眼当前可选的模型列表,把你要用的那个 ID 记下来,比如常见的对话模型 ID。C++ 侧请求体里的"model"字段必须和这个 ID 完全一致,大小写、连字符都不能错,否则会返回模型不存在的错误。
第三样是 C++ 工程依赖。MCP 服务器本身如果只需要 stdio 传输,其实不依赖 HTTP;但你要把 endpoint 指向 TaoToken,就意味着 server 内部要发起 HTTP 请求,所以需要一个 HTTP 客户端库。我推荐两种:
- libcurl:跨平台、成熟、CMake 里
find_package(CURL REQUIRED)就能用。缺点是 API 偏 C 风格,回调写法对新手不友好。 - cpp-httplib:header-only,扔进
third_party/就能编译,同步接口写起来像 Python。缺点是 HTTPS 需要链接 OpenSSL。
如果你只是本地联调,cpp-httplib 上手最快。下面配置片段我以 cpp-httplib 为主,libcurl 的写法在排错节会补一句。
CMake 里大致这样引入:
cmake_minimum_required(VERSION 3.16) project(mcp_server CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # cpp-httplib 是 header-only,直接 include 目录 add_executable(mcp_server src/main.cpp src/mcp_server.cpp src/llm_client.cpp ) target_include_directories(mcp_server PRIVATE third_party) target_link_libraries(mcp_server PRIVATE OpenSSL::SSL OpenSSL::Crypto Threads::Threads)注意CMAKE_CXX_STANDARD 17,因为 MCP 实现里常用std::optional、std::variant、结构化绑定,这些是 C++17 起步。如果你用了std::span之类,就升到 20。
环境变量这块,建议在 shell 里先导出,程序里用std::getenv读:
export TAOTOKEN_API_KEY="sk-你的Key" export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_MODEL_ID="你的模型ID"这样你的 C++ 代码里就不用出现明文 Key,联调时换 Key 也不用重新编译。这一步做完,前置就齐了。接下来进入正题:endpoint 到底怎么配。
3. 可复制的 endpoint 配置:把 MCP 服务端指向 TaoToken
这一节是全文的核心,给你可以直接抄的配置片段。分三块:C++ 代码里的 endpoint 常量、请求体 JSON、以及如果你用配置文件(JSON/TOML)该怎么写。
先说 endpoint 的拼接规则。TaoToken 的 API 入口是https://taotoken.net/api,OpenAI 兼容的对话补全路径是/v1/chat/completions。所以完整 URL 是:
https://taotoken.net/api/v1/chat/completions关键点:Base URL 里不要带/v1,路径里带/v1。很多人把 Base URL 写成https://taotoken.net/api/v1,然后路径又拼/v1/chat/completions,就变成/api/v1/v1/chat/completions,404。这个坑我在第五节会再强调一次。
C++ 侧我建议把 endpoint 拆成 base 和 path 两个常量,避免手滑:
// src/llm_client.h #pragma once #include <string> #include <httplib.h> #include <nlohmann/json.hpp> class LlmClient { public: LlmClient(); // 返回模型回复的文本内容;失败时返回空字符串并打印错误 std::string chat(const std::string& user_prompt); private: std::string base_url_; // https://taotoken.net/api std::string api_key_; // 从环境变量读取 std::string model_id_; // 从环境变量读取 static constexpr const char* kChatPath = "/v1/chat/completions"; };实现里读环境变量并拼 URL:
// src/llm_client.cpp #include "llm_client.h" #include <cstdlib> #include <iostream> LlmClient::LlmClient() { const char* key = std::getenv("TAOTOKEN_API_KEY"); const char* base = std::getenv("TAOTOKEN_BASE_URL"); const char* model = std::getenv("TAOTOKEN_MODEL_ID"); api_key_ = key ? key : ""; base_url_ = base ? base : "https://taotoken.net/api"; model_id_ = model ? model : ""; if (api_key_.empty() || model_id_.empty()) { std::cerr << "[LlmClient] 缺少 TAOTOKEN_API_KEY 或 TAOTOKEN_MODEL_ID\n"; } } std::string LlmClient::chat(const std::string& user_prompt) { // 从 base_url_ 里拆出 host 和 scheme,cpp-httplib 需要分开传 // 这里假设 base_url_ 形如 https://taotoken.net/api httplib::Client cli("https://taotoken.net"); cli.set_connection_timeout(10, 0); // 10 秒连接超时 cli.set_read_timeout(60, 0); // 60 秒读超时,模型推理可能慢 nlohmann::json body = { {"model", model_id_}, {"messages", nlohmann::json::array({ {{"role", "user"}, {"content", user_prompt}} })}, {"temperature", 0.7} }; httplib::Headers headers = { {"Authorization", "Bearer " + api_key_}, {"Content-Type", "application/json"} }; auto res = cli.Post(kChatPath, headers, body.dump(), "application/json"); if (!res) { std::cerr << "[LlmClient] 请求失败: " << httplib::to_string(res.error()) << "\n"; return ""; } if (res->status != 200) { std::cerr << "[LlmClient] HTTP " << res->status << " body=" << res->body << "\n"; return ""; } auto resp = nlohmann::json::parse(res->body, nullptr, false); if (resp.is_discarded() || !resp.contains("choices") || resp["choices"].empty()) { std::cerr << "[LlmClient] 响应缺少 choices 字段: " << res->body << "\n"; return ""; } return resp["choices"][0]["message"]["content"].get<std::string>(); }注意httplib::Client cli("https://taotoken.net")这里只传了 scheme + host,路径在Post里传/v1/chat/completions。如果你把 base_url 里的/api也拼进去,就要写成cli.Post("/api/v1/chat/completions", ...)。两种写法都行,但别重复拼。
如果你更喜欢用配置文件而不是环境变量,JSON 版本长这样,放在config/taotoken.json:
{ "llm": { "base_url": "https://taotoken.net/api", "chat_path": "/v1/chat/completions", "api_key_env": "TAOTOKEN_API_KEY", "model_id": "你的模型ID", "timeout": { "connect_seconds": 10, "read_seconds": 60 } } }TOML 版本(如果你用 toml11 之类的库):
[llm] base_url = "https://taotoken.net/api" chat_path = "/v1/chat/completions" api_key_env = "TAOTOKEN_API_KEY" model_id = "你的模型ID" [llm.timeout] connect_seconds = 10 read_seconds = 60三件套对照表,填配置时对着看:
| 配置项 | 值 | 说明 |
|---|---|---|
| Base URL | https://taotoken.net/api | 不带/v1 |
| Chat Path | /v1/chat/completions | 带/v1 |
| API Key | sk-... | 从 https://taotoken.net/api-keys 获取 |
| Model ID | 你的模型标识 | 从模型列表获取,必须完全一致 |
| Auth Header | Authorization: Bearer sk-... | 注意是 Bearer 加空格,不是冒号 |
注意:
Authorization头的值是Bearer加 Key,中间一个空格。写成Bearer: sk-xxx会 401,这是 C++ 手写 header 时的高频错误。
配置写完,编译:
cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j如果链接 OpenSSL 报错,Linux 上装libssl-dev,macOS 上brew install openssl并在 CMake 里指定OPENSSL_ROOT_DIR。编译过了,就进入验证环节。
4. 验证请求:跑通一次完整 MCP 工具调用
配置对不对,跑一次就知道。验证分两层:先单独验证 HTTP 通道通不通,再验证 MCP 服务器整体流程。
第一层,写个最小 main 直接调LlmClient::chat:
// src/main.cpp #include "llm_client.h" #include <iostream> int main() { LlmClient client; std::string reply = client.chat("用一句话说明 MCP 协议的作用"); if (reply.empty()) { std::cerr << "调用失败,检查上一节配置\n"; return 1; } std::cout << "模型回复: " << reply << "\n"; return 0; }编译运行:
./build/mcp_server成功的话你会看到类似:
模型回复: MCP 协议是 AI 与外部工具之间的统一接口,让模型能标准化地调用文件、数据库、API 等能力。这一步通了,说明 Base URL、Key、Model ID、header 全对。如果这里就失败,直接跳到第五节排错,别往下走。
第二层,验证 MCP 服务器整体流程。假设你的 MCP server 用 stdio 传输,启动后通过标准输入发 JSON-RPC 消息。先发initialize:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}正常会返回 capabilities 和 serverInfo。然后发tools/list:
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}你应该能看到注册的工具列表。最后发tools/call,调用一个内部会走 TaoToken 的工具,比如ask_model:
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ask_model","arguments":{"prompt":"你好"}}}如果这个工具内部调用了LlmClient::chat,返回的content里应该包含模型回复。到这里,一次完整的「MCP 客户端 → C++ MCP 服务器 → TaoToken 通道 → 模型 → 返回」链路就跑通了。
用 curl 单独验证通道也是个好习惯,能快速区分是 C++ 代码问题还是配置问题:
curl -s https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "'"$TAOTOKEN_MODEL_ID"'", "messages": [{"role":"user","content":"ping"}] }'curl 通了但 C++ 不通,问题就在 C++ 侧(header 拼装、URL 拼接、JSON 序列化);curl 也不通,问题在 Key/Model ID/网络。这个二分法能省你很多时间。
验证时还要注意超时。模型推理不是瞬时的,read_timeout给 60 秒比较稳。如果你设了 5 秒,长回复会直接超时,报Read timeout,你会误以为是网络问题。
提示:验证阶段建议把
temperature设成 0,输出更稳定,方便你判断返回内容是否符合预期。
跑通之后,你可以把 MCP server 接到支持 MCP 的客户端里做端到端联调。如果你还想验证不同模型的表现,可以去 https://taotoken.net/chat 直接对话对比,确认 Model ID 对应的模型是不是你要的那个。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错对照,每条给你原因和修法。这些是我和身边人实际撞过的,不是编的。
401 Unauthorized。最常见。原因有三种:一是 Key 错了或过期,去 https://taotoken.net/api-keys 重新生成;二是 header 拼错,Authorization: Bearer sk-xxx中间是空格,不是冒号,也不是api-key;三是 Key 前后有空格或换行,从环境变量读的时候尤其容易带上\n。修法:打印一下api_key_.size()和首尾字符,确认没有空白。
local proxy failed / connection refused。这个报错通常出现在你本地起了个代理或者端口转发,但目标没起来。注意,这里说的是你本地工具链自己的端口映射,不是任何网络工具。检查你的 MCP server 监听端口和客户端配置的端口是否一致,netstat -tlnp | grep 你的端口看一眼有没有在听。如果是 HTTPS 握手失败,检查 OpenSSL 是否正确链接,cpp-httplib 在没链接 OpenSSL 时会静默失败或报 SSL 相关错误。
reading 'choices' / Cannot read properties of undefined (reading 'choices')。这个报错来自客户端侧解析响应时找不到choices字段。原因:服务端返回的不是标准 OpenAI 格式,或者返回了错误对象(比如{"error": {...}}),但你的 C++ 代码没检查status就直接解析。修法:在解析前先判断res->status == 200,并且用resp.contains("choices")做防御。我上面给的代码已经加了这层判断。另外确认你的请求体里messages是数组、每个元素有role和content,字段名错了服务端可能返回错误对象。
OAuth / invalid_grant / token expired。如果你用的是需要 OAuth 的客户端(比如某些 coding agent 工具),它可能期望走 OAuth 流程而不是静态 Key。这时候你要确认该工具是否支持自定义 Base URL + API Key 模式。以 Claude Code 这类工具为例,它支持通过环境变量指定 Base URL 和 Key,配置三件套是:
- Base URL:
https://taotoken.net/api - API Key:你的
sk-... - Model ID:你的模型标识
如果工具走的是auth.json或settings.json配置,把这三项填进去,别混用 OAuth 和静态 Key。Cline 的 MCP 配置里如果出现OAuth相关字段,说明它默认走了另一套鉴权,你需要显式切到 API Key 模式。
404 Not Found。九成是 URL 拼错,/api/v1/v1/...或者漏了/v1。用 curl 验证完整 URL,确认路径。
超时 / Read timeout。模型推理慢,把read_timeout调到 60 秒以上。如果是连接超时,检查 DNS 和网络出口。
编译期报错undefined reference to SSL_...。CMake 里没链接 OpenSSL,加target_link_libraries(mcp_server PRIVATE OpenSSL::SSL OpenSSL::Crypto),并确保find_package(OpenSSL REQUIRED)在add_executable之前。
JSON 解析崩溃。nlohmann::json::parse遇到非 JSON 响应会抛异常,用parse(body, nullptr, false)返回 discarded 的版本,或者 try-catch。我上面用的是不抛异常的版本。
排查顺序建议:先 curl 验证通道 → 再单独跑LlmClient::chat→ 再跑 MCP 整体流程。每层通了再进下一层,别一上来就端到端调,出错时你分不清是哪层的问题。
注意:不要把 Key 打印到日志里。调试时可以打印 Key 的长度和前 4 位,别打全。
6. 下一步:把 MCP 服务器接进长期编码工作流
跑通一次请求只是起点。真正有价值的是把这个 C++ MCP 服务器接进你日常的编码工作流,让它长期稳定地提供工具能力。
如果你主要做本地工具链联调,下一步是把 endpoint 配置抽成可切换的 profile,比如dev和prod两套 Base URL/Model ID,通过环境变量或配置文件切换。这样你在本地调试时用一个模型,正式跑时换另一个,不用改代码。
如果你要做的是长期编码或 Agent 场景,建议了解一下 Coding Plan,它更适合需要持续调用、多轮工具编排的工作流:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。C++ MCP 服务器在这里的角色是「工具提供方」,模型通过 MCP 协议调用你注册的工具,而模型调用本身走 TaoToken 通道,两边解耦,各自演进。
如果你还想验证不同模型在你这个 MCP 工具链下的表现,直接去模型对话页面手动试:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。把同样的 prompt 丢给不同 Model ID,看哪个更适合你的工具调用场景,再回到配置里改TAOTOKEN_MODEL_ID。
接入文档在这里,遇到协议细节或字段问题可以查:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。Key 管理在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite ,需要轮换 Key 或创建多个 Key 分环境用时去这里。
最后给一个实用技巧:在你的 C++ MCP 服务器里加一个health工具,内部就调一次最轻量的模型请求(比如messages只发一个ping),返回通道是否可用。这样客户端在联调时可以先调health,快速判断是通道问题还是业务工具问题。这个模式在长期运行的服务里特别省事,比翻日志快得多。
代码层面,把LlmClient做成单例或者依赖注入,别在每个 tool handler 里都 new 一个 HTTP client,连接复用能明显降低延迟。如果你用 libcurl,记得curl_global_init在程序启动时调一次,别在每次请求里调。
到这里,从 endpoint 配置到验证到排错到长期接入,链路是完整的。你可以先把第 3 节的配置片段抄进去,跑通第 4 节的验证,遇到报错回第 5 节对照。跑通之后,再考虑 profile 切换和 health 工具这些工程化的事。