0%

go-zero 源码分析 14:Gateway 与 MCP

我们已经把 go-zero 的核心肌理几乎全部拆解了一遍:从 goctl 的代码生成,到 REST 和 zRPC 的请求处理链路,再到服务发现、弹性保护、可观测性与数据访问层。这些能力构成了一个微服务框架的基础设施——你可以在上面构建出一个完整的微服务系统。

但是,框架的真正活力在于扩展性。当核心能力稳定之后,自然会涌现出在核心框架之上构建更高层抽象的需求。go-zero 的 gateway 包和 mcp 包就是这种思路的两个典型产物:它们不修改 restzrpc 的一行代码,却分别构造出了协议网关AI 工具协议服务两种全新的服务形态。

这两个模块之所以值得放在同一篇文章中讨论,是因为它们共享了完全相同的设计策略:

  • rest.Server 为 HTTP 底座:Gateway 和 MCP 都不自己处理 HTTP 细节,而是利用已经在生产环境中验证过的 REST 引擎来管理启动、路由、中间件和优雅退出。
  • 函数选项模式(Functional Options):通过 WithHeaderProcessorWithMiddlewareWithRequestMetadataExtractor 等 Option 函数注入定制行为,保持核心构造函数的简洁。
  • 职责单一的配置结构体:嵌入 rest.RestConf,再叠加各自领域的特定字段,既不重复造轮子,又能表达领域语义。

我们先从 Gateway 讲起——它是两个模块中更"重"的一个,涉及协议转换(HTTP ↔ gRPC、HTTP ↔ HTTP)的完整实现;然后再看 MCP 如何以更轻盈的方式,将一个 gRPC 服务的某个能力注册为 AI 可调用的工具。

Gateway:为什么需要协议转换网关

HTTP 客户端能直接调用 gRPC 服务吗

在一个典型的微服务架构中,后端服务通常使用 gRPC 相互通信,但前端(浏览器、移动端)和外部的第三方系统只能发 HTTP 请求。这就产生了一个问题:外部 HTTP 请求如何调用内部 gRPC 服务?

gRPC 使用 Protobuf 序列化和 HTTP/2 传输——浏览器原生不支持。传统做法是写一个专门的"翻译层":对每个 gRPC 方法写一个 REST handler,在其中手动序列化参数、调用 gRPC、再把结果反序列化。随着服务数量的增长,这个翻译层会变成维护负担最重的代码——因为它不包含业务逻辑,却要针对每个接口重复编写。

go-zero 的 Gateway 就是为了解决这个问题。它的核心能力是:自动将 HTTP 请求转换为 gRPC 调用,或将 HTTP 请求转发到下游 HTTP 服务,而无需手写任何"翻译"代码。

先看它的顶层结构:

1
2
3
4
5
6
7
8
9
// gateway/server.go
type Server struct {
*rest.Server // 嵌入 REST 引擎,复用整个 HTTP 栈
upstreams []Upstream // 上游服务列表(gRPC 或 HTTP)
conns []zrpc.Client // gRPC 客户端连接池
processHeader func(http.Header) []string // 自定义 header 处理
dialer func(conf zrpc.RpcClientConf) zrpc.Client // 自定义 gRPC 拨号
middlewares []rest.Middleware // Gateway 层中间件
}

Server 通过嵌入 *rest.Server 获得了完整的 HTTP 服务能力——端口监听、路由匹配、中间件链、优雅退出——所有这些都是免费继承的。Gateway 自身只需要关注"上游服务是什么"以及"如何把 HTTP 请求转换并转发"。

它的配置也清晰地反映了这种设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
// gateway/config.go
type GatewayConf struct {
rest.RestConf // 标准 REST 配置(Host、Port、Timeout 等)
Upstreams []Upstream // 上游服务定义
}

type Upstream struct {
Name string `json:",optional"`
Grpc *zrpc.RpcClientConf `json:",optional"` // gRPC 上游
Http *HttpClientConf `json:",optional=!grpc"` // HTTP 上游(与 Grpc 互斥)
ProtoSets []string `json:",optional"` // ProtoSet 文件
Mappings []RouteMapping `json:",optional"` // 路由映射表
}

