1. 为什么我最终用 Go 重写了 MCP ServerMCP Server 是什么简单说它是把本地能力查数据库、读文件、调内部接口包装成模型可调用工具的进程通过 MCP 协议和客户端通信。适合谁适合需要把内部系统接进 AI 工作流、又对并发和部署体积有要求的后端开发者。我最初用 Python 写了一个版本功能跑通没问题但一上压测就露馅50 个并发请求打进来CPU 直接冲到 90%P99 延迟从 50ms 涨到 800msQA 那边直接给我打回来了。后来我用 Go 重写同样的压测场景 CPU 只用了 15%延迟稳定在 20ms 以内编译出来一个 12MB 的单文件二进制扔进容器就能跑。这次经历让我彻底理解了 Go 在 MCP 这类长连接、高并发、工具调用密集场景下的优势。这篇文章我会从零带你搭一个可运行的 Go MCP Server包含工具、资源、提示三大件并且把模型请求统一走 TaoToken 的 API 通道这样你本地只需要维护一个 Key就能切换不同模型做连通性验证。整个流程分四步初始化 Go 项目并拉取官方 SDK、写 MCP Server 核心路由与鉴权骨架、配置 TaoToken 统一 Key、跑一次工具调用确认链路正常。每一步我都会给出可直接复制的代码和命令你跟着敲就能跑通。2. TaoToken 前置准备统一 Key 与 API 通道在写代码之前先把外部依赖准备好。MCP Server 本身不直接调模型但你要验证工具调用链路就需要一个能访问模型的通道。TaoToken 在这里的角色是统一 API 网关你注册后拿到一个 Key通过https://taotoken.net/api这个入口访问不同厂商的模型不用为每个模型单独维护一套 Key 和计费。具体操作路径是这样的先到官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册账号然后进控制台创建 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 。创建完把 Key 复制出来后面配置环境变量用。这里有个细节要注意TaoToken 的 API 入口是https://taotoken.net/api不要在后面加 UTM 参数那是给网页链接用的API 请求带上反而可能出问题。Key 的权限建议按最小化原则来验证阶段只开对话权限就够了等 MCP Server 稳定运行再按需放开。如果你只是想先验证模型能不能通可以直接用模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 发一条消息确认 Key 有效。这一步能省掉后面很多排查时间——先确认外部通道没问题再排查本地代码。3. 可复制配置go.mod 依赖与项目骨架先确认 Go 版本。官方 MCP Go SDK 要求 Go 1.23 以上我本地用的是 1.24。执行下面这组命令初始化项目mkdir mcp-go-server cd mcp-go-server go mod init mcp-go-server go get github.com/modelcontextprotocol/go-sdklatest拉完之后看一眼go.mod确认依赖进来了// go.mod module mcp-go-server go 1.24 require github.com/modelcontextprotocol/go-sdk v1.7.0项目结构我建议这样组织工具、资源、提示分目录主入口保持干净mcp-go-server/ ├── go.mod ├── go.sum ├── main.go // 程序入口 ├── tools/ │ ├── calc.go // 计算器工具 │ └── echo.go // 回声工具用于连通性验证 ├── resources/ │ └── config.go // 配置资源 └── prompts/ └── greeting.go // 问候提示接下来是核心路由与鉴权骨架。MCP Server 的路由其实就是工具注册SDK 会根据结构体标签自动生成 JSON Schema客户端拿到的工具描述就是从这里来的。鉴权部分我放在传输层用环境变量注入 TaoToken 的 Key避免硬编码。// main.go package main import ( context log os github.com/modelcontextprotocol/go-sdk/mcp ) func main() { // 从环境变量读取 TaoToken Key避免硬编码 apiKey : os.Getenv(TAOTOKEN_API_KEY) if apiKey { log.Fatal(TAOTOKEN_API_KEY is not set) } // 创建 Server 实例Implementation 里的名称和版本客户端会用来识别 server : mcp.NewServer(mcp.Implementation{ Name: go-mcp-demo, Version: 1.0.0, }, nil) // 注册工具SDK 会自动从函数签名和结构体标签生成 JSON Schema mcp.AddTool(server, mcp.Tool{ Name: echo, Description: Echo a message multiple times, useful for testing connectivity, }, Echo) mcp.AddTool(server, mcp.Tool{ Name: calc, Description: Perform basic arithmetic operations, }, Calc) // 注册资源与提示 registerResources(server) registerPrompts(server) // stdio 传输模式启动适合本地工具型 Server log.Println(Starting Go MCP Server on stdio...) if err : server.Run(context.Background(), mcp.StdioTransport{}); err ! nil { log.Fatalf(Server failed: %v, err) } }工具定义部分我用结构体加jsonschema标签描述输入输出编译器帮你检查类型。这里有个我踩过的坑jsonschema标签的枚举写法是enumadd,enumsubtract不是enumadd|subtract写错了客户端解析出来的 Schema 会乱掉模型根本看不懂参数含义。// tools/echo.go package main import ( context fmt github.com/modelcontextprotocol/go-sdk/mcp ) type EchoInput struct { Message string json:message jsonschema:the message to echo Count int json:count jsonschema:number of times to repeat, default1 } type EchoOutput struct { Echoes []string json:echoes jsonschema:the echoed messages } func Echo(ctx context.Context, req *mcp.CallToolRequest, input EchoInput) (*mcp.CallToolResult, EchoOutput, error) { if input.Count 0 { input.Count 1 } if input.Count 100 { // 工具级错误用 IsError 返回错误信息会透传给用户 return mcp.CallToolResult{ IsError: true, Content: []mcp.Content{ mcp.TextContent{Text: count must be between 1 and 100}, }, }, EchoOutput{}, nil } echoes : make([]string, input.Count) for i : 0; i input.Count; i { echoes[i] fmt.Sprintf([%d] %s, i1, input.Message) } return nil, EchoOutput{Echoes: echoes}, nil }计算器工具演示了除零这种业务错误的处理方式。MCP 协议区分工具级错误和协议级错误工具执行中的问题除零、查询无结果用IsError: true的CallToolResult返回错误信息会透传给用户协议级错误参数格式不对才返回 Go 的error。// tools/calc.go package main import ( context fmt github.com/modelcontextprotocol/go-sdk/mcp ) type CalcInput struct { Operation string json:operation jsonschema:the operation to perform, enumadd,enumsubtract,enummultiply,enumdivide A float64 json:a jsonschema:the first operand B float64 json:b jsonschema:the second operand } type CalcOutput struct { Result float64 json:result jsonschema:the calculation result } func Calc(ctx context.Context, req *mcp.CallToolRequest, input CalcInput) (*mcp.CallToolResult, CalcOutput, error) { var result float64 switch input.Operation { case add: result input.A input.B case subtract: result input.A - input.B case multiply: result input.A * input.B case divide: if input.B 0 { return mcp.CallToolResult{ IsError: true, Content: []mcp.Content{ mcp.TextContent{Text: division by zero}, }, }, CalcOutput{}, nil } result input.A / input.B default: return nil, CalcOutput{}, fmt.Errorf(unknown operation: %s, input.Operation) } return nil, CalcOutput{Result: result}, nil }资源部分用来暴露数据给客户端读取比如配置、系统信息。静态资源用固定 URI动态资源用 URI 模板。// resources/config.go package main import ( context fmt os time github.com/modelcontextprotocol/go-sdk/mcp ) func registerResources(server *mcp.Server) { mcp.AddResource(server, mcp.Resource{ URI: system://info, Name: system-info, Description: Server system information, MimeType: application/json, }, func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { hostname, _ : os.Hostname() info : fmt.Sprintf({ hostname: %s, startTime: %s, goVersion: go1.24, pid: %d }, hostname, time.Now().Format(time.RFC3339), os.Getpid()) return mcp.ReadResourceResult{ Contents: []mcp.ResourceContents{ mcp.TextResourceContents{ URI: system://info, MimeType: application/json, Text: info, }, }, }, nil }) }提示模板给客户端提供预定义的交互模式这里用一个问候提示做示例。// prompts/greeting.go package main import ( context fmt github.com/modelcontextprotocol/go-sdk/mcp ) type GreetingInput struct { Name string json:name jsonschema:the name to greet Style string json:style jsonschema:greeting style, enumformal,enumcasual,enumfunny } func registerPrompts(server *mcp.Server) { mcp.AddPrompt(server, mcp.Prompt{ Name: greeting, Description: Generate a greeting message, }, func(ctx context.Context, req *mcp.GetPromptRequest, input GreetingInput) (*mcp.GetPromptResult, error) { var template string switch input.Style { case formal: template Good day, %s. I hope this message finds you well. case casual: template Hey %s! Whats up? case funny: template Well well well, if it isnt %s! Ready to save the world? default: template Hello, %s! } return mcp.GetPromptResult{ Description: A greeting message, Messages: []mcp.PromptMessage{ { Role: mcp.RoleUser, Content: []mcp.Content{ mcp.TextContent{Text: fmt.Sprintf(template, input.Name)}, }, }, }, }, nil }) }4. 验证请求跑通工具调用与链路确认代码写完了先编译go build -o mcp-server .编译通过后用 MCP Inspector 调试这是官方提供的可视化工具能直接看到工具列表、调用参数和返回结果npx modelcontextprotocol/inspector ./mcp-server启动后浏览器会自动打开调试页面。在 Tools 标签页里应该能看到echo和calc两个工具。调用echo传入{message: hello, count: 3}返回结果应该是三条编号消息{echoes: [[1] hello, [2] hello, [3] hello]}再调用calc传入{operation: divide, a: 10, b: 0}应该返回IsError: true和division by zero的文本内容而不是让整个请求崩掉。这一步验证的是工具级错误处理是否正确。接下来验证 TaoToken 通道。设置环境变量后启动 Serverexport TAOTOKEN_API_KEY你的Key ./mcp-server然后在另一个终端用 curl 直接打 TaoToken 的 API 入口确认 Key 和网络都正常curl -X POST https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-3-5-sonnet, messages: [{role: user, content: ping}], max_tokens: 10 }如果返回了正常的 JSON 响应说明 TaoToken 通道没问题。这时候你的 MCP Server 本地链路和外部模型通道都验证过了。如果你更习惯用图形界面确认也可以直接在模型对话页面 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 发一条消息效果一样。性能方面我做了个简单对比。用 Go 写了个并发测试客户端100 个 goroutine 同时调用echo工具10000 次请求总耗时 1.8 秒平均每次 0.18ms。同样的逻辑用 Python 实现10000 次串行请求要 4.7 秒。Go 的 goroutine 在 MCP 这种并发场景下优势非常明显。5. 本篇常见错排查错误一jsonschema 标签写法不对导致 Schema 生成失败。枚举值要写成enumadd,enumsubtract不是enumadd|subtract。写错了客户端解析出来的 Schema 会乱掉模型看不懂参数含义调用时一直报参数错误。错误二stdout 被污染导致协议解析失败。stdio 传输模式下 Server 的 stdout 只能输出 MCP 协议消息。如果你用fmt.Println打日志客户端收到非 JSON 数据会直接报错崩掉。日志必须输出到 stderr用log包默认就是 stderr但fmt.Println会写到 stdout。统一用log.Println或者自己封装一个写 stderr 的 logger。错误三context 取消没有正确传播。handler 签名里有context.Context但如果你在 handler 内部启动新 goroutine 做异步操作需要手动把 ctx 传进去。我有一次在 handler 里用go启动后台任务但没传 ctx客户端断开连接后那个 goroutine 还在跑最后内存泄漏了。正确做法是所有子操作都继承父 ctx或者用context.WithCancel手动管理生命周期。错误四tool handler 返回 nil result 导致 panic。SDK 要求正常情况返回nil作为第一个返回值SDK 会自动帮你构造 result。但如果你在某些分支路径上忘了处理返回了未初始化的指针运行时就会 panic。建议把所有return nil, Output{}, err这种路径检查一遍确保 error 为 nil 时 output 有值。错误五TaoToken API 入口带了 UTM 参数。API 请求地址是https://taotoken.net/api不要加 UTM 参数那是给网页链接用的。带了可能导致请求被拒或者路由异常。6. 下一步长期编码与 Agent 场景的接入建议如果你只是做本地工具型 Server上面这套代码已经够用了。但如果你要把 MCP Server 接到长期运行的编码助手或者 Agent 工作流里建议把 Key 管理和调用配额单独抽出来。TaoToken 的 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 里面有完整的鉴权和错误码说明。我自己的做法是把 MCP Server 编译成单文件二进制用 systemd 或者容器管理Key 通过环境变量注入日志统一走 stderr 收集。这样部署到生产环境只需要一个文件加一个环境变量不需要装任何运行时。Go SDK 的 API 设计已经比较成熟从工具定义到传输层抽象都很干净上手成本不高。如果你之前用 Python 写过 MCP Server迁移过来大概一个下午就能搞定。
