Files
Cyrene_v2/internal/capability/mcp/client.go
T
Aska b0bd93b15c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(mcp): Top5#3 对齐 MCP Go SDK——协议版本协商 + list_changed 差量刷新 + keepalive + Streamable HTTP
- 协议版本协商:initialize 先发最高版本(2025-11-25)并声明能力集合,遇
  UnsupportedProtocolVersionError(-32022)/服务器不支持版本 → negotiateMutuallySupportedVersion
  求出双方最高共同版本降级重试(最多2次);无共同版本→ErrUnsupportedProtocolVersion。
  协商结果记 negotiatedVersion,HTTP 传输回填 Mcp-Protocol-Version 头。
- tools/list_changed 订阅:capabilities 声明 tools.listChanged:true;传输读无 id 帧路由到
  handleServerNotification;refreshPending 原子旗标合并突发 + 新 goroutine 调 onToolListChanged
  (避免 stdio lineRPC 重入自锁);Registry 真差量刷新(重新 DiscoverTools→移除消失/注册新增,
  跳过同名冲突保外来工具)。
- keepalive:WithKeepAlive(interval,failures) 起 goroutine 定时 Call(ping),连续失败≥阈值关
  session(默认3,可配);服务器不支持 ping(-32601)停 keepalive 不误关;ctx+Close 取消不泄漏。
- Streamable HTTP 传输:newTransport 支持 TransportType=http|streamable(保留 stdio/SSE 推断,
  空值走旧逻辑不破坏);Call POST+Accept json/event-stream 响应可单 JSON 或 SSE 流找匹配 id;
  Mcp-Session-Id 会话 + 独立 GET SSE 流收 server 通知;config 读 MCP_TRANSPORT。
- schema 缓存判定不做:客户端透传 map[string]any 无重复编译,SDK SchemaCache 是服务端
  按 Go 类型反射场景,客户端无对应。

go build/vet/test 全绿(count=2 无 flaky) + windows/darwin 交叉编译。
2026-09-06 17:27:14 +08:00

416 lines
13 KiB
Go