你会注意到 GrpcHttp 通过 optional=!grpc 标记为互斥——一个上游要么是 gRPC 服务,要么是 HTTP 服务,不能同时是两者。这个约束在 build() 方法中也得到了相应的处理。

服务生命周期:Start 与 Stop 的顺序考量

在深入路由构建逻辑之前,先看一下 Gateway 的生命周期管理,这体现了它在资源管理上的细致考量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (s *Server) Start() {
logx.Must(s.build()) // 构建路由表
s.Server.Start() // 启动 HTTP 服务器
}

func (s *Server) Stop() {
s.Server.Stop() // 先停止 HTTP,不再接受新请求
group := threading.NewRoutineGroup()
for _, conn := range s.conns {
conn := conn
group.Run(func() {
_ = conn.Conn().Close() // 再关闭所有 gRPC 连接
})
}
group.Wait()
}

Start 中,build() 读取上游配置并构建路由表,然后将路由注册到底层 rest.Server,最后启动 HTTP 监听。这个顺序意味着路由构建是一次性的——Gateway 在启动后不再动态变更路由表,所有上游服务在启动时就必须确定。

Stop 中的顺序则更有意味:先关 HTTP,再关 gRPC。源码注释解释了原因:如果反过来先关 gRPC,那么 HTTP 服务器还可能继续接受请求并尝试转发这些请求到已经不存在的 gRPC 连接上。先停入口、再清理上游连接,能保证不会有"请求还在处理中但连接已经断开"的竞态窗口。所有 gRPC 连接通过 threading.NewRoutineGroup 并发关闭,避免逐个关闭的串行延迟。

路由构建:MapReduce 如何编排两个上游体系

Gateway 最核心的代码在 build() 方法中。这里使用了一个 mr.MapReduceVoid 来并行构建路由——这是一个值得关注的工程选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (s *Server) build() error {
if err := s.ensureUpstreamNames(); err != nil {
return err
}
return mr.MapReduceVoid(func(source chan<- Upstream) {
for _, up := range s.upstreams {
source <- up
}
}, func(up Upstream, writer mr.Writer[rest.Route], cancel func(error)) {
if up.Grpc != nil {
s.buildGrpcRoute(up, writer, cancel)
} else if up.Http != nil {
s.buildHttpRoute(up, writer)
}
}, func(pipe <-chan rest.Route, cancel func(error)) {
for route := range pipe {
s.Server.AddRoute(route)
}
})
}

三个回调函数分别对应 MapReduce 的 Generate、Map 和 Reduce 三个角色:

  • Generate:将上游列表逐个发送到 source channel。
  • Map:对每个上游,根据其类型(gRPC 或 HTTP),调用对应的路由构建方法,并将生成的路由写入 writer
  • Reduce:从 pipe channel 接收所有构建好的路由,统一注册到 rest.Server

这里使用 mr.MapReduceVoid 的动机很明确:当有多个上游服务时,每个上游的路由构建(特别是基于 protoset 文件或 gRPC reflection 的解析)可能是耗时的。比如 gRPC 上游的分支需要建立连接、获取服务描述符、遍历所有方法并解析 HTTP 注解——多个上游并行处理能显著缩短启动时间。

gRPC 协议转换:从 HTTP 请求到 Protobuf 调用的全过程

两步信息获取:先拿到描述符,再从描述符中提取路由

对于 gRPC 上游的转换,Gateway 需要知道三件事:

  1. 上游服务提供了哪些 RPC 方法
  2. 每个方法的请求/响应消息类型是什么(用于序列化/反序列化)
  3. 如果有 HTTP 注解(google.api.http),对应的 HTTP 方法和路径是什么

这些信息通过 grpcurl 的描述符源(Descriptor Source)获得。Gateway 支持两种获取方式:

1
2
3
4
5
6
7
8
9
10
11
func createDescriptorSource(cli zrpc.Client, up Upstream) (grpcurl.DescriptorSource, error) {
var source grpcurl.DescriptorSource
var err error
if len(up.ProtoSets) > 0 {
source, err = grpcurl.DescriptorSourceFromProtoSets(up.ProtoSets...)
} else {
client := grpcreflect.NewClientAuto(context.Background(), cli.Conn())
source = grpcurl.DescriptorSourceFromServer(context.Background(), client)
}
return source, nil
}

