跳转至

modelcontextprotocol/go-sdk:MCP 协议的官方 Go 实现

这是 MCP(Model Context Protocol,模型上下文协议)的官方 Go 实现,简单说就是让你的 Go 程序能作为 MCP Server 或 Client,跟大模型/Agent 之间交换工具和数据——协议本身可以理解成大模型时代连接"大脑决策"与"本地执行"的标准总线,而 modelcontextprotocol/go-sdk 就是这条总线在 Go 语言里的官方实现,具备高性能、强并发安全的特点。

这篇文章按"先跑起来、再往下挖"的顺序展开:先给一个几行代码就能跑通的最小 Server 例子,建立一个直观的印象;再深入到三个实际使用中真实会踩坑的点——两种物理传输通道分别怎么用、初始化握手的时序到底卡在哪一步、以及高并发下工具注册和分发是怎么保证协程安全的。


1. 先跑起来:注册一个工具,启动一个最小的 Go MCP Server

MCP Server 说到底就是把一些"工具"(Tool)暴露出来,让 Host(大模型/Agent 运行时)能够发现并调用。用 Go SDK 写一个最小的 Server,核心逻辑只有三步:定义工具的输入/输出结构体、写一个处理函数、把它注册进 Server 再启动。下面这份示例代码顺带把生产环境必需的日志规范(避免打脏 stdout)和优雅退出也带上了,但真正跟"注册工具"相关的核心逻辑只是中间那几行:

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

// 1. 定义工具输入/输出结构体,通过 jsonschema 标签自动生成 JSON Schema
type CalculatorArgs struct {
    A float64 `json:"a" jsonschema:"the first addend"`
    B float64 `json:"b" jsonschema:"the second addend"`
}

type CalculatorResult struct {
    Result float64 `json:"result" jsonschema:"the sum of a and b"`
}

// 2. 工具处理函数:签名固定为 (ctx, *mcp.CallToolRequest, Input) -> (*mcp.CallToolResult, Output, error)
func AddNumbers(ctx context.Context, req *mcp.CallToolRequest, args CalculatorArgs) (
    *mcp.CallToolResult, CalculatorResult, error,
) {
    // 每次调用都在独立的协程中被分发处理
    fmt.Fprintf(os.Stderr, "Executing add_numbers: %f + %f\n", args.A, args.B)
    return nil, CalculatorResult{Result: args.A + args.B}, nil
}

func main() {
    // 3. 初始化标准错误日志,强制避免污染标准输出
    fmt.Fprintln(os.Stderr, "Starting MCP Go Server...")

    // 4. 创建 Server 实例,声明实现信息
    server := mcp.NewServer(&mcp.Implementation{
        Name:    "SystemCalculator",
        Version: "1.0.0",
    }, nil)

    // 5. 注册工具:AddTool 会基于 Input/Output 结构体的 jsonschema 标签自动生成 Schema
    mcp.AddTool(server, &mcp.Tool{
        Name:        "add_numbers",
        Description: "Add two float numbers together",
    }, AddNumbers)

    // 6. 建立优雅退出机制
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        <-sigChan
        fmt.Fprintln(os.Stderr, "Shutdown signal received, closing transport...")
        cancel()
    }()

    // 7. 启动服务,挂载 stdio 传输通道
    if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil {
        fmt.Fprintf(os.Stderr, "Server execution failed: %v\n", err)
        os.Exit(1)
    }
}

API 提示:官方 modelcontextprotocol/go-sdkgithub.com/modelcontextprotocol/go-sdk,与 Google 联合维护)的实际导出 API 是 mcp.NewServer + 泛型函数 mcp.AddTool,工具的输入输出 Schema 由 Go 结构体的 jsonschema 标签反射生成,而不是手写 RegisterTool + json.RawMessage 手动反序列化。传输层通过 server.Run(ctx, transport) 挂载,&mcp.StdioTransport{} 对应 stdio 通道。

这个最小例子跑通之后,有三个问题值得深挖:工具的 JSON Schema 到底是怎么从 Go 结构体反射出来的?AddTool 内部具体做了什么适配?每个工具调用请求又是不是像代码注释里说的那样,真的在独立协程里并发执行?下面分别展开。

1.1 Schema 是怎么从结构体反射出来的

