
1. 从零手写 MCP 服务端骨架为什么不用 SDK 也要懂 JSON-RPCMCPModel Context Protocol是让大模型与外部工具、数据源对话的协议标准而 JSON-RPC 2.0 是它底层的消息格式。很多人第一次接触 MCP 时直接上官方 SDK结果遇到tools/list返回空、tools/call报-32601 Method not found就懵了——因为不知道 SDK 在背后替你拼了什么 JSON。这篇就反过来用 Go 从零手写一个最小 MCP 服务端骨架只实现两个方法tools/list和tools/call跑通一次完整往返。适合已经看过 MCP 概念、想搞清楚协议底层交互的 Go 开发者也适合需要自己实现 MCP 服务端、不想被 SDK 黑盒挡住的人。MCP 的通信模型其实很朴素客户端和服务端通过 stdin/stdout或 HTTP/SSE交换 JSON-RPC 消息。每条消息要么是请求带id、method、params要么是响应带id、result或error要么是通知无id。tools/list是客户端问“你有哪些工具”服务端返回工具数组tools/call是客户端说“帮我调用某个工具参数是这些”服务端执行后返回结果。工具的参数和返回值用 JSON Schema 描述这样客户端通常是大模型才知道该怎么填参数。我试过直接读 MCP 规范里的 TypeScript 类型定义一开始觉得字段太多但真正跑通最小骨架后回头看核心结构就那么几个。下面按“先跑通再优化”的思路来先定义消息结构体再写路由分发然后声明一个工具最后用 curl 或管道验证。整个过程不需要任何第三方 MCP 库只用 Go 标准库的encoding/json和bufio。需要说明的是如果你后续要接入真实的大模型客户端比如 Claude Code、Cline 这类手动拼 JSON-RPC 容易在字段命名、_meta、isError这些细节上踩坑。这时候可以用 TaoToken 这类兼容 MCP 的接入层来统一管理模型调用和工具注册它的 API 地址是 https://taotoken.net/api 模型对话入口在 https://taotoken.net/models 后面第五节会讲怎么把手写骨架和这类服务对接起来做验证。先明确目标我们要写的服务端启动后从 stdin 读一行 JSON解析出method如果是tools/list就返回工具列表如果是tools/call就执行对应函数并返回结果其他方法返回-32601。所有响应写到 stdout一行一条。这个骨架跑通后你就理解了 MCP 服务端 80% 的交互逻辑。2. 环境准备与 TaoToken 前置配置go.mod 依赖和 API Key 怎么放在写代码之前先把工程目录和依赖理清楚。Go 版本建议 1.21 以上因为后面会用到slices和泛型相关特性虽然最小骨架用不到泛型但为后续扩展留余地。新建目录mcp-go-skeleton初始化模块mkdir mcp-go-skeleton cd mcp-go-skeleton go mod init example.com/mcp-go-skeleton这个骨架不需要任何第三方依赖go.mod里只有模块声明和 Go 版本module example.com/mcp-go-skeleton go 1.21如果你打算后续接入真实的模型服务来测试工具调用可以准备一个 API Key。TaoToken 的 API Key 在控制台创建地址是 https://taotoken.net/api-keys 创建后拿到形如sk-xxx的字符串。注意这个 Key 是给模型调用用的不是 MCP 协议本身需要的——MCP 服务端和客户端之间的认证是另一套机制通常在传输层做比如 HTTP header。这里提前说清楚避免混淆。目录结构建议这样组织方便后面扩展mcp-go-skeleton/ ├── go.mod ├── main.go // 入口启动 stdio 循环 ├── protocol.go // JSON-RPC 消息结构体 ├── tools.go // 工具注册与执行 └── schema.go // JSON Schema 定义protocol.go里定义 JSON-RPC 2.0 的基础结构。注意id字段用json.RawMessage而不是int因为 JSON-RPC 允许id是字符串或数字用RawMessage可以原样回传避免类型不匹配导致客户端解析失败package main import encoding/json type Request struct { JSONRPC string json:jsonrpc ID json.RawMessage json:id,omitempty Method string json:method Params json.RawMessage json:params,omitempty } type Response struct { JSONRPC string json:jsonrpc ID json.RawMessage json:id,omitempty Result any json:result,omitempty Error *RPCError json:error,omitempty } type RPCError struct { Code int json:code Message string json:message Data any json:data,omitempty }这里有个细节Result用any而不是json.RawMessage因为我们要返回结构体让json.Marshal自动处理。但Error用指针因为成功响应里不应该出现error字段omitempty对指针有效对结构体无效。tools.go里定义工具的结构。MCP 规范里工具对象包含name、description、inputSchema三个必填字段inputSchema是 JSON Schema 对象type Tool struct { Name string json:name Description string json:description InputSchema json.RawMessage json:inputSchema } type ListToolsResult struct { Tools []Tool json:tools } type CallToolParams struct { Name string json:name Arguments json.RawMessage json:arguments,omitempty } type CallToolResult struct { Content []Content json:content IsError bool json:isError,omitempty } type Content struct { Type string json:type Text string json:text,omitempty }Content这里简化成只支持文本类型实际 MCP 规范还支持图片、音频、嵌入资源等用接口或带可选字段的结构体实现。最小骨架先跑通文本后面再扩展。schema.go里定义工具的输入 Schema。我们做一个add工具接收两个整数x和y返回它们的和package main import encoding/json var addToolSchema json.RawMessage({ type: object, properties: { x: {type: integer, description: 第一个加数}, y: {type: integer, description: 第二个加数} }, required: [x, y] })注意json.RawMessage里的反引号字符串不能有换行缩进问题实际写的时候可以压成一行或者用json.Marshal从结构体生成。手写 Schema 的好处是直观坏处是容易漏字段——比如忘了required客户端可能不传参数就调用服务端要自己兜底校验。到这里前置配置就齐了模块初始化、目录结构、协议结构体、工具结构体、Schema 定义。接下来写核心的路由和 stdio 循环。3. 可复制配置main.go 路由分发与 stdio 循环完整代码main.go是整个骨架的入口负责读 stdin、解析请求、分发到对应处理函数、写 stdout。先看完整代码再逐段解释package main import ( bufio encoding/json fmt os ) func main() { scanner : bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 1024*1024), 1024*1024) writer : bufio.NewWriter(os.Stdout) defer writer.Flush() for scanner.Scan() { line : scanner.Bytes() if len(line) 0 { continue } var req Request if err : json.Unmarshal(line, req); err ! nil { writeError(writer, nil, -32700, Parse error) continue } resp : handleRequest(req) if resp ! nil { data, _ : json.Marshal(resp) writer.Write(data) writer.WriteByte(\n) writer.Flush() } } }scanner.Buffer那行把缓冲区调到 1MB因为工具调用的参数可能很大比如传一段代码进去默认 64KB 会截断。writer.Flush()每次写完都调用确保客户端能立即收到响应不然会卡在缓冲区里。handleRequest是路由核心func handleRequest(req *Request) *Response { switch req.Method { case initialize: return Response{ JSONRPC: 2.0, ID: req.ID, Result: map[string]any{ protocolVersion: 2024-11-05, capabilities: map[string]any{ tools: map[string]any{}, }, serverInfo: map[string]any{ name: mcp-go-skeleton, version: 0.1.0, }, }, } case tools/list: return Response{ JSONRPC: 2.0, ID: req.ID, Result: ListToolsResult{ Tools: []Tool{ { Name: add, Description: 计算两个整数之和, InputSchema: addToolSchema, }, }, }, } case tools/call: return handleCallTool(req) case notifications/initialized: return nil default: return Response{ JSONRPC: 2.0, ID: req.ID, Error: RPCError{ Code: -32601, Message: Method not found, }, } } }initialize是 MCP 握手方法客户端连上后第一件事就是调它服务端要返回协议版本、能力声明、服务端信息。notifications/initialized是通知没有id不需要响应所以返回nil。tools/list返回工具数组。tools/call单独处理。handleCallTool解析参数并执行func handleCallTool(req *Request) *Response { var params CallToolParams if err : json.Unmarshal(req.Params, params); err ! nil { return Response{ JSONRPC: 2.0, ID: req.ID, Error: RPCError{Code: -32602, Message: Invalid params}, } } switch params.Name { case add: var args struct { X int json:x Y int json:y } if err : json.Unmarshal(params.Arguments, args); err ! nil { return Response{ JSONRPC: 2.0, ID: req.ID, Result: CallToolResult{ Content: []Content{{Type: text, Text: 参数解析失败: err.Error()}}, IsError: true, }, } } sum : args.X args.Y return Response{ JSONRPC: 2.0, ID: req.ID, Result: CallToolResult{ Content: []Content{{Type: text, Text: fmt.Sprintf(%d %d %d, args.X, args.Y, sum)}}, }, } default: return Response{ JSONRPC: 2.0, ID: req.ID, Error: RPCError{Code: -32602, Message: Unknown tool: params.Name}, } } }注意工具执行错误比如参数解析失败走的是Result里的IsError: true而不是 JSON-RPC 的Error字段。这是 MCP 规范的要求协议层错误方法不存在、参数格式错用error业务层错误工具执行失败用result.isError。这个区分很重要客户端会根据isError决定是否把错误信息喂给模型。writeError辅助函数func writeError(w *bufio.Writer, id json.RawMessage, code int, msg string) { resp : Response{ JSONRPC: 2.0, ID: id, Error: RPCError{Code: code, Message: msg}, } data, _ : json.Marshal(resp) w.Write(data) w.WriteByte(\n) w.Flush() }编译运行go build -o mcp-server . ./mcp-server程序会阻塞等待 stdin 输入。接下来手动喂一条tools/list请求验证。4. 验证请求与成功结果用 curl 和管道跑通 tools/list 与 tools/call因为我们的服务端走 stdio不是 HTTP所以不能用 curl 直接打。有两种验证方式管道喂 JSON或者写个简单的 Go 客户端。先看管道方式最直观echo {jsonrpc:2.0,id:1,method:tools/list} | ./mcp-server预期输出格式化后{ jsonrpc: 2.0, id: 1, result: { tools: [ { name: add, description: 计算两个整数之和, inputSchema: { type: object, properties: { x: {type: integer, description: 第一个加数}, y: {type: integer, description: 第二个加数} }, required: [x, y] } } ] } }如果输出里tools是空数组或者报-32601检查handleRequest里的case tools/list拼写以及Request结构体的Method字段 tag 是不是json:method。再验证tools/callecho {jsonrpc:2.0,id:2,method:tools/call,params:{name:add,arguments:{x:3,y:5}}} | ./mcp-server预期输出{ jsonrpc: 2.0, id: 2, result: { content: [ {type: text, text: 3 5 8} ] } }如果content为空或者报Invalid params检查CallToolParams的Arguments字段是不是json.RawMessage以及add工具的参数结构体字段 tag 是不是json:x和json:y。一次完整的 MCP 会话应该先initialize再notifications/initialized然后才是tools/list。用管道一次性喂多条printf %s\n%s\n%s\n \ {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2024-11-05,capabilities:{},clientInfo:{name:test,version:1.0}}} \ {jsonrpc:2.0,method:notifications/initialized} \ {jsonrpc:2.0,id:2,method:tools/list} \ | ./mcp-server会看到三行输出第一行是initialize的响应第二行是tools/list的响应notifications/initialized没有响应所以不输出。如果第二行缺失检查notifications/initialized的case是不是返回了nil。如果你要接入真实的模型客户端测试比如让模型决定调用add工具可以把服务端注册到支持 MCP 的客户端里。以 TaoToken 的模型对话为例它的 API 地址是 https://taotoken.net/api 你可以在客户端配置里把 MCP 服务端指向我们编译出的mcp-server可执行文件然后让模型处理“3 加 5 等于几”这类问题观察它是否发起tools/call。这一步能验证你的 Schema 是否被模型正确理解——如果模型不调用工具多半是description写得太模糊或者inputSchema缺了required。验证通过后你已经跑通了 MCP 最核心的往返。接下来看常见报错怎么排查。5. 本篇常见错排查401、local proxy failed、reading choices 与 OAuth 报错对照手写骨架时遇到的错误分两类协议层错误和接入层错误。协议层错误看 JSON-RPC 的error.code接入层错误看客户端日志。下面按真实报错对照。-32601 Method not found最常见。原因通常是handleRequest的switch里没有对应case或者Request.Method字段的 tag 写成了json:Method大小写敏感。检查方法名拼写MCP 规范里是tools/list和tools/call不是tool/list或tools.list。-32700 Parse errorstdin 收到的不是合法 JSON。用echo测试时注意引号转义JSON 里的双引号在 shell 单引号里是安全的但如果用双引号包裹整个 JSON里面的双引号会被 shell 吃掉。建议用单引号或者写到文件里用cat file.json | ./mcp-server。-32602 Invalid paramstools/call的params解析失败。检查CallToolParams的Arguments字段是不是json.RawMessage以及客户端传的arguments是不是对象不是字符串。如果客户端传的是arguments: {\x\:3}字符串化的 JSON需要先json.Unmarshal一次再解析。401 Unauthorized这个不是 MCP 协议本身的错误而是接入模型服务时 API Key 无效或缺失。如果你用 TaoToken 的 API 做模型调用检查Authorization: Bearer sk-xxx头是否带上Key 是否在 https://taotoken.net/api-keys 正确创建。注意 MCP 服务端和模型 API 是两层MCP 服务端不需要这个 Key是调用模型的客户端需要。local proxy failed通常出现在客户端配置了本地代理但代理没启动或者代理地址写错。MCP 的 stdio 传输不经过网络如果报这个错检查客户端是不是把 stdio 服务端误配成了 HTTP 服务端。stdio 服务端的配置应该是commandargs不是url。reading choices相关报错这是模型 API 返回格式不符合预期时的错误常见于客户端把非 OpenAI 兼容的响应当成 OpenAI 格式解析。检查你的模型服务是否返回标准的choices数组。如果用 TaoToken 的模型对话接口确认请求体里的model字段是支持的模型 ID。OAuth 报错MCP 的 HTTP 传输支持 OAuth 认证但 stdio 传输不需要。如果你在 stdio 服务端看到 OAuth 相关错误说明客户端配置里混入了 HTTP 传输的认证配置。检查客户端的 MCP 配置stdio 类型不应该有oauth字段。工具被调用但参数为空模型没有按 Schema 填参数。检查inputSchema的required是否包含所有必填字段properties里每个字段的type是否正确。如果 Schema 写的是type: integer但模型传了字符串3json.Unmarshal到int会失败走IsError: true分支。可以在参数解析失败时把原始arguments打日志看模型到底传了什么。响应没有输出检查writer.Flush()是否在每次写完后调用。bufio.Writer有缓冲不 Flush 的话数据留在缓冲区里客户端收不到。另外检查scanner.Scan()是否因为输入没有换行符而阻塞——JSON-RPC over stdio 要求每条消息以换行符结尾。排查时建议在handleRequest入口加一行日志到 stderr不是 stdoutstdout 是协议通道fmt.Fprintf(os.Stderr, recv: %s\n, line)这样能看到客户端实际发了什么对照上面的错误码定位。6. 语义一致 CTA把手写骨架接入真实 MCP 工作流骨架跑通后下一步是把它接入真实的 MCP 客户端和模型服务。手写骨架的价值在于你完全掌控了消息格式遇到问题时能直接定位到是哪一层出的错。但生产环境里工具注册、Schema 校验、进度通知、取消处理这些逻辑手写成本很高这时候可以用成熟的接入层来补足。如果你要继续验证模型对工具的调用可以用 TaoToken 的模型对话入口 https://taotoken.net/models 测试不同模型对add工具 Schema 的理解程度。把我们的mcp-server配置到客户端里然后问模型“帮我算 12 加 30”观察它是否发起tools/call并正确填x和y。这一步能暴露 Schema 设计问题——比如description写“计算两个整数之和”比写“add”更容易让模型理解。如果你打算长期做 MCP 相关的编码和 Agent 开发可以了解 TaoToken 的 Coding Plan https://taotoken.net/coding-plan 它把模型调用和工具编排做了封装省去手动拼 JSON-RPC 的重复劳动。接入文档在 https://taotoken.net/doc 里面有 stdio 和 HTTP 两种传输的配置示例。API Key 管理在 https://taotoken.net/api-keys 创建后可以直接用在客户端的模型配置里。回到骨架本身建议你在这个最小版本上继续加三个东西一是initialize时返回instructions字段告诉模型这个服务端能做什么二是给tools/call加超时控制用context.WithTimeout包住工具执行避免慢工具卡死整个会话三是把工具注册改成 map 驱动新增工具时只改数据不改路由代码。这三步做完你的手写骨架就接近生产可用了。最后留一个实操建议把tools/list的响应存成文件每次改完 Schema 后 diff 一下确保没有意外改动。MCP 客户端通常会缓存工具列表Schema 变了但客户端没刷新会出现“模型按旧 Schema 填参数、服务端按新 Schema 校验”的错位报错信息往往很隐晦。这个坑我在调试required字段时踩过排查了半天才发现是客户端缓存。