ProtoSet 模式up.ProtoSets 不为空):使用 protoc --descriptor_set_out 编译好的 proto 描述符文件,包含完整的服务定义和消息类型。这种方式不需要服务端开启 gRPC reflection,启动时直接从文件加载。

Reflection 模式up.ProtoSets 为空):通过 gRPC Server Reflection 协议动态获取服务定义。grpcreflect.NewClientAuto 会自动创建反射客户端,从 gRPC 服务端实时拉取服务描述符。

拿到描述符源之后,下一步是从中提取可路由的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// gateway/internal/descriptorsource.go
func GetMethods(source grpcurl.DescriptorSource) ([]Method, error) {
svcs, _ := source.ListServices()
var methods []Method
for _, svc := range svcs {
d, _ := source.FindSymbol(svc)
switch val := d.(type) {
case *desc.ServiceDescriptor:
for _, method := range val.GetMethods() {
rpcPath := fmt.Sprintf("%s/%s", svc, method.GetName())
// 尝试从方法选项中提取 google.api.http 注解
ext := proto.GetExtension(method.GetMethodOptions(), annotations.E_Http)
switch rule := ext.(type) {
case *annotations.HttpRule:
// 根据注解中的 HTTP 方法类型(GET/POST/PUT/DELETE/PATCH)
// 生成对应的 HttpMethod 和 HttpPath
}
}
}
}
return methods, nil
}

这段代码的核心逻辑是:遍历每个 gRPC 服务的每个方法,尝试从 Proto 的方法选项中提取 google.api.http 注解。这个注解是 gRPC-Gateway 生态的标准,定义了 RPC 方法对应的 HTTP 路由。

如果你的 proto 文件中写入了这样的注解:

1
2
3
4
5
6
7
8
9
10
import "google/api/annotations.proto";

service Greet {
rpc SayHello(HelloRequest) returns (HelloResponse) {
option (google.api.http) = {
post: "/v1/greeter/{name}"
body: "*"
};
}
}

那么 Gateway 解析后会自动生成路由 POST /v1/greeter/:nameGreet/SayHello——注意 {name}adjustHttpPath 替换为 :name,这是因为 go-zero 的 REST 路由使用 :param 作为路径参数格式,而不是 gRPC HTTP 注解的 {param} 格式。

除此之外,Upstream.Mappings 还允许你手动指定路由映射——这对于没有在 proto 中写入 HTTP 注解的场景(或需要覆盖默认映射时)非常有用。Mappings 的每个条目会作为独立的 Route 注册到 REST 引擎:

1
2
3
4
5
6
7
for _, m := range up.Mappings {
writer.Write(rest.Route{
Method: strings.ToUpper(m.Method),
Path: m.Path,
Handler: s.buildGrpcHandler(source, resolver, cli, m.RpcPath),
})
}

一次请求的完整旅途:解析、调用与响应

当 Gateway 接收到一个匹配到 gRPC 上游路由的 HTTP 请求时,buildGrpcHandler 返回的 handler 负责完成整个协议转换过程。我们可以把这个过程分为三个阶段:

第一阶段——请求解析internal.NewRequestParser 负责将 HTTP 请求体转换为 gRPC 消息结构。这里需要综合多个数据源:

1
2
3
4
5
6
7
8
9
10
// gateway/internal/requestparser.go
func NewRequestParser(r *http.Request, resolver jsonpb.AnyResolver) (grpcurl.RequestParser, error) {
vars := pathvar.Vars(r) // 路径变量
params, _ := httpx.GetFormValues(r) // 查询参数
for k, v := range vars {
params[k] = v // 路径变量优先级更高,会覆盖同名的查询参数
}
body, ok := getBody(r) // 请求体
// 根据 body 和 params 的有无,组合出最终数据并构建 JSON 解析器
}

这里有三种组合:纯参数(无 body)、纯 body(无参数)、混合数据(body + 参数/路径变量)。在混合模式下,先反序列化 body 为 map,再用参数/路径变量覆盖同名字段——路径变量拥有最高优先级。这个处理方式意味着:你的 HTTP 请求可以通过 query 参数或路径变量补充/覆盖请求体中的字段。

