package mcp // 本文件钉住「第三方 MCP server 的错误文本不进错误与日志」这条口径(errors.go)。 // // 生产风险:MCP server 是第三方进程/远端服务,它可以把刚收到的工具参数、读到的文件 // 内容、甚至自己的系统提示词塞进 error.message。原样经 SDK error 进本包错误,那条 // 文本就会随分类链一路进日志、进管理面板、进模型可见视图。 // // 变异判据(每条都对应一个会红的实现): // - 把 upstreamError 换成裸 err(或 `%w` 包原始 err)⇒ 哨兵文本出现在错误串里; // - 把 upstreamFailure 的 Error() 改成渲染 cause ⇒ 同上; // - 摘掉 Unwrap ⇒ 「分类仍成立」那条断言红(取消会被当成普通工具失败)。 import ( "context" "errors" "fmt" "strings" "testing" sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" ) // errSentinel 是「绝不能出现在错误串里」的第三方自由文本标记。 const errSentinel = "MCP-UPSTREAM-SENTINEL-原文回显" // sentinelServer 起一台会在 tools/call 里返回带哨兵文本的 JSON-RPC 错误的 server。 func sentinelServer() *mcpsdk.Server { srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "sentinel-mcp", Version: "1.0.0"}, nil) srv.AddTool(&mcpsdk.Tool{ Name: "boom", InputSchema: map[string]any{"type": "object"}, }, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { return nil, &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: errSentinel} }) return srv } // TestCallTool_KeepsServerErrorTextOut 是 CallTool 的正面证据:错误里只剩 // server / 工具 / 协议码 / 长度,哨兵文本一个字节都没有。 func TestCallTool_KeepsServerErrorTextOut(t *testing.T) { t.Parallel() c := connectFakeClient(t, sentinelServer()) _, err := c.CallTool(context.Background(), "boom", map[string]any{}) if err == nil { t.Fatal("server 返回协议错误时 CallTool 应当报错") } if strings.Contains(err.Error(), errSentinel) { t.Fatalf("第三方 server 的自由文本进了错误串:%v", err) } for _, want := range []string{"boom", "fake", "code=-32603"} { if !strings.Contains(err.Error(), want) { t.Errorf("错误里应保留可分类的骨架 %q:%v", want, err) } } } // TestUpstreamError_KeepsClassificationButNotText 直接钉住 errors.go 的契约: // Error() 只有骨架,errors.Is/As 仍然看得见原始原因。 func TestUpstreamError_KeepsClassificationButNotText(t *testing.T) { t.Parallel() cause := fmt.Errorf("sdk wrapper: %w", &sdkjsonrpc.Error{Code: -32601, Message: errSentinel}) err := upstreamError(cause) if strings.Contains(err.Error(), errSentinel) { t.Fatalf("自由文本进了错误串:%v", err) } if !strings.Contains(err.Error(), "code=-32601") { t.Errorf("错误里丢了 JSON-RPC 码:%v", err) } // 分类面:WireError 的 Is 比的是码,链不能断。 if !errors.Is(err, &sdkjsonrpc.Error{Code: -32601}) { t.Errorf("errors.Is 丢失了 JSON-RPC 码分类:%v", err) } var wire *sdkjsonrpc.Error if !errors.As(err, &wire) || wire.Code != -32601 { t.Errorf("errors.As 取不到原始 JSON-RPC 错误:%v", err) } } // TestUpstreamError_KeepsContextCancellation 钉住「取消仍可分类」:上层靠 // errors.Is(err, context.Canceled) 区分「用户取消」与「工具失败」, // 收敛文本时绝不能把这条链切断。 func TestUpstreamError_KeepsContextCancellation(t *testing.T) { t.Parallel() err := upstreamError(fmt.Errorf("mcp: %w", context.Canceled)) if !errors.Is(err, context.Canceled) { t.Fatalf("errors.Is(err, context.Canceled) = false,取消被吞掉了:%v", err) } if strings.Contains(err.Error(), "context canceled") { t.Errorf("取消的原文也被带进了错误串:%v", err) } }