fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN

- 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

四处均补了会随回退变红的用例(变异已实测)。
This commit is contained in:
AskaEth committed 2026-10-26 19:44:22 +08:00
1 parent 2702777950
commit d6493d79f4
10 files changed
+538 -23

No files matched your search

+10 -6
View File
@@ -33,7 +33,6 @@ func (c *Client) Connect(ctx context.Context) error {
if err != nil {
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
}
info := serverInfoFrom(session, c.hostFeatures())
c.mu.Lock()
c.session = session
@@ -62,7 +61,7 @@ func (c *Client) Close() error {
if session != nil {
if err := session.Close(); err != nil {
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err)
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
}
}
return nil
@@ -82,7 +81,7 @@ func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
out := make([]ToolInfo, 0, 8)
for tool, err := range session.Tools(ctx, nil) {
if err != nil {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, upstreamError(err))
}
out = append(out, toolInfoFrom(tool))
}
@@ -112,7 +111,7 @@ func (c *Client) callTool(ctx context.Context, name string, args map[string]any,
}
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
}
return ToolResult{
Content: toContentBlocks(res.Content),
@@ -188,12 +187,17 @@ func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
}
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
//
// 它同时是握手失败的**脱敏点**:原始 SDK 错误(第三方 server 的自由文本)只经
// upstreamError 留下 code 与长度,绝不被 %w 带进文案 —— 而哨兵映射仍然成立,因为
// upstreamFailure 的 cause 保留了整条链,errors.Is 不受影响。
func wrapConnectErr(err error) error {
safe := upstreamError(err)
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err)
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, safe)
}
return err
return safe
}
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
+71
View File
@@ -0,0 +1,71 @@
package mcp
import (
"errors"
"fmt"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// 本文件是「第三方 MCP server 的错误怎么写进本包错误」的**唯一**收敛点。
//
// 为什么需要它:MCP server 是第三方进程/远端服务,它的错误文本是**上游自由文本** ——
// 它可以把刚收到的工具参数、读到的文件内容、甚至是它自己的系统提示词塞进
// `error.message`。原样经 SDK error 进本包错误,那条文本就会随 `errors.Is` 分类链
// 一路进日志、进管理面板、进模型可见视图。与 trace.UpstreamError 同一口径:
// **默认不带自由文本**,只留可分类的骨架。
//
// 留下的骨架(够定位,不够回显):
// - 哪台 server / 哪把工具 —— 由调用点的 %w 文案给(名字来自配置与 tools/list,
// 不是错误文本);
// - JSON-RPC code —— 协议枚举(-32601 method not found、-32602 invalid params …),
// 是本文件唯一允许出镜的「对端给的值」,且以数字渲染;
// - message 的**字节数** —— 能区分「对端给了句话」与「对端什么都没说」,不带那句话。
// upstreamFailure 是一次第三方 server 失败在本包错误链里的形态。它的 Error() 只
// 渲染 code 与长度;原始原因挂在 cause 上,只供 errors.Is / errors.As 分类。
//
// 为什么不直接把 SDK 的 err 用 %w 包上:%w 只加前缀,被包的那条文本仍然在
// err.Error() 里 —— 包一层不是修复,只是换个位置泄漏(同 trace.URLCause 的口径)。
type upstreamFailure struct {
// code 是 JSON-RPC 错误码;hasCode 为 false 表示这条错误不是线上响应里的
// 结构化错误(传输层失败、SDK 内部错误、ctx 取消等),此时不带任何码。
code int64
hasCode bool
// length 是被扣下的自由文本的字节数。
length int
// cause 只用于分类,绝不参与 Error()。
cause error
}
// Error 渲染一行只含 code / 长度的诊断;自由文本一个字节都不出。
func (f *upstreamFailure) Error() string {
if f.hasCode {
return fmt.Sprintf("mcp server error: code=%d message_bytes=%d", f.code, f.length)
}
return fmt.Sprintf("mcp server error: %T message_bytes=%d", f.cause, f.length)
}
// Unwrap 让 errors.Is/As 仍然看得见原始原因(context.Canceled、
// context.DeadlineExceeded、*jsonrpc.Error 的码…),而 Error() 绝不渲染它。
// 少了它,取消会被上层当成普通工具失败继续跑。
func (f *upstreamFailure) Unwrap() error { return f.cause }
// upstreamError 把一次第三方 server 失败收敛成**不带自由文本**的错误。
//
// 收 code 用的 errors.As 会遍历整条链:SDK 常把线上错误再包一层(fmt 包装、
// JSON-RPC 信封),所以不能只断言最外层。
func upstreamError(err error) error {
if err == nil {
return nil
}
failure := &upstreamFailure{cause: err, length: len(err.Error())}
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
failure.code = wire.Code
failure.hasCode = true
// WireError.Error() 就是 Message 本身,所以这里量的正是被扣下的那段文本。
failure.length = len(wire.Message)
}
return failure
}
+94
View File
@@ -0,0 +1,94 @@
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)
}
}