
1. 后端工程师眼里的 AI Agent 与 MCP 服务器到底是什么AI Agent 这个词被讲得太玄了。站在后端视角它其实就是一个会调用工具的循环推理系统LLM 负责“想”状态机负责“控”工具系统负责“做”记忆系统负责“记”。你真正要写的代码不是模型本身而是中间那三层——循环、工具注册、协议对接。MCPModel Context Protocol就是 Agent 和工具之间的“HTTP 协议”用 JSON-RPC 2.0 把tools/list、tools/call这些动作标准化。后端工程师做 AI Agent本质是把“不稳定的模型输出”包成“稳定可控的服务”。这篇聚焦一件事用 Go 从零写一个 MCP 服务器注册几个工具再让 Agent 通过 TaoToken 统一 Key 调用模型。全程可复制go.mod依赖、工具注册代码、curl验证tools/list与tools/call的完整动作。适合已经会 Go、想搞懂 Agent 工具链怎么落地的人。我试过把工具注册和模型接入拆开写排障时定位快很多下面按这个思路来。先明确 MCP 服务器的职责边界。它不负责推理只负责三件事暴露工具清单tools/list、执行工具tools/call、维护会话初始化initialize。Agent 侧拿到工具清单后把清单塞进模型上下文模型决定调哪个工具、传什么参数Agent 再把调用转成 JSON-RPC 请求打到 MCP 服务器。这条链路里模型接入层用 TaoToken 统一 Key工具层用你自己的 Go 服务两边解耦换模型不用动工具代码。为什么用 Go 写 MCP 服务器并发执行多工具天然合适单二进制部署简单HTTP/JSON 生态成熟。一个ToolRegistry用map[string]ToolHandler就能撑起工具分发配合context.Context做超时控制比脚本语言更适合长期跑在服务端。下面从依赖开始。2. TaoToken 统一 Key 接入MCP 服务器调用模型的前置准备MCP 服务器本身不调模型但你的 Agent 循环要调。为了让工具链和模型接入解耦模型这一层统一走 TaoToken 的 API 通道一个 Key 管所有模型调用省得每个模型配一套环境变量。官网入口在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基址是 https://taotoken.net/api 这个不加 UTM。接入前先拿 Key。进控制台创建 API Key路径是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite Key 列表页在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。创建完复制那串sk-开头的字符串只显示一次丢了就重建。模型 ID 怎么选如果你只是验证工具链跑通用对话模型就够模型对话页在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 可以看当前可用的模型列表。长期跑编码类 Agent建议直接上 Coding Plan入口 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 额度模型更适合高频工具调用场景。环境变量这样设别硬编码进代码export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api export TAOTOKEN_MODEL你的模型IDGo 侧读取用os.Getenv配合net/http发请求。注意 Base URL 结尾不要带/v1之外的路径OpenAI 兼容接口的 chat completions 路径是/v1/chat/completions拼的时候确认一下。如果你用 Claude Code 这类工具接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有 Base URL、Key、Model ID 三件套的填法。这一步的核心目的让 MCP 服务器和模型接入层各自独立。工具服务器只管执行模型调用统一走一个 Key排障时能快速判断是工具挂了还是模型通道挂了。下面进入代码。3. Go 手写 MCP 服务器go.mod 依赖与工具注册可复制配置先建项目。目录结构建议这样后面排障好定位mcp-demo/ ├── go.mod ├── main.go ├── internal/ │ ├── mcp/ │ │ ├── server.go │ │ └── types.go │ └── tools/ │ └── registry.gogo.mod依赖清单MCP 用 JSON-RPC 2.0标准库net/httpencoding/json就够不需要额外框架module mcp-demo go 1.22 require ( github.com/google/uuid v1.6.0 )uuid用来生成请求 ID可选。如果你不想引外部依赖用时间戳当 ID 也行。先定义类型。internal/mcp/types.gopackage mcp import encoding/json type Request struct { JSONRPC string json:jsonrpc ID json.RawMessage json:id Method string json:method Params json.RawMessage json:params } type Response struct { JSONRPC string json:jsonrpc ID json.RawMessage json:id Result interface{} json:result,omitempty Error *RPCError json:error,omitempty } type RPCError struct { Code int json:code Message string json:message } type Tool struct { Name string json:name Description string json:description InputSchema InputSchema json:inputSchema } type InputSchema struct { Type string json:type Properties map[string]Property json:properties Required []string json:required } type Property struct { Type string json:type Description string json:description } type ToolCallParams struct { Name string json:name Arguments map[string]interface{} json:arguments } type ToolResult struct { Content []Content json:content } type Content struct { Type string json:type Text string json:text }工具注册表internal/tools/registry.gopackage tools import ( fmt mcp-demo/internal/mcp ) type ToolHandler func(args map[string]interface{}) (*mcp.ToolResult, error) type ToolRegistry struct { tools map[string]mcp.Tool handlers map[string]ToolHandler } func NewRegistry() *ToolRegistry { return ToolRegistry{ tools: make(map[string]mcp.Tool), handlers: make(map[string]ToolHandler), } } func (r *ToolRegistry) Register(tool mcp.Tool, handler ToolHandler) { r.tools[tool.Name] tool r.handlers[tool.Name] handler } func (r *ToolRegistry) GetHandler(name string) (ToolHandler, bool) { h, ok : r.handlers[name] return h, ok } func (r *ToolRegistry) List() []mcp.Tool { out : make([]mcp.Tool, 0, len(r.tools)) for _, t : range r.tools { out append(out, t) } return out } func (r *ToolRegistry) ValidateToolCall(name string) error { if _, ok : r.tools[name]; !ok { return fmt.Errorf(工具 %s 不存在, name) } return nil }注册一个json_format工具放在main.go里演示registry : tools.NewRegistry() registry.Register(mcp.Tool{ Name: json_format, Description: 格式化 JSON 字符串使其更易读, InputSchema: mcp.InputSchema{ Type: object, Properties: map[string]mcp.Property{ input: {Type: string, Description: 要格式化的 JSON 字符串}, indent: {Type: string, Description: 缩进字符默认两个空格}, }, Required: []string{input}, }, }, func(args map[string]interface{}) (*mcp.ToolResult, error) { input, _ : args[input].(string) indent : if v, ok : args[indent].(string); ok v ! { indent v } var buf bytes.Buffer if err : json.Indent(buf, []byte(input), , indent); err ! nil { return nil, fmt.Errorf(JSON 解析失败: %w, err) } return mcp.ToolResult{ Content: []mcp.Content{{Type: text, Text: buf.String()}}, }, nil })MCP 服务器核心internal/mcp/server.go处理initialize、tools/list、tools/callpackage mcp import ( encoding/json net/http ) type Server struct { registry *tools.ToolRegistry } func (s *Server) HandleMessage(body []byte) *Response { var req Request if err : json.Unmarshal(body, req); err ! nil { return Response{JSONRPC: 2.0, Error: RPCError{Code: -32700, Message: parse error}} } switch req.Method { case initialize: return s.handleInitialize(req) case tools/list: return s.handleToolsList(req) case tools/call: return s.handleToolsCall(req) case ping: return Response{JSONRPC: 2.0, ID: req.ID, Result: map[string]string{}} default: return Response{JSONRPC: 2.0, ID: req.ID, Error: RPCError{Code: -32601, Message: method not found}} } } func (s *Server) handleToolsList(req Request) *Response { return Response{ JSONRPC: 2.0, ID: req.ID, Result: map[string]interface{}{tools: s.registry.List()}, } } func (s *Server) handleToolsCall(req Request) *Response { var params ToolCallParams if err : json.Unmarshal(req.Params, params); err ! nil { return Response{JSONRPC: 2.0, ID: req.ID, Error: RPCError{Code: -32602, Message: invalid params}} } if err : s.registry.ValidateToolCall(params.Name); err ! nil { return Response{JSONRPC: 2.0, ID: req.ID, Error: RPCError{Code: -32601, Message: err.Error()}} } handler, _ : s.registry.GetHandler(params.Name) result, err : handler(params.Arguments) if err ! nil { return Response{JSONRPC: 2.0, ID: req.ID, Error: RPCError{Code: -32000, Message: err.Error()}} } return Response{JSONRPC: 2.0, ID: req.ID, Result: result} }HTTP 入口在main.go监听/mcphttp.HandleFunc(/mcp, func(w http.ResponseWriter, r *http.Request) { body, _ : io.ReadAll(r.Body) resp : server.HandleMessage(body) w.Header().Set(Content-Type, application/json) json.NewEncoder(w).Encode(resp) }) http.ListenAndServe(:8080, nil)跑起来go run main.go。到这里 MCP 服务器就绪工具注册表可扩展加新工具就是再调一次Register。4. curl 验证 tools/list 与 tools/call跑通本地 Agent 工具链服务器起来后先用curl验证两个核心动作别急着接模型。这一步能确认 JSON-RPC 链路是通的。验证tools/listcurl -s -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -d { jsonrpc: 2.0, id: 1, method: tools/list, params: {} }预期返回{ jsonrpc: 2.0, id: 1, result: { tools: [ { name: json_format, description: 格式化 JSON 字符串使其更易读, inputSchema: { type: object, properties: { input: {type: string, description: 要格式化的 JSON 字符串}, indent: {type: string, description: 缩进字符默认两个空格} }, required: [input] } } ] } }验证tools/callcurl -s -X POST http://localhost:8080/mcp \ -H Content-Type: application/json \ -d { jsonrpc: 2.0, id: 2, method: tools/call, params: { name: json_format, arguments: { input: {\name\:\test\,\age\:18} } } }预期返回格式化后的文本{ jsonrpc: 2.0, id: 2, result: { content: [ { type: text, text: {\n \name\: \test\,\n \age\: 18\n} } ] } }两个动作都通了说明 MCP 服务器可用。接下来把模型接进来。Agent 循环里先调tools/list拿工具清单拼进系统提示再调 TaoToken 的 chat completions。模型返回工具调用意图后Agent 转成tools/call打到 MCP 服务器把结果回填上下文继续下一轮。这个循环要有maxSteps限制防止死循环。模型调用示例用net/http直接发payload : map[string]interface{}{ model: os.Getenv(TAOTOKEN_MODEL), messages: []map[string]string{ {role: system, content: 你可以调用工具工具清单如下 toolsJSON}, {role: user, content: userInput}, }, } body, _ : json.Marshal(payload) req, _ : http.NewRequest(POST, os.Getenv(TAOTOKEN_BASE_URL)/v1/chat/completions, bytes.NewReader(body)) req.Header.Set(Authorization, Bearer os.Getenv(TAOTOKEN_API_KEY)) req.Header.Set(Content-Type, application/json)跑通后你会看到模型决定调json_formatAgent 执行工具结果回填模型输出最终答案。整条链路里MCP 服务器和模型接入层完全解耦换模型只改环境变量。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth排障按链路分段定位别一上来就怀疑模型。401 Unauthorized。先看 Key 有没有带对。Authorization: Bearer sk-xxx中间一个空格别漏。再看环境变量有没有真的导出echo $TAOTOKEN_API_KEY确认。如果 Key 是从控制台复制的注意别把前后空格带进去。401 基本就是 Key 问题跟 MCP 服务器无关。local proxy failed。这个报错通常出现在你本地配了代理类工具但代理没起来或端口不对。检查你的 HTTP 客户端有没有走系统代理Go 里http.DefaultClient默认读环境变量HTTP_PROXY。如果你不需要代理显式设http.Transport{Proxy: nil}避免被环境变量带偏。这个报错和 MCP 服务器本身没关系是出站请求的问题。reading choices 相关报错。这类错误一般出现在解析模型响应时choices字段为空或结构不对。先打印原始响应体确认返回的是不是 OpenAI 兼容格式。常见原因是 Base URL 拼错比如多拼了/v1/v1或者模型 ID 写错导致返回错误结构。把TAOTOKEN_BASE_URL设成https://taotoken.net/api路径拼/v1/chat/completions别重复。OAuth 相关报错。如果你用 Claude Code 或类似工具接入报 OAuth 错误通常是认证方式选错了。这类工具要走 API Key 模式不是 OAuth 登录模式。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 按里面的 Base URL、Key、Model ID 三件套填。Claude Code 的接入参考 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude-code-anthropicutm_campaignrewrite 里面有具体配置项。工具调用幻觉。模型返回了一个你没注册的工具名。在handleToolsCall里已经做了ValidateToolCall会返回-32601。Agent 侧拿到这个错误后应该把可用工具列表回填给模型让它重选而不是直接失败。JSON 解析失败。json_format工具收到非法 JSON 会返回错误。这是预期行为Agent 应该把错误信息回填让模型修正输入。别在工具里吞掉错误。排障顺序建议先curl打 MCP 服务器确认工具层通再单独测模型接口确认 Key 和 Base URL 对最后串起来跑循环。分段验证比一把梭快得多。6. 把工具链跑稳之后模型对话、Coding Plan 与接入文档工具链跑通只是起点。接下来你会想让 Agent 真正干活这时候模型通道的稳定性就重要了。验证单个模型行为用模型对话页 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 直接试确认模型对工具调用的支持程度。长期跑编码类 AgentCoding Plan 入口 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 更适合高频调用场景额度模型省心。Key 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 建议给不同环境建不同 Key方便按环境排障和轮换。接入细节和参数说明看文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有 Base URL、Key、Model ID 的完整填法。回到代码本身MCP 服务器加新工具就是再调一次Register工具多了之后建议按领域拆文件internal/tools/json.go、internal/tools/crypto.go这样。工具描述写清楚模型选工具的准确率会高很多。InputSchema的required字段别漏模型靠它判断哪些参数必填。最后说个实际经验Agent 循环里maxSteps设 10 左右比较稳配合context.WithTimeout给每个工具调用 30 秒超时。工具执行失败时把错误信息结构化回填模型下一轮往往能自己修正。这套结构跑顺之后你会发现后端工程师做 Agent 的核心竞争力就是把不稳定的模型输出包成稳定可控的服务而 MCP 就是那个标准化的接口层。