
1. 为什么我要用 C 手写一个 MCP stdio 服务MCP 全称 Model Context Protocol说白了就是让大模型和外部工具用同一套话术沟通的协议。你可以把它想成 USB-C以前每个工具一个接口客户端要写一堆适配现在只要按 MCP 说话客户端就能自动发现你有哪些工具、怎么调、返回什么格式。我这次的目标很具体用 C 从零写一个最小可用的 MCP 服务走 stdio 传输暴露tools/list和tools/call然后用 Codex CLI 当客户端联调确认握手和工具调用链路真的跑通。适合谁适合已经会一点 C、想搞明白 MCP 底层消息怎么流动、又不想被 Python/Node 框架遮住细节的人。为什么选 stdio因为本地 MCP 最常见的传输方式就是它客户端启动一个子进程服务端从 stdin 读请求、往 stdout 写响应一条消息一行。它的坑也很集中——stdout 只能输出协议内容日志必须写 stderr否则协议直接被打坏。这一点后面我会用真实报错带你踩一遍。整条链路我会拆成六块先讲清楚问题和场景再准备 TaoToken 的统一 Key 和 Base URL然后给出可复制的 CMake 工程和 C 代码接着用 curl 和 Codex CLI 双向验证再对照真实报错排查最后把 CTA 分流说清楚。你跟着做能拿到一个能编译、能握手、能被 Codex CLI 调用的 MCP 服务。2. TaoToken 前置准备统一 Key 与 Base URL 配置在写代码之前先把模型侧的入口准备好。我这边用 TaoToken 作为统一入口官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。它的作用是给你一个统一的 Key 和 Base URL后面不管是 curl 验证还是 Codex CLI 联调都走同一个入口省得每个模型换一套配置。第一步去控制台创建 API Key。打开 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 登录后进 API Keys 页面新建一个 Key复制出来先存好。注意 Key 只在创建时完整显示一次丢了就得重建。第二步确认你要用的模型 ID。进模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 可以看到当前可用的模型列表把你要在 Codex CLI 里用的那个 Model ID 记下来。MCP 服务本身不关心模型但 Codex CLI 作为客户端需要模型来驱动工具调用所以这一步不能省。第三步把 Base URL 和 Key 写进环境变量方便后面 curl 和 CLI 复用。Linux/macOS 下export TAOTOKEN_API_KEYsk-你的Key export TAOTOKEN_BASE_URLhttps://taotoken.net/apiWindows PowerShell 下$env:TAOTOKEN_API_KEYsk-你的Key $env:TAOTOKEN_BASE_URLhttps://taotoken.net/api这里有个容易混的点MCP 服务走的是 stdio不直接访问网络真正访问 TaoToken 的是 Codex CLI 这个客户端。所以 Base URL 和 Key 是配给 Codex CLI 的不是配给我们的 C 程序的。我们的 C 程序只负责在本地进程里收发 JSON-RPC。如果你后面要做长期编码或 Agent 场景可以看下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它更适合持续性的编码任务。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 遇到参数不确定时对着查。3. 可复制配置CMake 工程与 C stdio JSON-RPC 服务这一节是重头戏我给你一套能直接编译的 CMake 工程加上完整的 C 源码。工程结构如下mcp_cpp_demo/ ├── CMakeLists.txt ├── src/ │ └── main.cpp └── third_party/ └── json.hppjson.hpp用 nlohmann/json 单头文件版本去它的 release 页面下载后放到third_party/下即可。下面是CMakeLists.txtcmake_minimum_required(VERSION 3.16) project(mcp_cpp_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(mcp_cpp_demo src/main.cpp) target_include_directories(mcp_cpp_demo PRIVATE ${CMAKE_SOURCE_DIR}/third_party) if (MSVC) target_compile_options(mcp_cpp_demo PRIVATE /W4 /utf-8) else() target_compile_options(mcp_cpp_demo PRIVATE -Wall -Wextra) endif()然后是src/main.cpp它实现了 initialize 握手、notifications/initialized 通知、tools/list 列表、tools/call 调用只暴露一个echo_upper工具#include algorithm #include cctype #include iostream #include string #include nlohmann/json.hpp using json nlohmann::json; static void log_error(const std::string message) { std::cerr [mcp-cpp-demo] message std::endl; } static std::string to_upper_copy(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char ch) { return static_castchar(std::toupper(ch)); }); return s; } static json make_error_response(const json id, int code, const std::string message) { return { {jsonrpc, 2.0}, {id, id}, {error, {{code, code}, {message, message}}} }; } static json make_initialize_response(const json id) { return { {jsonrpc, 2.0}, {id, id}, {result, { {protocolVersion, 2024-11-05}, {capabilities, {{tools, {{listChanged, false}}}}}, {serverInfo, {{name, cpp-demo}, {version, 0.1.0}}} }} }; } static json make_tools_list_response(const json id) { return { {jsonrpc, 2.0}, {id, id}, {result, { {tools, json::array({ { {name, echo_upper}, {description, 把输入文本转成大写}, {inputSchema, { {type, object}, {properties, {{text, {{type, string}}}}}, {required, json::array({text})} }} } })} }} }; } static json make_tool_call_response(const json id, const std::string text) { return { {jsonrpc, 2.0}, {id, id}, {result, { {content, json::array({{{type, text}, {text, text}}})}, {isError, false} }} }; } static void write_message(const json message) { std::cout message.dump() \n; std::cout.flush(); } static void handle_message(const json message) { if (!message.contains(jsonrpc) || message[jsonrpc] ! 2.0) { if (message.contains(id)) { write_message(make_error_response(message[id], -32600, Invalid Request)); } return; } const bool has_id message.contains(id); const json id has_id ? message[id] : json(nullptr); const std::string method message.value(method, ); if (method initialize) { if (!has_id) return; write_message(make_initialize_response(id)); return; } if (method notifications/initialized) { return; } if (method tools/list) { if (!has_id) return; write_message(make_tools_list_response(id)); return; } if (method tools/call) { if (!has_id) return; if (!message.contains(params) || !message[params].is_object()) { write_message(make_error_response(id, -32602, Invalid params)); return; } const auto params message[params]; const std::string tool_name params.value(name, ); if (tool_name ! echo_upper) { write_message(make_error_response(id, -32601, Unknown tool)); return; } if (!params.contains(arguments) || !params[arguments].is_object()) { write_message(make_error_response(id, -32602, Missing arguments)); return; } const auto arguments params[arguments]; if (!arguments.contains(text) || !arguments[text].is_string()) { write_message(make_error_response(id, -32602, Argument text must be a string)); return; } const std::string input arguments[text].getstd::string(); write_message(make_tool_call_response(id, to_upper_copy(input))); return; } if (has_id) { write_message(make_error_response(id, -32601, Method not found)); } } int main() { std::ios::sync_with_stdio(false); std::cin.tie(nullptr); std::string line; while (std::getline(std::cin, line)) { if (line.empty()) continue; try { json message json::parse(line); handle_message(message); } catch (const std::exception ex) { log_error(std::string(JSON parse or handling error: ) ex.what()); } } return 0; }编译命令cmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build --config ReleaseWindows 下产物在build/Release/mcp_cpp_demo.exeLinux/macOS 下在build/mcp_cpp_demo。这里有个关键点write_message里必须std::cout.flush()否则响应可能卡在缓冲区里客户端等不到回复。另外std::ios::sync_with_stdio(false)是为了性能但要注意别在别处混用 C 的 printf 往 stdout 写东西。4. 验证请求curl 与 Codex CLI 双向确认握手和工具调用先做本地手动测试不接任何客户端直接喂消息。启动程序./build/mcp_cpp_demo然后逐行输入。第一步 initialize{jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2024-11-05,capabilities:{},clientInfo:{name:manual-test,version:0.1.0}}}应该看到一行响应{capabilities:{tools:{listChanged:false}},id:1,jsonrpc:2.0,result:{protocolVersion:2024-11-05,serverInfo:{name:cpp-demo,version:0.1.0}}}第二步发通知注意通知没有 id不应该有响应{jsonrpc:2.0,method:notifications/initialized}第三步查工具列表{jsonrpc:2.0,id:2,method:tools/list}响应里应该能看到echo_upper和它的inputSchema。第四步调用工具{jsonrpc:2.0,id:3,method:tools/call,params:{name:echo_upper,arguments:{text:hello mcp}}}响应{id:3,jsonrpc:2.0,result:{content:[{text:HELLO MCP,type:text}],isError:false}}手动测试通了说明协议层没问题。接下来用 curl 验证 TaoToken 侧的模型入口是否可用这一步是确认你的 Key 和 Base URL 没问题curl -s https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: 你的ModelID, messages: [{role: user, content: 只回复 ok}] }返回里能看到choices数组就说明 Key 和 Base URL 配置正确。如果这里报 401先别急着怀疑 MCP是 Key 的问题。最后接 Codex CLI。编辑~/.codex/config.tomlWindows 是C:\Users\你的用户名\.codex\config.toml加上 MCP 服务配置和模型配置model 你的ModelID model_provider taotoken [model_providers.taotoken] name TaoToken base_url https://taotoken.net/api env_key TAOTOKEN_API_KEY [mcp_servers.cpp_demo] type stdio command /绝对路径/build/mcp_cpp_demo cwd /绝对路径/mcp_cpp_demo startup_timeout_sec 120Windows 下command要写成双反斜杠转义比如D:\\mcp_example\\build\\Release\\mcp_cpp_demo.exe。重启 Codex CLI 后它会把cpp_demo当作 MCP server 加载。你可以直接问它「列出你当前可用的工具」如果它调用了tools/list并报出echo_upper说明链路通了。再让它「把 hello mcp 转成大写」它会走tools/call返回HELLO MCP。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth这一节我按真实报错来对照都是我在联调时踩过的。第一个401 Unauthorized。这个几乎都是 Key 的问题。检查三件事环境变量TAOTOKEN_API_KEY是否在当前 shell 生效echo $TAOTOKEN_API_KEY看有没有值Key 是否复制完整有没有多空格Codex CLI 的env_key是否写成了TAOTOKEN_API_KEY而不是别的名字。注意 Key 只在创建时显示一次如果你不确定直接去 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 重建一个。第二个local proxy failed或连接被拒绝。这个通常出现在 Codex CLI 启动 MCP 服务时。原因一般是command路径写错或者可执行文件没有执行权限。Windows 下路径要用双反斜杠Linux/macOS 下先chmod x。还有一个隐蔽原因cwd指向的目录不存在客户端启动子进程时直接失败。把cwd设成工程根目录最稳。第三个reading choices相关报错比如解析响应时找不到choices字段。这说明请求发出去了但返回的不是预期的 chat completions 结构。常见原因是base_url写成了https://taotoken.net而漏了/api或者模型 ID 写错导致返回了错误对象。对照 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 确认路径和模型 ID。第四个OAuth 相关报错。有些客户端默认走 OAuth 流程但 TaoToken 用的是 API Key 方式。如果你在 Codex CLI 里看到 OAuth 提示检查model_providers段里是不是写了env_key而不是让它走默认的登录流程。把 provider 显式指向taotoken并配好env_key就能绕过。还有一个 MCP 特有的坑客户端报「invalid JSON」或握手超时。九成是你的 C 程序往 stdout 打了非协议内容。检查所有std::cout调用只保留write_message里的那一处日志全部走std::cerr。我试过在main开头加了一句std::cout starting...结果 Codex CLI 直接握手失败排查了半天。6. 语义一致 CTA把链路跑通之后往哪走到这里你的 C MCP stdio 服务应该已经能被 Codex CLI 发现并调用了。如果你卡在排障或接入环节优先看 API Keys 和接入文档API Keys 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 这两个页面能解决大部分 Key 和 Base URL 的问题。如果你想先验证模型本身能不能正常对话去模型对话页面 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 直接试确认模型 ID 和返回格式没问题再回来接 MCP。如果你打算把 MCP 服务用在长期编码或 Agent 场景比如让 Codex CLI 持续调用你的工具链那 Coding Plan 更合适 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它针对持续性编码任务做了优化比按次调用更省心。最后给你一个实用建议把echo_upper换成你真正需要的工具比如查数据库、读文件、调内部 API。MCP 的inputSchema就是给大模型看的参数说明书写清楚description和required模型调用准确率会明显提升。我实测下来工具描述写得越具体模型瞎传参数的概率越低。