golang mcp 服务端指南

08-错误返回与重试

读完这篇你能:分清协议错误和业务错误,知道哪种该返回什么,以及客户端怎么接。

前置:工具的参数。

为什么 MCP 的错误要想一下

传统 API 的错误信息是写给开发者看的,开发者看日志、改代码。

MCP 的错误信息是写给 LLM 看的。 模型会实时读它,然后决定下一步:换个参数重试、告诉用户、还是放弃。

所以错误信息写得含糊,模型就会做出蠢决定——比如对着一个「参数非法」的错误反复重试。

失败分两类

MCP 协议里的失败分成两类,选错类型客户端会误判:

  协议错误 工具业务错误
含义 请求本身不合法,或服务端处理不了 请求合法、工具正常执行了,但结果是失败
例子 工具不存在、参数不符合 schema、服务端内部故障 游标过期、下游超时、业务规则不满足
JSON-RPC 层面 错误响应 成功响应
客户端表现 CallTool() 返回非 nil 的 error,result 不可信 结果里 isError: true,content 中带错误描述
模型能看到吗 一般看不到(连接层失败) 能读到,并据此决定怎么补救

对应到代码就是两种返回方式:

// 普通 error → 业务错误:SDK 自动把 CallToolResult.IsError 置为 true
return nil, out, fmt.Errorf("查询日志失败: %w", err)

// *jsonrpc.Error → 协议错误:客户端 CallTool 返回非 nil error
return nil, out, &jsonrpc.Error{
	Code:    jsonrpc.CodeInvalidParams,
	Message: "start_time 不能晚于 end_time",
}

怎么选:重试有没有意义

判断标准只有一条:客户端不修正输入的话,重试还有没有意义?

  • 有意义(上游超时、下游抖动)→ 返回普通 error,把情况说清楚,让模型自己重试或换参数。
  • 没意义(参数组合本身非法,怎么重试都一样)→ 返回 *jsonrpc.Error。

典型的需要用协议错误的场景是跨字段约束——schema 表达不了字段之间的关系:

import "github.com/modelcontextprotocol/go-sdk/jsonrpc"

func queryLogsTool(_ context.Context, _ *mcp.CallToolRequest, in queryLogsInput) (*mcp.CallToolResult, queryLogsOutput, error) {
	if in.StartTime.After(in.EndTime) {
		return nil, queryLogsOutput{}, &jsonrpc.Error{
			Code:    jsonrpc.CodeInvalidParams,
			Message: "start_time 不能晚于 end_time",
		}
	}
	// ...
}

有些协议错误不用你管

容易误解的一点:协议错误并不是都要你亲手处理。

工具名不存在、参数不符合 schema、prompt 名不存在——这类请求 SDK 在进 handler 之前就拦掉了,返回 InvalidParams。你要做的只是把工具名和 schema 设计对,代码层面一行都不用写。

这也呼应了工具的参数里那张表:类型、必填、候选值三类校验都是 SDK 的事。handler 里该写的只有 schema 表达不了的业务约束。

资源和提示词的错误处理

和工具不一样,记一个区分就行。

资源 handler:

  • URI 不匹配(资源不存在)→ 用 mcp.ResourceNotFoundError,别用 fmt.Errorf。前者带标准错误码 -32002,并把请求的 URI 放进 data.uri,客户端才能分清「没找到」和「内部错误」。
  • 读文件、网络请求这类 IO 失败 → 返回普通 error,SDK 会把它作为协议错误传给客户端。

具体代码见提供 Resources 知识库。

提示词 handler:没有 isError 那套机制——返回的任何 error 都会变成 JSON-RPC 协议错误,客户端只能从 err 感知失败。未知的 prompt 名同样由 SDK 在进 handler 前拦下。

客户端怎么接

客户端要分三层判断,缺一层都可能丢信息:

// 第 1 层:连接失败——session 都没有,直接退出
session, err := client.Connect(ctx, transport, nil)
if err != nil {
	log.Fatalf("connect: %v", err)
}

// 第 2 层:协议错误(工具不存在、schema 校验失败等),res 不可用
res, err := session.CallTool(ctx, &mcp.CallToolParams{
	Name:      "query_logs",
	Arguments: args,
})
if err != nil {
	log.Fatalf("CallTool protocol error: %v", err)
}

// 第 3 层:业务错误——RPC 成功但 isError=true,content 里是可读的错误描述
if res.IsError {
	log.Printf("tool reported failure: %+v", res.Content)
	// 到这一步模型还能决定重试或换参数,别直接当崩溃处理
}

// 三层都过了,res.Content 才是正常数据

第 3 层最容易被漏掉:只看 err != nil 就以为万事大吉,结果业务错误被当成正常结果处理了。

写错误信息的几个习惯

  • 带上下文:fmt.Errorf("查询日志失败 service=%s range=%s: %w", svc, timeRange, err),别只回一个 “error”——模型没有别的渠道了解现场。
  • 用 %w 包装,保留 errors.Is / errors.As 可解包的能力。
  • 别在 handler 里 panic:SDK 不保证所有传输方式都能帮你 recover,可预见的错误都以 error 形式返回。
  • 进程级错误不归 MCP 协议管:http.ListenAndServe、gin.Run 失败直接 log.Fatal——此时会话已不可能建立,谈不上发协议错误。HTTP 层鉴权失败(401/403)同理,在 MCP handler 之前就被拦掉了。

想在中间件里统一记录业务错误

srv.AddReceivingMiddleware(func(next mcp.MiddlewareHandler) mcp.MiddlewareHandler {
	return func(ctx context.Context, req *mcp.Request) (any, error) {
		res, err := next(ctx, req)
		if toolRes, ok := res.(*mcp.CallToolResult); ok && toolRes.IsError {
			log.Printf("tool business error: %v", toolRes.GetError())
		}
		return res, err
	}
})

GetError() 只在服务端中间件里有意义,客户端收到的 CallToolResult 没有这个方法。

各错误码的含义查错误码。


下一篇:Streamable HTTP 配置与安全——可选。

主线到此已经完整:一个带参数约束、资源、提示词、鉴权的 MCP Server。09、10 是部署前后按需看的加餐,跳过不影响你手里的东西。