- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=), URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动 - browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数 - mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度, 分类链(errors.Is/As、context.Canceled)经 Unwrap 保留 - channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText 四处均补了会随回退变红的用例(变异已实测)。
95 lines
3.8 KiB
Go
95 lines
3.8 KiB
Go
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)
|
||
}
|
||
}
|