package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// initializeParams is the MCP initialize request body.
type initializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
ClientInfo clientInfo `json:"clientInfo"`
}
// initializeResult is the MCP initialize response body.
type initializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
ServerInfo serverInfo `json:"serverInfo"`
}
// clientInfo identifies the client to the server.
type clientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// serverInfo identifies the server to the client.
type serverInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// Connect selects the transport (unless one was injected), starts it, completes
// the MCP initialize handshake, and marks the client connected. It is
// idempotent; a second call while connected returns nil.
func (c *Client) Connect(ctx context.Context) error {
c.mu.Lock()
switch c.state {
case stateClosed:
c.mu.Unlock()
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, ErrClosed)
case stateConnected:
c.mu.Unlock()
return nil
}
c.mu.Unlock()
tr := c.tr
if tr == nil {
var err error
if tr, err = newTransport(c.cfg, c.log); err != nil {
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, err)
}
}
if err := tr.Start(ctx); err != nil {
tr.Close()
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, err)
}
// Wire server→client notification routing (list_changed) before the server
// can start sending them after notifications/initialized.
attachNotificationSink(tr, c.handleServerNotification)
info, err := c.initialize(ctx, tr)
if err != nil {
tr.Close()
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, err)
}
// Let the transport echo the negotiated protocol version (Streamable HTTP).
attachProtocolProvider(tr, c.protocolVersion)
c.sendInitialized(ctx, tr)
c.mu.Lock()
c.tr = tr
c.info = info
c.state = stateConnected
c.mu.Unlock()
c.startKeepalive()
c.log.Debug("mcp connected", "name", c.cfg.Name, "server", info.Name, "protocol", c.protocolVersion())
return nil
}
// Initialize performs the MCP initialize handshake on an already-connected
// client. It returns the server identity.
func (c *Client) Initialize(ctx context.Context) (ServerInfo, error) {
tr := c.transport()
if tr == nil {
return ServerInfo{}, fmt.Errorf("mcp initialize %q: %w", c.cfg.Name, ErrNotConnected)
}
return c.initialize(ctx, tr)
}
// initialize performs the MCP initialize handshake with protocol version
// negotiation (DevDocs/design/09 §1.1). It does not hard-code a single version:
// it first requests latestProtocolVersion, and if the server rejects that version
// (a JSON-RPC error with code codeUnsupportedProtocolVersion carrying the
// supported set) or replies with a version this client cannot speak, it downgrades
// once to the highest mutually supported version and retries. It records the
// agreed version so the HTTP transport can echo it back as Mcp-Protocol-Version.
func (c *Client) initialize(ctx context.Context, tr Transport) (ServerInfo, error) {
sent := c.preferredProtocolVersion
if sent == "" {
sent = latestProtocolVersion
}
var lastErr error
for attempt := 0; attempt < maxInitializeAttempts; attempt++ {
params := initializeParams{
ProtocolVersion: sent,
Capabilities: c.capabilities(sent),
ClientInfo: clientInfo{Name: "cyrene", Version: "0.1.0"},
}
var res initializeResult
err := tr.Call(ctx, "initialize", params, &res)
if err != nil {
// Server rejected the requested version and may advertise the versions
// it supports; downgrade to the highest mutually supported one.
if supported, ok := unsupportedVersions(err); ok {
if v := negotiateMutuallySupportedVersion(supported); v != "" && v != sent {
c.log.Debug("mcp downgrading protocol version",
"name", c.cfg.Name, "requested", sent, "version", v)
sent = v
lastErr = err
continue
}
return ServerInfo{}, fmt.Errorf("mcp initialize %q: %w: %w", c.cfg.Name, ErrUnsupportedProtocolVersion, err)
}
return ServerInfo{}, err
}
negotiated := res.ProtocolVersion
if negotiated == "" {
negotiated = sent
}
if !isSupportedVersion(negotiated) {
// The server replied with a version this client does not speak;
// downgrade once to the highest version we both accept.
if v := highestCommonVersion(negotiated); v != "" && v != sent {
c.log.Debug("mcp renegotiating protocol version",
"name", c.cfg.Name, "server", negotiated, "version", v)
sent = v
lastErr = err
continue
}
return ServerInfo{}, fmt.Errorf("mcp initialize %q: %w: server advertised %q", c.cfg.Name, ErrUnsupportedProtocolVersion, negotiated)
}
c.setProtocolVersion(negotiated)
return ServerInfo{Name: res.ServerInfo.Name, Version: res.ServerInfo.Version}, nil
}
return ServerInfo{}, fmt.Errorf("mcp initialize %q: %w: %w", c.cfg.Name, ErrUnsupportedProtocolVersion, lastErr)
}
// isMethodNotFound reports whether err wraps a JSON-RPC "method not found" error
// (code -32601), which a peer returns when it does not implement a method such as
// the keepalive ping.
func isMethodNotFound(err error) bool {
var re *rpcError
return errors.As(err, &re) && re.Code == codeMethodNotFound
}
// unsupportedVersions reports the protocol versions the server supports, extracted
// from the data field of a codeUnsupportedProtocolVersion JSON-RPC error. It
// reports ok=false when err does not carry such an error or the data is unusable.
func unsupportedVersions(err error) ([]string, bool) {
var re *rpcError
if !errors.As(err, &re) || re.Code != codeUnsupportedProtocolVersion {
return nil, false
}
if re.Data == nil {
return nil, false
}
data, err := json.Marshal(re.Data)
if err != nil {
return nil, false
}
var payload struct {
Supported []string `json:"supported"`
}
if err := json.Unmarshal(data, &payload); err != nil || len(payload.Supported) == 0 {
return nil, false
}
return payload.Supported, true
}
// sendInitialized best-effort sends the notifications/initialized advisory ack.
func (c *Client) sendInitialized(ctx context.Context, tr Transport) {
n, ok := tr.(notifier)
if !ok {
return
}
if err := n.notify(ctx, "notifications/initialized", map[string]any{}); err != nil {
c.log.Debug("mcp initialized notification failed", "name", c.cfg.Name, "err", err)
}
}
// handleServerNotification is the transport-level sink for server→client
// notifications. It offloads the actual work to a fresh goroutine because it runs
// on the transport read path and must not block or re-enter the same transport
// synchronously; refreshPending coalesces bursts so at most one refresh runs at a
// time.
func (c *Client) handleServerNotification(method string) {
if method != notificationToolsListChanged || c.onToolListChanged == nil {
return
}
if !c.refreshPending.CompareAndSwap(false, true) {
return
}
go func() {
defer c.refreshPending.Store(false)
c.onToolListChanged()
}()
}
// ping sends a JSON-RPC ping request and reports whether the server responded.
// Pings are used by the keepalive loop to detect a dead connection.
func (c *Client) ping(ctx context.Context) error {
tr := c.transport()
if tr == nil {
return fmt.Errorf("mcp ping: %w", ErrNotConnected)
}
if err := tr.Call(ctx, "ping", map[string]any{}, nil); err != nil {
return fmt.Errorf("mcp ping: %w", err)
}
return nil
}
// startKeepalive launches the ping keepalive loop when configured. It closes the
// client once keepAliveFailureThreshold consecutive pings fail, so the host can
// rebuild the session. The loop exits when the client is closed.
func (c *Client) startKeepalive() {
interval := c.keepAlive
if interval <= 0 {
return
}
ctx, cancel := context.WithCancel(context.Background())
c.mu.Lock()
c.keepaliveCancel = cancel
c.mu.Unlock()
threshold := c.keepAliveFailures
if threshold < 1 {
threshold = keepAliveFailureThreshold
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
failures := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
err := c.ping(pingCtx)
pingCancel()
if err != nil {
// A peer that does not implement ping is still healthy; stop
// keepalive rather than treating every ping as a failure.
if isMethodNotFound(err) {
c.log.Debug("mcp keepalive stopped; server does not support ping", "name", c.cfg.Name)
return
}
failures++
c.log.Warn("mcp keepalive ping failed", "name", c.cfg.Name, "err", err, "failures", failures)
if failures < threshold {
continue
}
c.log.Error("mcp keepalive ping failed; closing session", "name", c.cfg.Name, "failures", failures)
_ = c.Close()
return
}
failures = 0
}
}
}()
}
// ListTools returns the tools exposed by the connected MCP server.
func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
tr := c.transport()
if tr == nil {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, ErrNotConnected)
}
var res listToolsResult
if err := tr.Call(ctx, "tools/list", map[string]any{}, &res); err != nil {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
}
out := make([]ToolInfo, 0, len(res.Tools))
for _, t := range res.Tools {
out = append(out, ToolInfo{Name: t.Name, Description: t.Description, InputSchema: t.InputSchema})
}
return out, nil
}
// CallTool invokes a tool on the connected MCP server.
func (c *Client) CallTool(ctx context.Context, name string, args any) (ToolResult, error) {
tr := c.transport()
if tr == nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, ErrNotConnected)
}
arguments, err := normalizeArgs(args)
if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
}
params := callToolParams{Name: name, Arguments: arguments}
var res callToolResult
if err := tr.Call(ctx, "tools/call", params, &res); err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
}
return ToolResult{Content: toContentBlocks(res.Content), IsError: res.IsError}, nil
}
// DiscoverTools lists the server tools and maps each to an sdk.Tool so the MCP
// server appears as a normal tool provider.
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 {
out = append(out, &mcpTool{client: c, info: t})
}
return out, nil
}
// Close closes the transport and marks the client closed. It is idempotent. It
// also cancels the keepalive loop so no ping goroutine outlives the client.
func (c *Client) Close() error {
c.mu.Lock()
if c.state == stateClosed {
c.mu.Unlock()
return nil
}
c.state = stateClosed
tr := c.tr
if c.keepaliveCancel != nil {
c.keepaliveCancel()
c.keepaliveCancel = nil
}
c.mu.Unlock()
if tr != nil {
return tr.Close()
}
return nil
}
// newTransport selects the transport matching cfg. An explicit TransportType
// ("stdio", "sse" or "http") wins; otherwise the transport is inferred from the
// fields, preserving the legacy behaviour of stdio-when-Command and SSE-when-URL.
func newTransport(cfg MCPConfig, log *slog.Logger) (Transport, error) {
switch cfg.TransportType {
case "http", "streamable":
return newHTTPTransport(cfg, log), nil
case "sse":
return newSSETransport(cfg, log), nil
case "stdio":
return newStdioTransport(cfg, log), nil
}
switch {
case cfg.Command != "":
return newStdioTransport(cfg, log), nil
case cfg.URL != "":
return newSSETransport(cfg, log), nil
default:
return nil, fmt.Errorf("mcp: %w", ErrNoTransport)
}
}
// normalizeArgs converts an agent-supplied argument value into the MCP arguments
// object, requiring a JSON object.
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)
}
// listToolsResult is the MCP tools/list response body.
type listToolsResult struct {
Tools []toolSpec `json:"tools"`
}
// toolSpec is a single tool in a tools/list response.
type toolSpec struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
// callToolParams is the MCP tools/call request body.
type callToolParams struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
}
// callToolResult is the MCP tools/call response body.
type callToolResult struct {
Content []contentBlock `json:"content"`
IsError bool `json:"isError"`
}
// contentBlock is a wire content block in a tools/call response.
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}