mcp/tool.go 里并没有自己实现 Schema 推导,而是直接依赖 go.mod 声明的外部模块 github.com/google/jsonschema-go v0.4.3import "github.com/google/jsonschema-go/jsonschema")——这个包是从 go-sdk 里独立拆出来、由 Google 单独维护的通用 JSON Schema 库。真正触发反射的地方在 mcp/server.gosetSchema[T any] 泛型函数里:当调用方没有手写 Schema 时,它会调用 jsonschema.ForType(rt, &jsonschema.ForOptions{})reflect.Type 做递归推导,并把结果缓存进 SchemaCache,避免同一类型被反复反射。jsonschema 标签的读取逻辑则在 jsonschema-go 仓库的 jsonschema/infer.go 里,核心就是一行 Tag.Lookup 加赋值:

// github.com/google/jsonschema-go, jsonschema/infer.go
if tag, ok := field.Tag.Lookup("jsonschema"); ok {
    if tag == "" {
        return nil, fmt.Errorf("empty jsonschema tag on struct field %s.%s", t, field.Name)
    }
    fs.Description = tag
}
也就是说 jsonschema 标签目前只承担"字段描述"这一件事(写入 Schema.Description),并不支持像某些 Java/Python 校验库那样在标签里塞 min=1,max=100 之类的约束表达式。

1.2 AddTool 内部做了什么

搞清楚 Schema 从哪来之后,再看 AddToolmcp/server.go:561)本身把"强类型 handler"适配成统一接口的过程:

func AddTool[In, Out any](s *Server, t *Tool, h ToolHandlerFor[In, Out]) {
    tt, hh, err := toolForErr(t, h, s.opts.SchemaCache)
    if err != nil {
        panic(fmt.Sprintf("AddTool: tool %q: %v", t.Name, err))
    }
    s.AddTool(tt, hh)
}
其中 ToolHandlerFor[In, Out] 定义在 mcp/tool.go,本质是 func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error) 的类型别名;toolForErr 会把这个"强类型 handler"适配成底层统一的 ToolHandlerfunc(context.Context, *CallToolRequest) (*CallToolResult, error)),中间自动完成入参反序列化、按 Schema 校验、把 Out 序列化进 StructuredContent,以及把 handler 返回的普通 error 包装成 CallToolResult.IsError=true(区别于协议级错误 *jsonrpc.Error,后者会直接透传)。注意 Schema 推导失败(比如 In 不是 struct/map 而是不兼容类型)是在 AddTool 内部直接 panic,不是返回 error——这是写生产代码时容易踩的一个坑:工具注册最好放在 main 启动早期集中做,方便一次性暴露所有 panic。

1.3 每个工具调用是不是真的并发执行

最后是并发这一块——示例代码里的注释写"每次调用都在独立的协程中被分发处理",这到底是不是真的、又是靠什么机制实现的?SDK 自身的 mcp/server.go 并没有显式为每个请求写 go handleRequest(...),真正的并发调度点藏在更底层的 internal/jsonrpc2/conn.go 里。这个内部 JSON-RPC 层默认按队列顺序单线程处理请求(handleAsync 方法里维护一个 handlerQueue),但提供了一个逃生舱:调用 jsonrpc2.Async(ctx) 可以立刻把"排队权"释放给下一个请求,让当前请求的业务逻辑在独立 goroutine 里继续跑。mcp/server.go 在通用的请求分发入口(约 1908-1914 行)里对几乎所有 call 类型的请求都会调用它:

// mcp/server.go
// modelcontextprotocol/go-sdk#26: handle calls asynchronously, and
// notifications synchronously, except for 'initialize' which shouldn't be
// asynchronous to other
if req.IsCall() && req.Method != methodInitialize {
    jsonrpc2.Async(ctx)
}
也就是说,tools/call(以及其他非 initialize 的请求方法)确实会在各自独立的 goroutine 中并发执行——这与本文示例代码里的注释一致——但这是靠 SDK 显式调用 jsonrpc2.Async 主动让出队列实现的,而不是"jsonrpc2 库天生对所有请求并发调度";initialize 被特意排除在外,以保证初始化请求不会和后续请求交错处理。


2. 两种连接方式:本地 stdio 管道 与 跨网络的 Streamable HTTP