最终,这些数据通过 jsonpb.Unmarshaler(带 AllowUnknownFields: true)反序列化——允许未知字段的好处是,当 proto 定义升级后,Gateway 不会因为客户端多传了字段就拒绝请求。

第二阶段——发起 RPC 调用。解析完成后,核心调用只有一行:

1
2
grpcurl.InvokeRPC(r.Context(), source, cli.Conn(), rpcPath,
s.prepareMetadata(r.Header), handler, parser.Next)

grpcurl.InvokeRPCgithub.com/fullstorydev/grpcurl 库提供的关键功能:它能根据描述符源动态调用 gRPC 方法——不需要生成任何客户端 stub 代码。参数依次是:请求上下文、服务描述符、gRPC 连接、RPC 全路径(格式 package.Service/Method)、gRPC metadata、响应处理器和请求数据提供者。

第三阶段——响应处理internal.NewEventHandler 实现了 grpcurl.InvocationEventHandler 接口,负责将 gRPC 的响应体(Protobuf 消息)序列化为 JSON 并写入 HTTP 响应。同时,它会将 gRPC metadata header 和 trailer 分别映射为 HTTP 响应头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// gateway/internal/eventhandler.go
func (h *EventHandler) OnReceiveHeaders(md metadata.MD) {
for k, vs := range md {
header := "Grpc-Metadata-" + k // 以 Grpc-Metadata- 前缀写入响应头
w.Header().Add(header, v)
}
}

func (h *EventHandler) OnReceiveResponse(message proto.Message) {
h.marshaler.Marshal(h.writer, message) // Protobuf → JSON
}

func (h *EventHandler) OnReceiveTrailers(status *status.Status, md metadata.MD) {
for k, vs := range md {
header := "Grpc-Trailer-" + k // 以 Grpc-Trailer- 前缀写入响应头
w.Header().Add(header, v)
}
h.Status = status
}

Header 的处理:Metadata 传递与 Trace 传播

将 HTTP header 转换为 gRPC metadata 是 Gateway 的一个重要设计点。prepareMetadata 方法将这一过程分为两步:

1
2
3
4
5
6
7
func (s *Server) prepareMetadata(header http.Header) []string {
vals := internal.ProcessHeaders(header) // 内置处理:Grpc-Metadata- 前缀 + trace headers
if s.processHeader != nil {
vals = append(vals, s.processHeader(header)...) // 用户自定义处理
}
return vals
}

ProcessHeaders 的职责分成两条线索:

线索一——Grpc-Metadata- 前缀:HTTP 请求头中以 Grpc-Metadata- 开头的字段,会被去掉前缀并转换为小写后作为 gRPC metadata 传递。这种机制让 HTTP 客户端可以通过请求头直接影响 gRPC 调用的 metadata 参数。

线索二——OpenTelemetry Trace 传播:trace 相关的请求头(traceparenttracestatebaggage)会被直接转发为 gRPC metadata,确保分布式追踪在穿越 Gateway 后仍然连续。这是关键的设计细节——如果没有这层转发,Gateway 就成了 trace 链路上的"断点",发起方的 trace ID 无法传递到下游 gRPC 服务。

1
2
3
4
5
var traceHeaders = map[string]bool{
"traceparent": true,
"tracestate": true,
"baggage": true,
}

HTTP 代理:简洁的请求转发

问题:gRPC Gateway 解决了纯 HTTP 转发的需求吗

Gateway 的 HTTP 上游模式处理的场景与 gRPC 模式完全不同:上游服务本身就是一个 HTTP 服务,Gateway 的角色退化为一个反向代理(reverse proxy)。如果后端服务已经暴露了 HTTP 接口——比如一个第三方的 REST API 或一个内部的老旧服务——你不需要做协议转换,只需要做路径映射和请求转发。

相比 gRPC 模式经由 grpcurl 的复杂路径,HTTP 模式的 handler 构建要简洁得多:

1
2
3
4
5
6
7
8
9
10
11
func (s *Server) buildHttpHandler(target *HttpClientConf) http.HandlerFunc {
handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(httpx.ContentType, httpx.JsonContentType)
req, err := buildRequestWithNewTarget(r, target)
// ... 处理超时
resp, err := httpc.DoRequest(req)
// ... 复制响应头和状态码
io.Copy(w, resp.Body)
}
return s.buildChainHandler(handler)
}

