读完这篇你能:分清协议错误和业务错误,知道哪种该返回什么,以及客户端怎么接。
前置:工具的参数。
为什么 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 是部署前后按需看的加餐,跳过不影响你手里的东西。