上面的最小例子里,我们用 &mcp.StdioTransport{} 直接挂了一个标准输入输出传输。实际生产中 Server 和 Client 之间怎么把 JSON-RPC 消息传来传去,Go SDK 底层将物理连接完全抽象为 Transport 接口,生产中主要采用两种传输网络通道,各自适用的场景差别很大,下面先分别说清楚。

2.1 Stdio 管道传输(本机进程间,标准输入输出)

  • 物理机制:Host 运行时通过 exec 派生(fork)出 MCP Server 子进程。两端通过底层的标准输入(os.Stdin)与标准输出(os.Stdout)进行全双工 JSON-RPC 帧读取与写入。源码里 StdioTransport 本身极薄,就是把 os.Stdin/os.Stdout 包成一个读写闭包再丢给内部的 ioConn
    // mcp/transport.go
    type StdioTransport struct{}
    
    func (*StdioTransport) Connect(context.Context) (Connection, error) {
        return newIOConn(rwc{os.Stdin, nopCloserWriter{os.Stdout}}), nil
    }
    
    真正的帧读写(按换行分隔的 JSON,以及批量消息的拼装/拆分)都在 mcp/transport.goioConn.Read / ioConn.Write(约 581 行、673 行起)里完成;如果自己有非 stdio 的 io.ReadCloser/io.WriteCloser(比如管道、TCP 连接),可以直接用同文件里的 IOTransport{Reader, Writer} 复用同一套帧解析逻辑,不必自己重新实现协议帧切割。
  • ⚠️ 致命崩溃雷区(Stdout Pollution): 在 Stdio 传输通道中,Server 端进程的业务代码绝对禁止向系统标准输出(如 fmt.Println, log.Println)打印任何裸日志。因为这些裸日志会混入标准输出管道,直接污染并砸碎 JSON-RPC 帧的数据流,导致 Client 端解析器抛出 Invalid JSON framing 错误,进而单方面断开管道,引发智能体系统雪崩。
    • 正确解法:所有 Server 内部日志必须重定向并写入标准错误流(os.Stderr)或物理日志文件。

2.2 Streamable HTTP 传输(跨网络部署)

  • 物理机制:适用于跨网络、分布式部署。这是 MCP 规范在 2025-03-26 之后引入的传输方式,取代了早期版本中独立的 HTTP+SSE 传输——不再强制拆分成"长连接 SSE 事件流 + 短连接 POST 请求"两条物理通道,而是统一挂载在单一 HTTP 端点上,按需以 SSE 流式返回或直接同步返回响应。Go SDK 中对应的实现类型是 StreamableServerTransport / StreamableClientTransport(配合 StreamableHTTPHandler),而不存在名为 SSETransport 的独立类型。这部分实现集中在 mcp/streamable.go(近 2800 行,是全仓库最大的单文件)和更薄的入口 mcp/streamable_server.goNewStreamableHTTPHandler 构造出的 StreamableHTTPHandler.ServeHTTP 会先按是否有会话状态分流到 serveStateful / serveStatelessserveStateful 内部再按 HTTP method 派发到 serveStatefulGET(建立/续接 SSE 流)、serveStatefulPOST(提交 JSON-RPC 请求,按需以 SSE 流式回包)、serveStatefulDELETE(客户端主动结束会话)三个方法;每个会话内部由 streamableServerConn 承载,用 streammcp/streamable.go 内的小结构体,带 mu 互斥锁保护的事件缓冲与 eventID 序号)来管理断线重连时的事件重放。
  • 适用场景:由于 stdio 只能跑在单机本地,Streamable HTTP 传输是云原生微服务中跨物理节点集成工具集的不二选型。

3. 握手:连接建立后,谁先说话、什么时候才能真正干活

