1. 从 stdio 到 Claude Desktop:MCP server 开发第二篇要解决的真实问题
如果你已经跟着第一篇把 MCP server 的骨架搭起来了,大概率会卡在同一个地方:本地用echo手动喂 JSON-RPC 请求能跑通,但一接进 Claude Desktop 就各种不响应、工具列表刷不出来、调用报Method not found。这不是你的代码写错了,而是 stdio 这条链路里有一堆隐式约定——换行符、stdout 纯净度、初始化握手顺序、配置文件的绝对路径——任何一个环节出问题,表现都是「连不上」。
这篇就聚焦这件事:用 stdio 协议把工具暴露出去,然后接进 Claude Desktop 验证整条调用链路。核心检索词是 MCP server 开发、stdio 协议、Claude Desktop 接入。适合谁?适合已经写过一点 Go、知道 JSON-RPC 长什么样、但还没把 MCP server 真正跑进桌面客户端的开发者。我会给出可复制的 server 配置片段、TaoToken 统一 Key 的 Base URL 填写位置,以及一次完整的工具调用验证动作。
先说清楚 stdio 模式的本质。MCP 协议支持多种传输方式,stdio 是最朴素的一种:客户端启动你的 server 进程,双方通过标准输入输出交换 JSON-RPC 消息,每条消息以换行符分隔。这意味着你的 server 不能往 stdout 打印任何非协议内容——日志、调试信息、panic 堆栈全部得走 stderr 或文件。我见过太多人在这里翻车:fmt.Println("server started")一写,Claude Desktop 收到的第一行就不是合法 JSON,握手直接失败,界面上那个锤子图标永远是灰的。
另一个容易忽略的点是初始化顺序。客户端会先发initialize,你的 server 必须回一个包含protocolVersion、capabilities、serverInfo的 result;紧接着客户端发notifications/initialized通知,这条消息没有 id,你的 server 即使不处理也不能报错崩掉。很多简化实现直接对未知 method 返回 error,结果客户端认为握手失败。正确做法是对notifications/前缀的消息静默忽略。
至于为什么要在这一篇里引入 TaoToken,是因为当你把 MCP server 接进 Claude Desktop 之后,下一步必然是想让工具背后真正调用大模型能力——比如让 server 暴露一个「总结文件」的工具,内部去请求 LLM。这时候如果每个工具都硬编码一套 API Key 和 Base URL,维护起来是灾难。TaoToken 提供统一 Key 和统一 Base URL,把模型调用收敛到一个入口,MCP server 里只需要读环境变量即可。下面会具体讲怎么填。
2. TaoToken 前置准备:统一 Key 与 Base URL 在 MCP server 里的落点
在动手改代码之前,先把 TaoToken 这一侧准备好。你需要一个统一 Key,以及记住两个地址:官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。注意 API 地址后面不加任何 UTM 参数,直接用它作为 Base URL 就行。
为什么 MCP server 要用统一 Key?设想你的 server 注册了三个工具:summarize_file、translate_text、write_file。前两个需要调模型,第三个纯本地文件操作。如果每个需要模型的工具各自读一套配置,代码里就会散落OPENAI_API_KEY、ANTHROPIC_API_KEY之类的变量,换环境时逐个改。用 TaoToken 之后,所有模型调用共享一个TAOTOKEN_API_KEY和一个TAOTOKEN_BASE_URL,工具处理函数里统一走一个 client 构造函数。
具体怎么落?我建议在 server 启动时从环境变量读取,而不是写死在代码里。这样 Claude Desktop 的配置文件里可以通过env字段注入,本地测试时用 shell 导出,两边行为一致。下面是一个读取配置的片段,放在main.go里 server 实例化之前:
package main import ( "log" "os" ) type LLMConfig struct { APIKey string BaseURL string ModelID string } func loadLLMConfig() LLMConfig { apiKey := os.Getenv("TAOTOKEN_API_KEY") if apiKey == "" { log.Println("warning: TAOTOKEN_API_KEY not set, LLM tools will fail") } baseURL := os.Getenv("TAOTOKEN_BASE_URL") if baseURL == "" { baseURL = "https://taotoken.net/api" } modelID := os.Getenv("TAOTOKEN_MODEL_ID") if modelID == "" { modelID = "claude-3-5-sonnet" } return LLMConfig{ APIKey: apiKey, BaseURL: baseURL, ModelID: modelID, } }这里三个变量对应三件套:Base URL、Key、Model ID。任何接入类配置都必须写全这三样,缺一个都会在调用时报错。Base URL 填https://taotoken.net/api,Key 填你在控制台生成的统一 Key,Model ID 填你要用的模型标识。如果你还没生成 Key,去控制台的 API Keys 页面创建,地址是 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。
有一点要提醒:不要把 Key 直接写进claude_desktop_config.json然后提交到 git。虽然本地用方便,但配置文件很容易被同步到云端或误传。更稳妥的做法是写进 shell 的 profile,或者用系统钥匙串,配置文件里只引用环境变量名。Claude Desktop 的配置支持env字段,你可以在里面写"TAOTOKEN_API_KEY": "你的key",但同样注意别泄露。
准备好这些之后,你的 MCP server 就具备了「本地工具 + 远程模型」的混合能力。接下来进入代码配置环节。
3. 可复制配置:stdio server 与 Claude Desktop 的完整对接片段
这一节给你可以直接抄的配置。分两部分:server 侧的 stdio 主循环,以及 Claude Desktop 侧的claude_desktop_config.json。
先看 server 侧。第一篇里你可能已经写了一个从 stdin 读、往 stdout 写的主循环,但有几个细节必须修正。第一,读取要用bufio.Reader按行读,遇到\n才算一条完整消息;第二,写响应时必须fmt.Fprintf(os.Stdout, "%s\n", responseBytes),末尾的换行不能少;第三,所有日志走 stderr 或文件,绝不碰 stdout。下面是一个修正后的主循环:
func main() { // 日志重定向到文件,避免污染 stdout logFile, err := os.OpenFile("/var/mcp-fs-server/app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) if err == nil { log.SetOutput(logFile) } cfg := loadLLMConfig() server := NewMCPServer("mcp-fs-server", "0.0.1", cfg) reader := bufio.NewReader(os.Stdin) for { line, err := reader.ReadString('\n') if err != nil { log.Printf("stdin read error: %v", err) return } // 跳过空行 if strings.TrimSpace(line) == "" { continue } log.Printf("request: %s", line) resp := server.HandleMessage([]byte(line)) respBytes, _ := json.Marshal(resp) log.Printf("response: %s", string(respBytes)) // 关键:末尾必须带换行 fmt.Fprintf(os.Stdout, "%s\n", respBytes) } }注意HandleMessage里对notifications/initialized的处理。如果你的 switch 没有这个 case,会走到 default 返回METHOD_NOT_FOUND,客户端收到 error 后可能中断握手。改成这样:
switch baseMessage.Method { case "initialize": return s.handleInitialize(baseMessage.ID) case "notifications/initialized": // 通知类消息,无 id,静默忽略 return JSONRPCMessage{} case "tools/list": return s.handleListTools(baseMessage.ID) case "tools/call": var request Request _ = json.Unmarshal(message, &request) return s.handleToolCall(baseMessage.ID, &request) default: return createErrorResponse(baseMessage.ID, METHOD_NOT_FOUND, fmt.Sprintf("Method %s not found", baseMessage.Method)) }然后是 Claude Desktop 的配置。打开 Claude Desktop,进入 Settings → Developer → Edit Config,会打开claude_desktop_config.json。macOS 上的路径是~/Library/Application Support/Claude/claude_desktop_config.json,Windows 上是%APPDATA%\Claude\claude_desktop_config.json。填入以下内容:
{ "mcpServers": { "mcp_fs_server": { "command": "/usr/local/bin/mcp-fs-server", "args": ["/var/mcp-fs-server"], "env": { "TAOTOKEN_API_KEY": "sk-你的统一Key", "TAOTOKEN_BASE_URL": "https://taotoken.net/api", "TAOTOKEN_MODEL_ID": "claude-3-5-sonnet" } } } }三个字段的含义:command是你编译出来的二进制绝对路径,千万别填相对路径,Claude Desktop 的工作目录不是你的项目目录;args是传给二进制的参数,这里传了工作目录;env注入环境变量,三件套 Base URL、Key、Model ID 都在这里。如果你不想把 Key 写进配置文件,删掉env里的TAOTOKEN_API_KEY,改在系统环境变量里设置,效果一样。
配置改完必须完全退出 Claude Desktop 再重启,不是关窗口,是退出进程。重启后对话框右下角会出现一个锤子图标,显示已加载的 MCP server 数量。点开能看到 server 的 name、version 和工具列表。如果图标没出现,先看日志文件/var/mcp-fs-server/app.log,再看 Claude Desktop 自己的日志。
4. 验证请求:一次完整的工具调用链路与成功结果
配置就绪后,来跑一次完整验证。分两步:先用命令行直接喂 JSON-RPC,确认 server 本身没问题;再通过 Claude Desktop 发自然语言指令,确认整条链路通。
命令行验证。启动 server 后,依次输入三条消息,每条以换行结束:
./mcp-fs-server {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"1.0.0"},"capabilities":{}}} {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"write_file","arguments":{"path":"test.txt","content":"hello mcp"}}}预期看到三条响应。第一条是 initialize 的结果,包含protocolVersion、capabilities.tools.listChanged、serverInfo。第二条是 tools/list,返回你注册的所有工具,每个工具带 name、description、inputSchema。第三条是 tools/call 的结果,content 里是一段 text,类似Successfully wrote 9 bytes to /var/mcp-fs-server/test.txt。然后cat /var/mcp-fs-server/test.txt应该看到hello mcp。
这里有个细节值得说:inputSchema里的 description 直接决定 LLM 会不会正确调用你的工具。我试过把path的描述写成「Path where to write the file」,结果模型有时候把 content 和 path 搞反。后来改成更明确的「Absolute or relative file path to write to」,调用准确率明显提升。工具描述不是给人看的,是给模型看的,措辞要精确。
Claude Desktop 验证。重启后,在对话框输入:「请用 write_file 工具在当前目录创建一个 test1.txt,内容写 hello from claude」。Claude 会先展示它打算调用的工具和参数,你确认后执行。成功后文件出现在/var/mcp-fs-server/test1.txt。同时打开app.log,能看到完整的交互序列:initialize 请求与响应、notifications/initialized、tools/list、tools/call。日志里如果出现Method resources/list not found这类 error,不用慌,那是客户端在探测你未实现的能力,只要不影响工具调用就没事。
如果你在 server 里加了调用 TaoToken 的工具,比如summarize_file,验证时让它总结一个文件,日志里会多出一条对https://taotoken.net/api的请求记录。返回正常说明统一 Key 生效。这一步跑通,意味着你的 MCP server 已经具备「本地文件操作 + 远程模型调用」的完整能力。
5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
接入过程中报错集中在几类,逐个对照。
401 Unauthorized。出现在调用 TaoToken 时,说明 Key 无效或没传。检查三件事:TAOTOKEN_API_KEY是否真的注入到了 server 进程(在 server 启动时打印一下os.Getenv的长度,别打印内容);Key 是否复制完整,前后有没有空格;Base URL 是不是https://taotoken.net/api,多一个斜杠或少一个路径段都会 401。如果 Key 是从控制台复制的,注意有些编辑器会自动换行。
local proxy failed / connection refused。这个报错通常不是 TaoToken 的问题,而是你的 server 进程根本没起来,或者 Claude Desktop 找不到二进制。检查command路径是不是绝对路径,文件有没有执行权限(chmod +x),以及二进制依赖的动态库是否齐全。macOS 上如果二进制是交叉编译的,可能因为架构不匹配直接退出,日志里会有exec format error。
reading choices / unexpected end of JSON input。这类报错指向 stdout 被污染。最常见的原因是 server 里某处fmt.Println或第三方库往 stdout 打了日志。排查方法:把 server 单独跑起来,手动喂一条 initialize,看 stdout 输出的第一行是不是合法 JSON。如果前面混了别的字符,就是污染。把所有日志改到 stderr 或文件即可。
OAuth / authentication failed。如果你用的是需要 OAuth 的模型服务,而 TaoToken 走的是 Key 认证,两者不要混。统一 Key 模式下不需要 OAuth 流程,配置里只填 Key。如果客户端提示 OAuth,说明它没读到你的 Key,回退到了默认认证方式。检查env字段的拼写,JSON 里键名大小写敏感。
工具列表为空。Claude Desktop 连上了但锤子图标点开没有工具。原因通常是tools/list返回了空数组,或者capabilities.tools没设置。确认ServerCapabilities里Tools: ToolCapabilities{ListChanged: true}有值,且toolsmap 里确实注册了 handler。另一个可能是initialize响应里capabilities字段被omitempty吃掉了,检查 struct tag。
调用工具报 Tool not found。tools/call的params.name和你注册的 key 不一致。注意大小写和连字符,write_file和write-file是两个不同的名字。建议在handleToolCall里把收到的 name 和注册表的所有 key 打日志,一眼就能看出差异。
排查顺序建议:先命令行验证 server 本身,再验证 Claude Desktop 配置,最后验证模型调用。每一层单独确认,不要跳步。
6. 下一步:把统一 Key 用在长期编码与 Agent 场景
跑通这一篇之后,你手里有一个能用的 stdio MCP server,接进了 Claude Desktop,工具调用链路完整。接下来自然会想扩展:加更多工具、让工具背后调模型、把 server 复用到其他客户端。
如果你打算长期做编码类或 Agent 类的工作,建议了解一下 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。它适合需要持续调用模型、跑自动化任务的场景,比按次调用更省心。模型对话的入口在 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,想先验证模型是否可用可以从这里试。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言的调用示例。如果你用 Claude Code 做开发,Anthropic 兼容接入的说明在 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite 。
最后分享一个实用技巧:把 MCP server 的日志按天切分,并在每条请求前打上时间戳和请求 id。当工具调用变多之后,你会需要从日志里回溯某次调用到底传了什么参数、模型返回了什么。我现在的做法是在HandleMessage入口生成一个短 id,贯穿请求和响应日志,排查时直接 grep 这个 id,比翻时间线快得多。这个习惯在你把 server 从玩具变成日常工具之后,会省下大量时间。