Go 基础体系 · 第 109/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go MCP 服务与客户端:Tools、Resources、Prompts 和传输安全

本文以 Go 1.26.4、MCP 2025-06-18 稳定协议和官方 github.com/modelcontextprotocol/go-sdk v1.2.x 稳定版本线为基线。生产必须固定模块与 protocol version,并对 initialize、能力和传输做契约测试;SDK 构造细节以锁定版本编译结果为准。

MCP 让 host 通过 client 连接 server,统一发现 Tools、Resources 和 Prompts。它解决能力描述与调用互操作,不自动提供认证、对象授权、沙箱、幂等、确认或可信输出。Server 应是应用服务的受控适配层,不能让模型绕过业务层直连数据库。

1. Host、Client、Server 与信任边界

Host 是用户直接使用的 AI 应用,管理模型、用户同意、多个 client 和上下文;每个 client 通常维护到一个 server 的有状态连接;server 暴露有限能力。远程 server、它的描述和返回内容都应视为外部输入。

User -> Host (model, consent, policy)
           |-- MCP Client A -> local stdio Server
           |-- MCP Client B -> remote HTTP Server
           `-- application Tool Registry / audit

Host 不能把一个 server 的资源自动转发给另一个,也不能把全部环境变量和会话历史交给子进程。最终 Actor 必须来自可信认证链路,Host 与 Server 各自授权。

2. JSON-RPC 2.0 消息和关联规则

MCP 消息基于 JSON-RPC 2.0。Request 有唯一 idmethod 和可选 params,Response 使用相同 id 返回 resulterror;Notification 没有 id,不得等待响应。ID 在当前会话内必须可关联,不能复用仍在途的值。

{"jsonrpc":"2.0","id":7,"method":"tools/list","params":{"cursor":"next-1"}}
{"jsonrpc":"2.0","id":7,"result":{"tools":[],"nextCursor":null}}
{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}

JSON-RPC error 表示协议或方法级失败;工具业务错误通常通过 result 的 isError 和有界内容表达。客户端不能把失败折叠成空列表,消息与嵌套深度均须限额。

3. 初始化是严格的生命周期握手

新连接先由 client 发送 initialize,携带支持的协议版本、client capabilities 和实现信息。Server 选择兼容版本并返回自己的 capabilities;client 验证后发送 notifications/initialized。握手完成前不能并发调用 tools/listresources/read

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"roots":{"listChanged":true},"sampling":{}},
  "clientInfo":{"name":"wrblog-host","version":"2.4.0"}
}}
{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{
    "tools":{"listChanged":true},
    "resources":{"subscribe":true,"listChanged":true},
    "prompts":{"listChanged":false}
  },
  "serverInfo":{"name":"article-mcp","version":"1.8.2"},
  "instructions":"仅提供已授权文章资料"
}}

若版本不兼容,立即关闭而不是猜测降级。握手设置短 deadline,验证 server name/version 长度但不把它当身份凭证。连接状态可建模为 new -> initializing -> ready -> closing -> closed,每个 API 入口检查状态。断线重连是全新会话,重新 initialize、重新取能力与列表。

4. 能力协商不是静态功能猜测

Capabilities 表示一端明确支持的可选协议面。Server 未声明 resources,client 就不调用资源方法;声明 listChanged 后 client 才依赖变化通知;client 未声明 roots 或 sampling,server 不能发对应请求。能力对象存在与某个子字段为 true 也要按 schema 区分。

协商结果按连接保存,不跨 server 或重连复用。工具列表还要结合用户、租户和 feature flag;收到 list_changed 只使缓存失效并安排有界刷新。

协议升级先让 client/server 支持新旧版本,再灰度切换声明;契约测试至少覆盖版本拒绝、缺失能力、错误类型、未知通知和列表分页。不要依据 SDK 方法是否存在推断对端能力。

5. 用官方 Go SDK 建立服务边界

官方 SDK 处理类型、关联和传输,但 handler 仍应调用已有 application service。SDK request 中的参数是未信任输入,身份从连接认证上下文取得。

type SearchInput struct {
	Query string `json:"query" jsonschema:"要搜索的关键词"`
	Limit int    `json:"limit,omitempty" jsonschema:"返回条数,1 到 20"`
}

func searchArticles(
	ctx context.Context,
	request *mcp.CallToolRequest,
	input SearchInput,
) (*mcp.CallToolResult, any, error) {
	actor, err := actorFromContext(ctx)
	if err != nil {
		return nil, nil, err
	}
	items, err := articleService.Search(ctx, actor, input.Query, input.Limit)
	if err != nil {
		return nil, nil, fmt.Errorf("search articles: %w", err)
	}
	return boundedSearchResult(items), items, nil
}

Server 和依赖在启动时构造并复用;重名或 schema 无效应启动失败。领域层不接收 MCP 类型,适配器负责 DTO 与错误映射。

6. Tools 是候选动作,不是权限

Tool 名称稳定、唯一、短且带领域语义,例如 articles.search,不要暴露 run_sqlrun_shell 或任意 URL。Description 告诉模型何时使用,但不是安全策略。Input schema 设置 required、additionalProperties:false、枚举、长度和数值边界;output schema 若协议/SDK支持也要定义。

{
  "name":"articles.search",
  "description":"在当前用户有权查看的文章中搜索,不执行写操作",
  "inputSchema":{
    "type":"object",
    "additionalProperties":false,
    "required":["query"],
    "properties":{
      "query":{"type":"string","minLength":1,"maxLength":200},
      "limit":{"type":"integer","minimum":1,"maximum":20}
    }
  }
}

服务端仍需严格解码、规范化、对象授权和查询参数绑定。返回 content 供模型阅读,structuredContent 供机器消费时,两者语义应一致且均有限额。工具结果中的网页或文章可能包含提示注入,只是数据;不能借结果修改 Host 的工具白名单。

7. 写工具需要双重授权、确认和幂等

发布、删除、付款和发信等副作用先由 Host 展示计划并收集用户确认,Server 执行时再验证 Actor、对象权限、资源版本和确认凭据。不能因为 Host 已过滤工具就省略 Server 授权,也不能相信 arguments 内的 tenant_id

{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{
  "name":"articles.publish",
  "arguments":{
    "article_id":"art_83K2",
    "expected_version":17,
    "idempotency_key":"run_7-call_12-v1",
    "confirmation_token":"server-issued-single-use-token"
  }
}}

Token 绑定 user、tenant、tool、规范化参数 hash、过期时间和 nonce,使用后失效。幂等表以 key 唯一约束保存参数 hash 与结果;重复相同请求返回原结果,不同参数报冲突。外部调用超时属于未知结果,先查询而不是换 key 重做。审计记录调用者、授权、确认 ID、参数摘要、结果码和资源版本,不记录 token 值。

8. Resources 表示可寻址上下文

Resource 使用 URI 标识内容,适合文章、schema、日志片段等读取,而不是执行动作。URI scheme 与路径由 server 定义并文档化,例如 wrblog://articles/{id};不能把 URI 直接当文件路径或任意 HTTP URL。读取时重新授权,限制正文大小、MIME、编码和重定向。

{"jsonrpc":"2.0","id":21,"method":"resources/read","params":{
  "uri":"wrblog://articles/art_83K2?version=17"
}}

列表分页使用 opaque cursor,并限制最大页数和总项目。Resource template 参数仍要验证。订阅只是变化提示;断线后重新读取当前版本。

Resource metadata 包含稳定名称、MIME、大小或版本,但不泄漏不可访问对象。对不存在与无权限可以使用相同外部错误,防 ID 枚举。二进制 blob 使用协议支持的编码并限制解码后大小。

9. Prompts 是模板,不提升权限

Prompt 是 server 发布的可复用消息模板,Host 可以让用户选择并提供参数。模板内容仍需要审查、版本化和大小限制;它不能隐式获得额外工具或资源,也不能把参数当可信系统指令。

{"jsonrpc":"2.0","id":31,"method":"prompts/get","params":{
  "name":"review-go-article",
  "arguments":{"article_id":"art_83K2","focus":"cancellation"}
}}

Server 返回角色化消息后,Host 决定如何放入模型上下文。参数 schema 使用枚举和长度,文章内容通过已授权 Resource 加载,避免在 prompt 参数里传整篇秘密文档。Prompt list_changed 同样只触发缓存刷新。变更模板要保留版本/hash,并用固定评测集验证工具倾向、引用和 token。

10. Server 向 Client 发起的能力

某些 MCP 能力方向相反:server 可请求 client sampling(让 Host 调模型)、elicitation(向用户收集信息)或 roots(查询允许的文件根),前提是 client 在 initialize 明确声明。Server 不能把 sampling 当免费无限模型代理,也不能用 elicitation 绕过确认 UI。

Host 对 sampling 设置模型 allowlist、system 规则、token/费用、内容政策和并发上限;server 提供的消息全部视为不可信。结果不自动触发工具。Elicitation 只收集 schema 允许的低风险字段,密码、API key 和支付凭证应走专用安全界面。Roots 是允许范围提示,不等于文件系统权限;本地 server 仍运行在 OS 沙箱中。

嵌套调用要防循环,Host 跟踪调用树、最大深度、总步骤和费用。

11. stdio 传输的进程与帧边界

stdio 适合 Host 启动本地 server 子进程。协议 stdout 必须只写 MCP 消息,日志写 stderr;任意启动 banner 都会破坏帧。Host 使用固定可执行路径和参数,不接受模型提供 shell 字符串,不经 sh -c

cmd := exec.CommandContext(ctx, "/opt/wrblog/bin/article-mcp", "--stdio")
cmd.Env = []string{
	"PATH=/usr/bin:/bin",
	"APP_ENV=production",
}
cmd.Dir = "/var/empty/article-mcp"
cmd.Stderr = limitedAuditWriter

使用 SDK transport 连接管道并等待退出。清理云凭据、SSH agent 和代理,以无特权用户、只读文件系统和网络白名单运行。父 context 取消时先协议关闭,再限时终止并 Wait;stderr 限速限量。

12. Streamable HTTP 与会话管理

远程 MCP 使用规范定义的 Streamable HTTP 行为,客户端通过受控 endpoint 发送消息,服务端可能返回普通 JSON 或流式响应。不要把旧 HTTP+SSE 教程与当前协议混用;Content-Type、状态码、会话 header、恢复语义和 DELETE/关闭行为以协商版本为准。

mcp:
  endpoint: https://mcp.example.com/api
  protocol_version: "2025-06-18"
  connect_timeout: 5s
  request_timeout: 30s
  max_response_bytes: 4194304
  max_inflight: 16
  allowed_redirects: 0

HTTP Transport 长期复用并限制各阶段。Endpoint 来自管理员 allowlist,默认不重定向。Session ID 视为 opaque secret,不写日志、不跨 principal 复用;负载均衡须支持其会话语义。

13. 远程认证、Origin 与授权上下文

远程 server 必须 TLS,使用协议建议的 OAuth/资源元数据流程或部署批准的工作负载身份,不能把长期 bearer token 放 URL。Client 校验 issuer、audience、scope、过期和 server identity;凭据按 server 分离。401 只触发受控刷新,不能无限重试。

浏览器可达端点验证 Origin,服务端也验证 Host/Forwarded 配置以抵御 DNS rebinding;CORS 不是认证。反向代理限制 body、header、连接、速率和空闲时间。最终用户身份可通过受信 token delegation 传递,但 server 必须验证签名与 audience,不能采纳普通 MCP 参数中的 user ID。

每次 tools/read/get 都按最新权限授权。列表最好只返回可见能力,调用仍再次校验,因为权限可能撤销。服务账户模式如果只能代表整个 Host,Host 必须隔离用户且 Server 只能提供低风险、同权限能力,不能假装具备细粒度最终用户授权。

14. Context、取消和进度通知

每个 request ID 对应独立 context。调用方取消、deadline、连接关闭和 server shutdown 都传播到底层数据库/HTTP。协议的取消 notification 是协作信号,不保证已发生副作用回滚;Server 收到后停止可停止的工作,并返回或结束对应请求。

进度通知使用与请求关联的 token,值应单调、总量语义稳定且频率有限。不能每处理一个字节发送通知造成放大。Client 接收通知时验证 token 属于仍在途调用,迟到通知丢弃并计数。

func waitContext(ctx context.Context, duration time.Duration) error {
	timer := time.NewTimer(duration)
	defer timer.Stop()
	select {
	case <-timer.C:
		return nil
	case <-ctx.Done():
		return context.Cause(ctx)
	}
}

取消后的错误分类优先检查 context.Cause。只读调用可安全停止;写工具依赖幂等记录判断 committed/unknown。Server handler 不启动无人等待的 goroutine,SDK session 关闭后等待全部 owned workers 或将持久任务交还队列。

15. 列表变化、订阅与断线恢复

Tool/resource/prompt 缓存保存 connection generation、能力、etag/hash 与刷新时间。收到变化通知只把对应缓存标脏;多个通知合并一次刷新,刷新失败保留旧快照但标记 stale,并根据风险决定是否继续调用。删除的写工具应立即不可选。

网络断开后,所有未完成 request 分类为未返回;只读请求可在重新 initialize 后按预算重试,写请求先按幂等键查询。新连接不能沿用旧 request ID 的等待表、session ID、subscriptions 或能力。订阅逐项重新建立,再读取当前 Resource 版本弥补通知缺口。

重连使用指数退避和全抖动,受总 deadline 与最大 attempt 限制。认证/版本不兼容不重连风暴。Host 对每个 server 设置隔离舱,单个 MCP 故障不能耗尽所有 goroutine 或阻塞其他工具。

16. 错误、重试与失败恢复

区分 transport、JSON-RPC、protocol、authorization、tool business、cancel/deadline 和 malformed content。对模型只提供稳定最小 code;对日志保留 wrapped cause 和 server/request ID。远端返回的 error message 不直接展示给用户或作为日志格式串。

仅在请求未产生用户可见输出、操作可重放、错误瞬时且剩余预算足够时重试。tools/list 可重试,带服务端幂等的只读 tool 通常可重试;未知写结果不可直接重放。客户端、SDK、代理和 server 只能约定一层承担重试,避免次数相乘。

Server 异步长任务写持久队列并返回任务 ID;worker 至少一次交付,handler 幂等。实例崩溃后由 lease 接管。MCP 请求 context 结束不等于持久任务自动取消,接口必须明确返回的是同步完成还是已接受任务,以及后续查询/取消方法。

17. 测试协议、权限和异常帧

单元测试用 fake application service 验证 schema 边界、Actor、授权和错误映射。协议测试启动真实 SDK client/server,覆盖 initialize 顺序、版本拒绝、缺失 capability、分页、list_changed、取消、超大消息、未知 method、重复 ID 和断线重连。传输测试把 JSON 拆成不同读写片段,不能假设一次 Read 等于一条消息。

func TestToolRejectsCrossTenant(t *testing.T) {
	ctx := withActor(context.Background(), Actor{TenantID: "tenant-a", UserID: "u1"})
	_, _, err := searchArticles(ctx, nil, SearchInput{Query: "tenant-b secret", Limit: 5})
	if !errors.Is(err, ErrForbidden) {
		t.Fatalf("expected forbidden, got %v", err)
	}
}

攻击用例包括伪造 tenant 参数、工具结果提示注入、Unicode 混淆名称、超大 schema、恶意 URI、SSRF、Origin 缺失、token audience 错、确认重放和 stdout 日志污染。CI 执行 gofmtgo test ./...go test -race ./...go vet ./...;fuzz JSON/URI/parser,确保不 panic、不无限分配。

18. 观测、成本与诊断

Trace 关联 connection generation、JSON-RPC request、tool/resource/prompt 与下游调用。记录 transport、protocol version、server 实现版本、method、工具名、结果类别和耗时;不记录参数、正文、token 或 session ID。tool 名来自批准注册表可作低基数标签,URI 和用户 ID 不可以。

指标包括连接/初始化成功率、在途、消息字节、列表刷新、取消、重连、每工具授权拒绝/错误/延迟、采样 token 与费用。Server 变慢时分解连接等待、Host 排队、MCP transport、handler 和下游;初始化失败先核对版本、capability schema、Content-Type、代理与身份。

成本不仅是模型 sampling,含本地子进程、远程连接、工具 API 和 Resource 内容进入模型的 token。Host 给每个 server 和用户设并发、调用次数、结果字节、sampling token/费用上限。高频 list_changed、异常大 tool list 和重复采样应触发熔断与告警。

19. 部署、优雅关闭与上线清单

Server 镜像固定 Go 1.26.4、官方 SDK、协议版本、tool schema hash 与应用版本;以非 root、只读根文件系统、最小网络和数据库权限运行。远程实例的 readiness 只在能接受新 session 时成功,liveness 不依赖短暂下游故障。水平扩展前明确 session 是否粘滞、共享或可重建。

关闭先摘 readiness、停止新 initialize/request,给在途只读操作有限时间;已确认写操作到达明确事务边界,发送可发送的最终响应,关闭 session、等待 goroutine 和审计 flush,再关连接池。stdio Host 关闭 stdin 并等待子进程,超时后才终止。

上线前确认:握手顺序与版本严格;所有可选调用先看 capability;工具列表最小化;参数严格且结果有界;Resource URI 不越界;Prompt 不提升权限;sampling/elicitation/roots 有预算和用户策略;远程 TLS/OAuth/Origin 正确;stdio 环境已清理;每次调用按最终用户授权;写操作确认且幂等;取消、未知结果和重连有明确恢复;协议与攻击回归通过。这样 MCP 才是可互操作又可治理的能力接口,而不是把新的高权限入口交给模型。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。