两端物理连接建立好之后(不管是 stdio 还是 Streamable HTTP),并不能马上开始收发 tools/call——SDK 会强制先走一轮不可逆转的初始化握手阶段

 Client (Host)                                                       Server
   │                                                                   │
   ├────── [1] initialize request (Protocol Version, Client Info) ─────>│
   │                                                                   ├─ 校验版本并匹配 Capabilities
   │<───── [2] initialize response (Server Info, Capabilities) ────────┤
   │                                                                   │
   ├────── [3] notifications/initialized ─────────────────────────────>│ 
   │                                                                   ├─ 初始化状态锁解锁,开启 Tool 接受
   V                                                                   V
  • 状态锁定(协议规范 vs. 当前 SDK 实现):MCP 协议规范要求 Server 在收到 notifications/initialized 之前不应正常处理业务请求。但需要澄清一处容易被过度断言的细节:截至当前版本,Go SDK 里的 ServerSession.checkInitialized/hasInitializedmcp/server.go 约 1504-1528 行)只用于反方向的场景——即 Server 主动向 Client 发起 roots/listsampling/createMessageelicitation/create 这类请求时的前置校验,而并非用于拦截 Client 发来的 tools/call。源码里这段逻辑写得非常直白,实际拦截分支是被注释掉的:
    // mcp/server.go
    func (ss *ServerSession) checkInitialized(method string) error {
        if !ss.hasInitialized() {
            // TODO(rfindley): enable this check.
            // Right now is is flaky, because server tests don't await the
            // initialized notification. Perhaps requests should simply block
            // until they have received the initialized notification
            // ...(被注释掉的报错分支)
        }
        return nil
    }
    
    这个函数被 ServerSession.ListRoots(约 1569 行)、CreateMessage(约 1590、1636 行)、Elicit(约 1655 行)在发起反向请求前调用,但函数体里真正会返回错误的分支目前被整体注释掉了,等于当前版本里这层校验实质上是空转的——不管是正向还是反向请求都不会被这个函数拦截。结论:握手时序本身是协议规定的硬要求,但具体到"提前调用会被拦截、返回什么错误码"这类实现细节,必须以当前 SDK 源码为准,不要凭直觉假设已经严格实现;这也是本文与早期版本相比修正过的一处关键描述,之前误判过锁定方向。

4. 生产级故障演进与运维排查

故障现象 物理根因 生产级破坏后果 治理与诊断手段
Invalid json framing 崩溃 业务代码或者第三方依赖库向 stdout 打印了纯文本日志(如 Trace 信息)。 整个智能体会话断裂,子进程强行退出,Host 侧报错 Stream closed unexpectedly 1. 在初始化阶段强制将 os.Stdout 进行重定向劫持。
2. 严格拦截 Stderr 进行日志输出。
工具执行协程死锁 / 泄露 工具 Handler 执行了慢 SQL 或外部阻塞 HTTP 请求,且没有响应上游 ctx.Done() 的取消信号。 线上并发性能骤降,Go 协程数量缓慢爬升,直至内存被打爆。 1. 在 Handler 内部所有的 I/O 操作处挂载传入的 ctx
2. Host 端在每次发出 tools/call 时,强制注入带有 Timeout 控制的 Context 管道。
initialize 超时挂起 Client 启动 Server 进程后没有按时发送 initialize 请求。 进程残留,服务器上出现大量处于阻塞就绪状态的 MCP Server 僵尸进程。 1. 建立建连握手超时器,Server 端如果在握手期超过 10s 未收到 initialize,则强制自毁。
2. 及时执行垃圾回收。

5. 资深系统架构师面试表达方案

面试提问:在利用 Go 开发 MCP 工具服务时,如何保证通道通信的高并发性能与安全性?开发中有什么必须避免的致命大坑?

回答模版: Go 写 MCP Server,并发这块基本是白捡的优势——每个 tools/call 请求 SDK 都会分派到独立的 goroutine 去处理,只要自己写的 Handler 内部是线程安全的、老老实实往下传 ctx,高并发场景基本不用操心。真正让我们栽过跟头的,反而是这个协议本身对物理传输通道的洁癖:stdio 模式下 Server 进程的标准输出被完全用来传 JSON-RPC 帧,团队里一个同事在排查问题时随手加了句 fmt.Println 打日志,本地测试没事,一接到线上 Host 环境立刻断连,报的是 Invalid JSON framing——查了挺久才想起来是这行调试日志把输出流搞脏了。

从那以后我们定了个死规矩:Server 端任何日志都必须走 os.Stderrstdout 只允许协议帧本身写入,代码审查时会专门盯这一条。另外握手这块也提醒一下,协议规范里说 Server 在收到 initialized 通知前不该处理业务请求,但具体到 Go SDK 现在的实现,这层拦截目前只用在 Server 主动发起请求的反方向场景,并不会去挡 Client 提前发来的 tools/call——这个细节容易被想当然地过度解读,真要验证还是得回源码看当前版本的实际行为,不能凭协议文档去猜实现。