buildRequestWithNewTarget 是其中的关键。它从原始 HTTP 请求构建一个新的目标指向上游的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func buildRequestWithNewTarget(r *http.Request, target *HttpClientConf) (*http.Request, error) {
u := *r.URL
u.Host = target.Target // 替换目标地址
if len(target.Prefix) > 0 {
u.Path, _ = url.JoinPath(target.Prefix, u.Path) // 拼接路径前缀
}
newReq := &http.Request{
Method: r.Method,
URL: &u,
Header: r.Header.Clone(), // 复制原始请求头
Body: io.NopCloser(r.Body),
}
return newReq.WithContext(r.Context()), nil
}

操作可以归结为两步:替换目标地址,以及可选地添加路径前缀Target 字段支持 host:port 格式的地址,Prefix 用于为上游服务统一添加路径前缀。

一个关键细节是 WithContext:新请求继承原始请求的 context,因此原始请求的 deadline、取消信号和 trace 上下文都会贯穿整个转发过程。这就是为什么 TargetConfTimeout 会在原始请求 deadline 的基础上再创建 context.WithTimeout 的子 context——它并不会违反原始请求的取消约定。

中间件支持

Gateway 通过 WithMiddleware Option 提供了中间件注入能力:

1
2
3
4
5
6
func (s *Server) buildChainHandler(handler http.HandlerFunc) http.HandlerFunc {
for i := len(s.middlewares) - 1; i >= 0; i-- {
handler = s.middlewares[i](handler)
}
return handler
}

倒序遍历中间件列表是为了保证中间件的洋葱模型执行顺序(先注册的先执行,后注册的后执行)——这和我们熟悉的 rest/chain.Chain 的实现方式一致。每个中间件包裹内部 handler,最终形成一层层嵌套。

MCP:当框架需要"对话" AI

问题:go-zero 的 REST/zRPC 底座如何承载 AI 工具协议

如果说 Gateway 是 go-zero 在传统微服务领域的一次"横向扩展"——让 HTTP 请求可以访问 gRPC 服务——那么 MCP 包则是一次更有想象力的"纵向突破":它让 go-zero 的服务可以暴露为 AI 模型可调用的工具

Model Context Protocol(MCP)是一个由 Anthropic 提出的开放协议,用于标准化 AI 模型与外部工具/数据源之间的交互。MCP 定义了三种传输方式:stdio(进程通信)、SSE(Server-Sent Events,基于 2024-11-05 规范)和 Streamable HTTP(基于 2025-03-26 规范)。go-zero 的 MCP 实现选择了用 HTTP 服务承载后两种传输方式——这正是它能复用 rest.Server 的原因。

1
2
3
4
5
6
7
// mcp/server.go
type mcpServerImpl struct {
conf McpConf // MCP 配置
httpServer *rest.Server // HTTP 基础设施(复用 REST 引擎)
mcpServer *sdkmcp.Server // 官方 MCP SDK 的 Server
options serverOptions // 可定制选项
}

你可以把它看作一个"双层结构":外层是 go-zero 的 rest.Server,负责 HTTP 生命周期、路由、超时和 CORS;内层是官方的 sdkmcp.Server,负责 MCP 协议本身——Tool/Prompt/Resource 的注册、JSON-RPC 消息的路由和 Schema 的自动生成。

传输层选择:SSE 还是 Streamable HTTP

MCP 服务通过 McpConf.Mcp.UseStreamable 字段选择传输方式。这一选择决定了路由注册的方式和底层使用的 MCP SDK handler 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (s *mcpServerImpl) setupSSETransport() {
handler := sdkmcp.NewSSEHandler(func(r *http.Request) *sdkmcp.Server {
return s.mcpServer
}, nil)
s.registerRoutes(s.wrapRequestMetadata(handler), s.conf.Mcp.SseEndpoint)
}

func (s *mcpServerImpl) setupStreamableTransport() {
handler := sdkmcp.NewStreamableHTTPHandler(func(r *http.Request) *sdkmcp.Server {
return s.mcpServer
}, nil)
s.registerRoutes(s.wrapRequestMetadata(handler), s.conf.Mcp.MessageEndpoint)
}

