Files
Cyrene_v2/internal/capability/mcp/client.go
T
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① 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 用法作为例子写进去。
2026-10-26 20:00:57 +08:00

215 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mcp
import (
"context"
"errors"
"fmt"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Connect 选择传输(未注入时)、建立官方 SDK 会话并完成 initialize 握手。
// 幂等:已连接时直接返回 nil;已关闭时返回 ErrClosed。
func (c *Client) Connect(ctx context.Context) error {
same, err := c.begin()
if err != nil {
return err
}
if same {
return nil
}
tr := c.tr
if tr == nil {
if tr, err = newTransport(c.cfg); err != nil {
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, err)
}
}
session, err := c.newSDKClient().Connect(ctx, tr, c.sessionOptions())
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
c.tr = tr
c.state = stateConnected
c.info = info
c.mu.Unlock()
c.log.Debug("mcp connected", "name", c.cfg.Name, "server", info.Name,
"version", info.Version, "protocol", info.ProtocolVersion,
"tools_declared", info.Features.Tools)
return nil
}
// Close 关闭 SDK 会话并标记客户端已关闭。幂等。
func (c *Client) Close() error {
c.mu.Lock()
if c.state == stateClosed {
c.mu.Unlock()
return nil
}
session := c.session
c.session = nil
c.state = stateClosed
c.mu.Unlock()
if session != nil {
if err := session.Close(); err != nil {
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
}
}
return nil
}
// ListTools 返回服务器暴露的工具(SDK 自动翻页)。
// 服务器在 initialize 里没有声明 tools 能力族时拒绝调用、不发出 RPC:
// design/09 §1.1「声明了才算可用,未声明不得调用」。
func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
session := c.connected()
if session == nil {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, ErrNotConnected)
}
if !c.Features().Tools {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, ErrCapabilityNotDeclared)
}
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, upstreamError(err))
}
out = append(out, toolInfoFrom(tool))
}
return out, nil
}
// CallTool 调用服务器上的工具。args 必须是 JSON 对象(map[string]any)。
// 调用前用 schema 缓存里的 input schema 校验参数并补默认值。
func (c *Client) CallTool(ctx context.Context, name string, args any) (ToolResult, error) {
arguments, err := normalizeArgs(args)
if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
}
return c.callTool(ctx, name, arguments, nil)
}
// callTool 是 CallTool 的内部实现,fallback 是缓存未命中时可用的 input schema
// (来自 tools/list 的发现结果)。
func (c *Client) callTool(ctx context.Context, name string, args map[string]any, fallback map[string]any) (ToolResult, error) {
session := c.connected()
if session == nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, ErrNotConnected)
}
prepared, err := c.schemas.Prepare(c.cfg.Name, name, fallback, args)
if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
}
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
}
return ToolResult{
Content: toContentBlocks(res.Content),
Structured: res.StructuredContent,
IsError: res.IsError,
}, nil
}
// DiscoverTools 列出服务器工具,把每把工具包装成 sdk.Tool 并把 input schema
// 放进缓存,使 MCP server 看起来就是一个普通工具提供者。
func (c *Client) DiscoverTools(ctx context.Context) ([]sdk.Tool, error) {
tools, err := c.ListTools(ctx)
if err != nil {
return nil, err
}
out := make([]sdk.Tool, 0, len(tools))
for _, t := range tools {
c.cacheSchema(t)
out = append(out, &mcpTool{client: c, info: t})
}
return out, nil
}
// cacheSchema 把一次发现到的 input schema 解析并放进缓存;解析失败只降级为
// 「调用时不校验」,不影响工具接入。
func (c *Client) cacheSchema(t ToolInfo) {
if err := c.schemas.Put(c.cfg.Name, t.Name, t.InputSchema); err != nil {
c.log.Warn("mcp tool input schema not resolvable; validation skipped",
"name", c.cfg.Name, "tool", t.Name, "err", err)
}
}
// handleToolListChanged 是 SDK 的 notifications/tools/list_changed 回调:
// 先让 schema 缓存失效(design/09 §1.1),再合并触发宿主的增量刷新。
// 它可能运行在 SDK 的读路径上,因此真正的刷新放到新 goroutine 里。
func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedRequest) {
c.schemas.Invalidate(c.cfg.Name)
if c.onToolListChanged == nil {
return
}
if !c.refreshPending.CompareAndSwap(false, true) {
return
}
go func() {
defer c.refreshPending.Store(false)
c.onToolListChanged()
}()
}
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
func (c *Client) newSDKClient() *mcpsdk.Client {
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
// 🔴 交给 SDK 的 Logger 必须过 redactingLogger(sdklog.go):SDK 自己会把对端
// 错误原样打进日志(keepalive 那条已实测),那条路绕开了本包 upstreamError 的出口。
Logger: redactingLogger(c.log),
Capabilities: c.clientCapabilities(),
CreateMessageHandler: c.wrapSampling(),
ElicitationHandler: c.wrapElicitation(),
ToolListChangedHandler: c.handleToolListChanged,
KeepAlive: c.keepAlive,
KeepAliveFailureThreshold: c.keepAliveFailures,
})
if len(c.roots) > 0 {
client.AddRoots(c.roots...)
}
return client
}
// sessionOptions 返回 SDK 会话选项;宿主固定了协议版本时才返回非 nil。
func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
if c.preferredProtocolVersion == "" {
return nil
}
return &mcpsdk.ClientSessionOptions{ProtocolVersion: c.preferredProtocolVersion}
}
// 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, safe)
}
return safe
}
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
func normalizeArgs(args any) (map[string]any, error) {
if args == nil {
return map[string]any{}, nil
}
if m, ok := args.(map[string]any); ok {
return m, nil
}
return nil, fmt.Errorf("arguments must be a JSON object, got %T", args)
}