1. 从零写 Agent 时,工具越加越乱怎么办
写 Agent 写到第四篇,最容易卡住的不是模型调用,而是工具管理。前几篇里我们给 Agent 加了 shell 工具、时间工具,每加一个就要改一次主程序,注册表越写越长,main.go里堆满了toolRegistry.Register(...)。更麻烦的是,工具的实现和 Agent 的核心循环绑死了,想换一个文件操作实现,得动 Agent 的代码。
这个问题的本质是耦合。Agent 负责思考循环,工具负责具体执行,两者本该独立演进,却因为直接函数调用被焊在一起。解决办法在软件工程里很经典:加一层抽象。让 Agent 和工具之间用一套标准协议对话,只要双方都遵守协议,谁都不用关心对方内部怎么实现。
这套协议就是 MCP(Model Context Protocol,模型上下文协议)。你可以把它理解成 USB:U 盘、鼠标、键盘只要符合 USB 规范,插到任何电脑上都能用,电脑不需要为每个设备装专用驱动。在 MCP 里,Agent 是那台电脑,各种工具服务是 USB 设备。
MCP 基于 JSON-RPC 2.0,所有交互都是结构化的 JSON 消息。核心流程就四步:连接后发initialize握手,问对方是谁、支持什么能力;发tools/list拿到工具清单;把工具清单喂给 LLM;LLM 决定调用时发tools/call,带上工具名和参数,拿到结果继续循环。
传输方式官方定义两种。stdio 适合本地可执行程序,Server 作为 Client 的子进程,JSON-RPC 消息写进子进程 stdin,从 stdout 读响应。HTTP/SSE 适合远程服务,先建 SSE 长连接,服务端下发 endpoint 事件告诉客户端往哪 POST 请求。
这篇要做的,就是用 Go 从零实现一个 MCP 客户端,解析 JSON-RPC 消息,完成工具注册与调用链路,最后用 TaoToken 的统一 Key 跑通整个工具调用流程。适合已经写过基础 Agent、想引入标准化工具生态的 Go 开发者。下面所有代码都可以直接复制进你的项目。
2. 用 TaoToken 统一 Key 接入 MCP 工具调用链路
在动手写 MCP 客户端之前,先把模型接入这块理顺。MCP 解决的是工具怎么插拔,但工具调用最终还是要 LLM 来决定调哪个、传什么参数。也就是说,Agent Loop 里必须有一个稳定的模型入口,能返回标准的 tool_calls 结构。
我试过在多个项目里分别维护不同厂商的 Key 和 Base URL,切换模型时改配置改到崩溃。后来统一走 TaoToken,一个 Key 覆盖多种模型,Base URL 固定,Agent 侧只需要改 model 字段就能换模型,MCP 工具链路完全不用动。
TaoToken 在这里的角色是模型能力的统一入口。它的 API 地址是https://taotoken.net/api,兼容 OpenAI 的 chat completions 格式,返回的tool_calls字段结构标准,正好对接我们 MCP 客户端注册进来的工具。你不需要为每个模型单独写适配层,Agent 的 LLM 客户端只认一套请求响应格式。
具体来说,MCP 客户端负责把工具清单整理成 OpenAI function calling 需要的 JSON Schema,LLM 返回 tool_calls 后,Agent 根据工具名前缀找到对应的 MCP Server,转发tools/call请求。TaoToken 保证的就是这个 LLM 环节的稳定输出。
配置上,你需要在项目里准备一个 config.json,把模型接入信息和 MCP Server 配置放一起。模型部分填 TaoToken 的 Base URL 和 Key,MCP 部分填你要接入的工具服务。这样 Agent 启动时一次性加载,模型和工具都从配置来,代码里不写死任何厂商信息。
有一点要注意:MCP 工具的 InputSchema 是 JSON Schema 格式,和 OpenAI function calling 的 parameters 格式基本一致,但个别字段命名有差异,比如 MCP 用inputSchema,OpenAI 用parameters。适配器里要做一次转换,这个后面代码里会体现。
如果你还没拿 Key,可以去 TaoToken 控制台创建一个,然后在 API Keys 页面复制。接入文档里有完整的请求示例,对照着填 config.json 就行。模型对话页面可以先手动测一下 tool_calls 返回结构,确认格式没问题再写进 Agent。
3. 可复制的 Go 模块配置与 MCP 握手代码
先把项目结构和依赖定下来。我们用 Go modules 管理,核心依赖是 zerolog 做日志,其余标准库够用。go.mod 长这样:
module chapter4 go 1.21 require ( github.com/rs/zerolog v1.32.0 )MCP 客户端的核心是 JSON-RPC 消息封装。先定义请求和响应结构,字段名严格按 JSON-RPC 2.0 规范来:
package transport // Request JSON-RPC 2.0 请求 type Request struct { JSONRPC string `json:"jsonrpc"` ID int64 `json:"id"` Method string `json:"method"` Params interface{} `json:"params,omitempty"` } // Response JSON-RPC 2.0 响应 type Response struct { JSONRPC string `json:"jsonrpc"` ID int64 `json:"id"` Result json.RawMessage `json:"result,omitempty"` Error *RPCError `json:"error,omitempty"` } type RPCError struct { Code int `json:"code"` Message string `json:"message"` }握手是第一步。Client 连接后发initialize,带上协议版本和客户端能力,Server 返回它支持的能力集:
func (c *Client) initialize(ctx context.Context) error { params := map[string]interface{}{ "protocolVersion": "2024-11-05", "capabilities": map[string]interface{}{ "tools": map[string]interface{}{}, }, "clientInfo": map[string]string{ "name": "go-agent", "version": "1.0.0", }, } resp, err := c.sendRequest(ctx, "initialize", params) if err != nil { return fmt.Errorf("initialize 失败: %w", err) } // 握手成功后发送 initialized 通知 c.sendNotification("notifications/initialized", nil) return c.parseServerCapabilities(resp.Result) }握手完成后拉工具列表,tools/list返回的每个工具包含 name、description、inputSchema:
func (c *Client) fetchTools(ctx context.Context) error { resp, err := c.sendRequest(ctx, "tools/list", nil) if err != nil { return err } var result struct { Tools []Tool `json:"tools"` } if err := json.Unmarshal(resp.Result, &result); err != nil { return err } c.tools = result.Tools return nil }工具调用走tools/call,参数是工具名加 arguments:
func (c *Client) CallTool(ctx context.Context, name string, args map[string]interface{}) (*ToolsCallResult, error) { params := map[string]interface{}{ "name": name, "arguments": args, } resp, err := c.sendRequest(ctx, "tools/call", params) if err != nil { return nil, err } var result ToolsCallResult if err := json.Unmarshal(resp.Result, &result); err != nil { return nil, err } return &result, nil }stdio 传输的实现要点:启动子进程,把 stdin 包成 writer,stdout 包成 bufio.Scanner 按行读。每条 JSON-RPC 消息一行,读到就解析。这里有个坑,Server 可能输出非 JSON 的日志到 stdout,解析失败要跳过而不是直接报错。
func (t *StdioTransport) readLoop() { scanner := bufio.NewScanner(t.stdout) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) for scanner.Scan() { line := scanner.Bytes() var resp Response if err := json.Unmarshal(line, &resp); err != nil { continue // 跳过非 JSON 输出 } t.dispatch(resp) } }Manager 负责管理多个 Server 连接,聚合所有工具。适配器把 MCP 工具包装成本地 Tool 接口,Execute 时转发给 Manager:
func (a *MCPToolAdapter) Execute(ctx context.Context, params json.RawMessage) (string, error) { var arguments map[string]interface{} if err := json.Unmarshal(params, &arguments); err != nil { return "", fmt.Errorf("参数解析失败: %w", err) } result, err := a.manager.CallTool(ctx, a.serverName, a.tool.Name, arguments) if err != nil { return "", err } return formatResult(result), nil }config.json 里把 TaoToken 接入和 MCP Server 配置放一起:
{ "base_url": "https://taotoken.net/api", "api_key": "你的TaoToken Key", "model": "claude-3-5-sonnet", "temperature": 0.7, "max_tokens": 10000, "timeout": 120, "mcp_server_config": [ { "name": "filesystem", "transport": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], "enabled": true } ] }注意 Base URL 填https://taotoken.net/api,不要带多余路径。Model ID 按你实际用的填,TaoToken 支持多种模型,换模型只改这一行。
4. 验证 MCP 工具调用是否跑通
配置写完,跑一次完整链路验证。main 函数里初始化 Manager、连接所有 Server、注册工具、启动 Agent Loop:
func main() { config, err := loadConfig("./config.json") if err != nil { log.Fatal().Err(err).Msg("加载配置失败") } client := llm.NewOpenAIClient(config) toolRegistry := tool.NewRegistry() manager := mcp.NewManager() for _, server := range config.MCPServerConfig { manager.AddServer(server) } if err := manager.ConnectAll(context.Background()); err != nil { log.Warn().Err(err).Msg("部分 MCP Server 连接失败") } toolRegistry.RegisterMCPTools(manager) myAgent := agent.NewAgent("MyAgent", "", 10, client, toolRegistry) answer, err := myAgent.Run(context.Background(), "用 filesystem 工具在当前目录写一个 hello.txt,内容为 hello mcp") if err != nil { log.Fatal().Err(err).Msg("运行 Agent 失败") } fmt.Println(answer) }运行后你应该看到三段日志。第一段是 MCP 初始化,每个 Server 打印[MCP] 尝试连接和握手成功。第二段是工具注册,[MCP] 已注册工具后面跟着工具名,比如mcp_filesystem_write_file。第三段是 Agent Loop,LLM 返回 tool_calls,Agent 转发给 Manager,Manager 找到对应 Client 发tools/call。
成功的结果是当前目录出现 hello.txt,内容为 hello mcp。同时控制台会打印工具调用结果,格式类似:
Pipeline 'deploy-frontend' 状态: SUCCESS 开始时间: 2026-03-26T10:00:00Z 结束时间: 2026-03-26T10:05:23Z如果你接的是 git 工具,可以让 Agent 执行「写一首诗到 a.txt 并用 git 提交」,观察它依次调用 writeFile、stage、commit 三个工具。每个工具调用都是一次完整的 JSON-RPC 往返,日志里能看到 request 和 response 的 id 对应关系。
验证模型侧的时候,可以单独用 curl 测一下 TaoToken 的 tool_calls 返回:
curl https://taotoken.net/api/chat/completions \ -H "Authorization: Bearer 你的Key" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-3-5-sonnet", "messages": [{"role": "user", "content": "写个文件"}], "tools": [{"type": "function", "function": {"name": "write_file", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}}}] }'返回里如果有tool_calls数组,说明模型侧链路通了。这一步能快速区分是模型接入问题还是 MCP 客户端问题。
5. 常见报错排查:401、local proxy failed、reading choices
跑 MCP 接入最容易撞几类错,逐个说。
401 Unauthorized。这个基本是 Key 问题。检查 config.json 里api_key有没有填对,有没有多余空格。TaoToken 的 Key 在控制台 API Keys 页面复制,注意不要复制到别的字段。如果 Key 没问题还报 401,看 Base URL 是不是写成了https://taotoken.net/api/带尾斜杠,有些客户端拼接路径时会出问题,去掉尾斜杠。
local proxy failed。这个错通常出现在 stdio 传输启动子进程时。原因可能是 command 找不到,比如npx不在 PATH 里,或者 args 里的包名拼错。排查方法:先在终端手动跑一遍npx -y @modelcontextprotocol/server-filesystem ./,看能不能启动。如果手动能跑,代码里报错,检查 exec.Command 的 Dir 字段有没有设对工作目录。
reading choices 相关报错。这个一般出在 LLM 响应解析阶段。TaoToken 返回的是标准 OpenAI 格式,choices[0].message.tool_calls是数组。如果你的解析代码假设choices一定存在且非空,遇到模型返回纯文本没调工具时就会 panic。正确做法是先判断len(choices) > 0,再看message.tool_calls是否为 nil。
OAuth 相关报错。部分远程 MCP Server 需要 OAuth 认证,stdio 本地服务一般不需要。如果你接的是 HTTP 传输的远程服务,报 OAuth 错,检查 Headers 里有没有带对 Authorization。本地 filesystem 和 git 工具走 stdio,不会碰到这个。
工具注册了但 LLM 不调用。这个不是报错但很常见。原因通常是工具描述太模糊,或者 InputSchema 格式不对。检查适配器里parameters字段是不是从 MCP 的inputSchema正确转换过来的。另外工具名建议加前缀mcp_服务器名_工具名,避免和本地工具重名。
JSON-RPC id 不匹配。stdio 传输是异步的,发请求和收响应通过 id 关联。如果 id 生成用了随机数但没做映射,响应回来找不到对应请求就会超时。建议用自增 int64,维护一个map[int64]chan Response做分发。
排查顺序建议:先确认模型侧 curl 能返回 tool_calls,再确认 MCP Server 手动能启动,最后看代码里的日志。三段日志哪段断了,问题就在哪。
6. 继续往下走:从 MCP 到 Skill 的演进
MCP 跑通后,Agent 的工具生态就解耦了。新增工具只需要在 config.json 里加一个 Server 配置,代码一行不用改。filesystem、git、数据库、内部 API,只要有人写了符合 MCP 规范的 Server,你的 Agent 就能即插即用。
但工具多了会出新问题。想象一下几百个工具全塞进 Prompt,上下文窗口瞬间被工具描述占满,LLM 还会因为信息过载选错工具。这时候需要更高层的抽象,把常用工具组合成 Skill,让 Agent 按标准作业流程执行,而不是每次临场发挥。
下一篇会讲 Skill 的设计和实现,把「代码评审」这种多步骤流程固化下来。当前这篇的完整代码可以先把 MCP 客户端和适配器部分抽出来,作为独立模块复用。你可以在 TaoToken 的模型对话页面多试几个模型,看看不同模型对 tool_calls 的支持程度,选一个工具调用最稳的作为 Agent 默认模型。接入文档里有各模型的参数说明,对照着调 temperature 和 max_tokens,工具调用场景下 temperature 建议调低一点,减少模型乱选工具的概率。