两种传输方式共用同一个路由注册函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
func (s *mcpServerImpl) registerRoutes(handler http.Handler, endpoint string) {
s.httpServer.AddRoute(rest.Route{
Method: http.MethodGet,
Path: endpoint,
Handler: handler.ServeHTTP,
}, rest.WithSSE(), rest.WithTimeout(s.conf.Mcp.SseTimeout))

s.httpServer.AddRoute(rest.Route{
Method: http.MethodPost,
Path: endpoint,
Handler: handler.ServeHTTP,
}, rest.WithTimeout(s.conf.Mcp.MessageTimeout))
}

无论选择哪种传输方式,同一个路径上都会同时注册 GET 和 POST 两个路由。这是 MCP 协议的要求:GET 用于建立 SSE 长连接(客户端通过 GET 订阅服务器推送的事件流),POST 用于发送 JSON-RPC 请求(客户端通过 POST 发送 initialize、tools/list、tools/call 等 MCP 协议消息)。

两种方式的区别在于底层的消息传递模型:

  • SSE 模式UseStreamable: false):GET 端点建立持久连接(默认 24 小时超时),服务器通过这个连接向客户端推送事件。POST 端点用于客户端发送请求。这是 2024-11-05 规范的实现。
  • Streamable HTTP 模式UseStreamable: true):将 SSE 和消息请求融合到一个更统一的双向模型中。这是 2025-03-26 规范的新传输方式,在某些场景下支持更高效的服务端推送。

请求元数据的传递桥梁

在实践中,MCP 工具往往需要感知请求上下文——租户 ID、用户标识、环境标记。这些信息不在 MCP 协议标准中,必须由 go-zero 的 HTTP 层提取后传递给 MCP handler。

go-zero 通过 RequestMetadataExtractor 解决了这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// mcp/options.go
type RequestMetadataExtractor func(*http.Request) RequestMetadata

// mcp/request_metadata.go
type RequestMetadata struct {
Headers map[string][]string
Query map[string][]string
Path map[string]string
}

func DefaultRequestMetadataExtractor(r *http.Request) RequestMetadata {
metadata := RequestMetadata{
Headers: make(map[string][]string),
Query: make(map[string][]string),
Path: clonePathVars(pathvar.Vars(r)),
}
// 提取 headers 和 query 参数
return metadata
}

wrapRequestMetadata 方法在每个 HTTP 请求处理前提取元数据并注入 context.Context

