① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason → 返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的 judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune), engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error() 统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。 ② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error 原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。 新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留 code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。 ③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、 自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
176 lines
6.2 KiB
Go
176 lines
6.2 KiB
Go
package mcp
|
||
|
||
// 本文件是「SDK 自己的 Logger 日志面会不会漏第三方 server 的自由文本」的**探针**,
|
||
// 由塔 2026-10-26 复审 p2 要求:先证明有没有泄漏,再决定要不要上基础设施。
|
||
//
|
||
// 探针覆盖 go-sdk v1.8.0 客户端侧两处会打日志的点:
|
||
// 1. 正常的方法调用失败(tools/call 返回 JSON-RPC error)—— SDK 是否记录它;
|
||
// 2. keepalive ping 被对端用 JSON-RPC error 拒绝 —— SDK 明确会
|
||
// `logger.Warn("keepalive ping failed;…", "error", err)`,err 就是**对端的自由文本**。
|
||
//
|
||
// 结论(v1.8.0 实测):第 1 条不打日志;第 2 条会,且原样带出 message —— 因此
|
||
// newSDKClient 交给 SDK 的 Logger 必须经过 redactingLogger(见 sdklog.go)。
|
||
//
|
||
// 变异判据:把 newSDKClient 的 redactingLogger 摘掉 ⇒ 本文件的哨兵断言红。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
)
|
||
|
||
// sdkLogSentinel 是第三方 server 塞进 JSON-RPC error.message 的哨兵(模拟「她刚才
|
||
// 说的那句话」被对端回显)。
|
||
const sdkLogSentinel = "SENTINEL-MCPLOG 她刚才说的那句话"
|
||
|
||
// pingErrorTransport 包一层传输:把客户端的 `ping` 拦下来,直接用一条**带哨兵文本的
|
||
// JSON-RPC error** 回答它(不转发给真 server),从而稳定复现「对端 free text 进
|
||
// keepalive 日志」这条路径。
|
||
//
|
||
// 为什么要在传输层拦 ping:SDK server 的 ping 应答是内置的(总回空 result),无法让
|
||
// 它回错误;而 ping 失败正是客户端侧唯一一处「把对端 error 原样交给 logger」的地方。
|
||
type pingErrorTransport struct {
|
||
inner mcpsdk.Transport
|
||
}
|
||
|
||
func (t *pingErrorTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
|
||
conn, err := t.inner.Connect(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return newPingErrorConn(conn), nil
|
||
}
|
||
|
||
// pingErrorConn 把内层连接的消息泵到一个自己的 channel,好让拦下的 ping 能**插队**
|
||
// 回一条错误响应(直接在 Read 里塞响应不行:读循环已经阻塞在内层 Read 上)。
|
||
type pingErrorConn struct {
|
||
inner mcpsdk.Connection
|
||
msgs chan sdkjsonrpc.Message
|
||
done chan struct{}
|
||
once sync.Once
|
||
}
|
||
|
||
func newPingErrorConn(inner mcpsdk.Connection) *pingErrorConn {
|
||
c := &pingErrorConn{
|
||
inner: inner,
|
||
msgs: make(chan sdkjsonrpc.Message, 16),
|
||
done: make(chan struct{}),
|
||
}
|
||
go c.pump()
|
||
return c
|
||
}
|
||
|
||
// pump 把内层消息搬进 msgs;连接结束就关掉 msgs(读侧据此如实报错)。
|
||
func (c *pingErrorConn) pump() {
|
||
defer close(c.msgs)
|
||
for {
|
||
msg, err := c.inner.Read(context.Background())
|
||
if err != nil {
|
||
return
|
||
}
|
||
select {
|
||
case c.msgs <- msg:
|
||
case <-c.done:
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *pingErrorConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
|
||
req, ok := msg.(*sdkjsonrpc.Request)
|
||
if !ok || req.Method != "ping" {
|
||
return c.inner.Write(ctx, msg)
|
||
}
|
||
select {
|
||
case c.msgs <- &sdkjsonrpc.Response{
|
||
ID: req.ID,
|
||
Error: &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: sdkLogSentinel},
|
||
}:
|
||
return nil
|
||
case <-c.done:
|
||
return context.Canceled
|
||
}
|
||
}
|
||
|
||
func (c *pingErrorConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
|
||
select {
|
||
case msg, ok := <-c.msgs:
|
||
if !ok {
|
||
return nil, context.Canceled
|
||
}
|
||
return msg, nil
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
}
|
||
|
||
func (c *pingErrorConn) Close() error {
|
||
c.once.Do(func() { close(c.done) })
|
||
return c.inner.Close()
|
||
}
|
||
|
||
// SessionID 满足 mcpsdk.Connection(SDK 把它当作传输的身份标签,探针原样透传)。
|
||
func (c *pingErrorConn) SessionID() string { return c.inner.SessionID() }
|
||
|
||
// TestSDKLogger_ToolCallErrorIsNotLogged 证明探针不是空转:tools/call 的 JSON-RPC
|
||
// 错误**确实**会被我们的(redacted)日志器看到 —— 只是 SDK 并不记录它。
|
||
func TestSDKLogger_ToolCallErrorIsNotLogged(t *testing.T) {
|
||
t.Parallel()
|
||
var buf bytes.Buffer
|
||
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
|
||
c := New(MCPConfig{Name: "fake"},
|
||
WithTransport(startFakeServer(t, sentinelServer())),
|
||
WithLogger(log))
|
||
if err := c.Connect(context.Background()); err != nil {
|
||
t.Fatalf("Connect: %v", err)
|
||
}
|
||
defer func() { _ = c.Close() }()
|
||
if _, err := c.CallTool(context.Background(), "boom", map[string]any{}); err == nil {
|
||
t.Fatal("server 返回协议错误时 CallTool 应当报错")
|
||
}
|
||
if got := buf.String(); strings.Contains(got, errSentinel) {
|
||
t.Fatalf("tools/call 路径的 SDK 日志里出现哨兵(原先认为它不打日志,结论要改):\n%s", got)
|
||
}
|
||
t.Logf("tools/call 路径 SDK 日志(预期不含哨兵):\n%s", buf.String())
|
||
}
|
||
|
||
// TestSDKLogger_KeepaliveErrorTextStaysOut 是探针的主断言:keepalive 被对端用
|
||
// JSON-RPC error 拒绝时,SDK 会把 err 原样打进 WARN —— 哨兵绝不能出现在日志里。
|
||
func TestSDKLogger_KeepaliveErrorTextStaysOut(t *testing.T) {
|
||
t.Parallel()
|
||
var buf bytes.Buffer
|
||
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
|
||
tr := &pingErrorTransport{inner: startFakeServer(t, newFakeServer())}
|
||
c := New(MCPConfig{Name: "fake"},
|
||
WithTransport(tr),
|
||
// 固定到 keepalive 生效的旧协议版本(同 keepalive_test.go 的口径)。
|
||
WithProtocolVersion("2025-11-25"),
|
||
WithKeepAlive(20*time.Millisecond, 1),
|
||
WithLogger(log))
|
||
if err := c.Connect(context.Background()); err != nil {
|
||
t.Fatalf("Connect: %v", err)
|
||
}
|
||
defer func() { _ = c.Close() }()
|
||
|
||
waitFor(t, 5*time.Second, "keepalive 报出 ping 失败", func() bool {
|
||
return strings.Contains(buf.String(), "keepalive ping failed")
|
||
})
|
||
logged := buf.String()
|
||
// 探针本身要可证伪:日志里确实有那条 keepalive 记录,否则「没哨兵」是空转。
|
||
if !strings.Contains(logged, "keepalive ping failed") {
|
||
t.Fatalf("探针没触发 keepalive 日志,结论不可用:\n%s", logged)
|
||
}
|
||
if strings.Contains(logged, sdkLogSentinel) {
|
||
t.Fatalf("SDK 的 keepalive 日志把对端自由文本原样打出来了:\n%s", logged)
|
||
}
|
||
t.Logf("keepalive 路径 SDK 日志(预期含记录、不含哨兵):\n%s", logged)
|
||
}
|