1
2
3
4
5
6
7
8
9
10
11
func (s *mcpServerImpl) wrapRequestMetadata(next http.Handler) http.Handler {
extractor := s.options.requestMetadataExtractor
if extractor == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metadata := normalizeRequestMetadata(extractor(r))
ctx := context.WithValue(r.Context(), requestMetadataCtxKey{}, metadata)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

在 MCP 工具 handler 中,可以通过以下几个辅助函数读取这些元数据:

  • HeaderFromContext(ctx, "X-Tenant-Id") — 获取 HTTP 请求头
  • QueryFromContext(ctx, "trace") — 获取 URL 查询参数
  • PathFromContext(ctx, "scope") — 获取路径变量(如 /sse/:scope 中的 :scope
  • RequestMetadataFromContext(ctx) — 一次性获取所有元数据

这个设计与 go-zero REST handler 中的 httpx.Parse 参数解析是同样的理念:HTTP 层的信息被整理、规范化后,通过 context 传递给业务逻辑,业务 handler 不需要直接依赖 *http.Request,也不需要了解 HTTP 的传输细节。

类型安全的工具注册

注册 MCP Tool 的方式体现了类型安全与便利性的平衡。通过 Go 泛型,工具 handler 可以在编译时确定参数类型,而 JSON Schema 由 SDK 根据 struct tag 自动生成:

1
2
3
4
5
6
7
8
9
// mcp/types.go
func AddTool[In, Out any](server McpServer, tool *Tool,
handler func(context.Context, *CallToolRequest, In) (*CallToolResult, Out, error)) {
if impl, ok := server.(*mcpServerImpl); ok {
sdkmcp.AddTool(impl.mcpServer, tool, handler)
} else {
logx.Error("AddTool: server must be of type *mcpServerImpl to use this helper")
}
}

类型参数 In 是工具的输入参数类型,Out 是结构化输出的类型。SDK 会根据 In 类型的 struct tag 自动生成 JSON Schema——例如 json:"name"jsonschema:"description=Name of the person to greet"——客户端可以据此理解工具的输入契约。Out 类型用于生成结构化输出 Schema,让 AI 模型知道工具返回的数据结构,从而做出更精确的后续行为决策。

AddTool 的类型断言 server.(*mcpServerImpl) 是一个防护中间层:如果传入的不是 mcpServerImpl(比如你自己实现了一个 McpServer 接口),它会记录错误而不是 panic。这种做法让 mcp.AddTool 对于框架的扩展性保持友好。

两个模块的设计共性

共用的"底座 + 插件"架构

Gateway 和 MCP 虽然解决的业务问题完全不同——一个是协议转换,一个是 AI 协议服务——但它们在架构上使用了同一套模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
              ┌─────────────────────────────┐
│ rest.Server │
│ (HTTP 引擎:路由、中间件、 │
│ 超时、优雅退出、CORS) │
└──────────┬──────────────────┘
│ 嵌入/引用
┌─────────────────┼─────────────────┐
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ Gateway │ │ MCP │
│ .Server │ │ .Server │
├─────────────┤ ├─────────────┤
│ • zrpc 连接池│ │ • SDK Server│
│ • grpcurl │ │ • Tool 注册 │
│ • 请求转换 │ │ • 元数据提取 │
│ • 响应映射 │ │ • 传输选择 │
└─────────────┘ └─────────────┘

这个架构的好处是关注点分离。HTTP 基础设施(监听端口、TLS、超时、优雅关闭、CORS)是在生产环境中经过充分验证的 rest.Server 负责的——不需要在 Gateway 或 MCP 中重复实现。而每个模块只需要关注自己的领域逻辑:Gateway 关注协议转换,MCP 关注协议适配。

Options 模式:相同的扩展哲学

两个模块都使用 functional options 模式来提供扩展点:

1
2
3
4
5
6
7
// Gateway
WithHeaderProcessor(func(http.Header) []string)
WithMiddleware(...rest.Middleware)
WithDialer(func(zrpc.RpcClientConf) zrpc.Client)

// MCP
WithRequestMetadataExtractor(RequestMetadataExtractor)

这个模式的核心优势是:构造函数保持简洁(只接受必需的配置参数),所有可选的定制行为通过 Option 函数注入。当将来需要增加新的扩展点时,只需要添加新的 Option 函数,不影响已有的调用代码。

配置设计:嵌入与叠加

两者的配置都采用"嵌入基础配置 + 叠加领域配置"的模式:

1
2
3
4
5
6
7
8
9
type GatewayConf struct {
rest.RestConf // 基础:Host、Port、Timeout、CORS 等
Upstreams []Upstream // 扩展:上游服务定义
}

type McpConf struct {
rest.RestConf // 基础:Host、Port、Timeout、CORS 等
Mcp struct { ... } // 扩展:MCP 协议相关配置
}

这种配置设计让 go-zero 的用户在使用这两个模块时有着一致的体验:先用标准的 REST 配置定义服务基本属性,再添加各自领域的配置。对于那些已经熟悉了 REST 服务配置的人来说,转用 Gateway 或 MCP 几乎零学习成本。

示例串联:让一条请求从 Gateway 到 MCP 工具调用

现在让我们用一个完整的示例来串联 Gateway 和 MCP 的能力。假设你有一个 gRPC 订单服务 OrderService,我们先用 Gateway 把它暴露为 HTTP 接口,再注册一个 MCP Tool 让 AI 能查询订单状态。

Gateway 配置与启动

首先编写 Gateway 配置,将一个 gRPC 服务的 GetOrder 方法暴露为 HTTP 路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Name: order-gateway
Host: localhost
Port: 8888
Upstreams:
- Name: order-service
Grpc:
Endpoints:
- localhost:9090
ProtoSets:
- order.pb
Mappings:
- Method: get
Path: /api/orders/:orderId
RpcPath: order.OrderService/GetOrder

Gateway 会从 order.pb 中加载服务描述符,解析出 GetOrder 方法的请求/响应消息类型,然后将 GET /api/orders/:orderId 绑定到该 RPC 方法。当客户端发来 HTTP 请求时,请求解析器将路径变量 :orderId 注入到解析后的数据中,经由 grpcurl.InvokeRPC 调用 gRPC 方法,再将 Protobuf 响应序列化为 JSON 返回。

MCP 服务配置与 Tool 注册

接下来,我们把同一个 gRPC 服务的订单查询能力注册为 MCP Tool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type QueryOrderArgs struct {
OrderId string `json:"order_id" jsonschema:"description=The ID of the order to look up"`
}

var orderCli order.NewOrderServiceClient(client.Conn())

server := mcp.NewMcpServer(c)
tool := &mcp.Tool{
Name: "query_order",
Description: "Query an order by its ID, returns order details including status and amount",
}

mcp.AddTool(server, tool, func(ctx context.Context, req *mcp.CallToolRequest,
args QueryOrderArgs) (*mcp.CallToolResult, any, error) {

tenantHeader, _ := mcp.HeaderFromContext(ctx, "X-Tenant-Id")
resp, err := orderCli.GetOrder(ctx, &order.GetOrderRequest{
OrderId: args.OrderId,
TenantId: tenantHeader,
})

if err != nil {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}},
}, nil, nil
}

return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: fmt.Sprintf("Order %s: status=%s, amount=%.2f",
args.OrderId, resp.Status, resp.Amount)},
},
}, resp, nil
})

server.Start()

这里的 handler 通过 mcp.HeaderFromContext 从 HTTP 请求头中获取租户 ID——这个信息在 MCP 标准协议之外,通过 go-zero 的 metadata bridge 传递给 handler。注意 AddToolOut 类型参数是 protobuf 的 GetOrderResponse:SDK 会自动为它生成 JSON Schema,AI 模型就能理解这个工具返回的结构化数据,进而做出更精准的决策。

运行时刻的数据流

完整的数据流如下:

1
2
3
4
5
6
7
8
9
10
11
12
AI Client (MCP SDK)
│ POST /message (JSON-RPC tools/call)

MCP Server (:8080)
│ sdkmcp.SSEHandler → route dispatch
│ RequestMetadataExtractor → ctx
│ Handler: orderCli.GetOrder(...)

gRPC OrderService (:9090)
│ GetOrder → SQL / Cache

Response → MCP Server → JSON → AI Client

从 Gateway 的协议转换到 MCP 的 AI 工具暴露,底层的 rest.Serverzrpc.Client、配置加载和优雅退出机制都是相同的。上层形态不同,下层基础一致——这就是在成熟框架之上构建扩展模块的威力。

小结

Gateway 和 MCP 是 go-zero 核心框架能力之上的两个扩展形态。它们验证了一个基本判断:当 restzrpc 的抽象足够稳定、配置体系和生命周期管理足够成熟之后,在其上构建新的服务形态是水到渠成的事。

Gateway 处理的是**“协议边界”**的问题——将 gRPC 服务暴露为 HTTP 接口,为此它实现了一套完整的描述符解析、请求转换和响应映射机制。这套机制的核心依赖是 grpcurlInvokeRPC——它使得 Gateway 可以在不生成任何 gRPC 客户端 stub 代码的情况下,动态调用任意的 gRPC 服务。

MCP 处理的是**“工具边界”**的问题——将 go-zero 的服务能力暴露为 AI 可调用的工具。它的核心依赖是官方 MCP SDK,但通过 rest.Server 的集成和 RequestMetadata bridge,它让 MCP 工具能自然地融入 go-zero 的 HTTP 基础设施中。

从这两个模块中,我们可以提炼出 go-zero 扩展体系的核心方法:

  • 嵌入复用:通过嵌入 rest.RestConf*rest.Server 继承 HTTP 能力
  • Option 注入:通过 functional options 提供可组合的扩展点
  • 配置分层:基础配置 + 领域配置的叠加模式
  • Adapter 模式:Gateway 和 MCP 本质上是不同协议的适配器,它们将外部协议映射到 go-zero 内部的 rest.Route / zrpc.Client 体系

下一篇(最后一篇)我将全盘复盘:从测试策略到性能分析工具,从框架扩展点到设计哲学,为整个十五篇系列做一个系统性的收束。