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 交叉编译。
This commit is contained in:
1 parent
464ed2cad3
commit
b0bd93b15c
13 files changed
+1517
-66
No files matched your search
@@ -2,8 +2,11 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
@@ -61,11 +64,17 @@ func (c *Client) Connect(ctx context.Context) error {
|
||||
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()
|
||||
@@ -73,7 +82,9 @@ func (c *Client) Connect(ctx context.Context) error {
|
||||
c.info = info
|
||||
c.state = stateConnected
|
||||
c.mu.Unlock()
|
||||
c.log.Debug("mcp connected", "name", c.cfg.Name, "server", info.Name)
|
||||
|
||||
c.startKeepalive()
|
||||
c.log.Debug("mcp connected", "name", c.cfg.Name, "server", info.Name, "protocol", c.protocolVersion())
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -87,19 +98,94 @@ func (c *Client) Initialize(ctx context.Context) (ServerInfo, error) {
|
||||
return c.initialize(ctx, tr)
|
||||
}
|
||||
|
||||
// initialize sends the initialize request over tr and records the server
|
||||
// identity.
|
||||
// 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) {
|
||||
params := initializeParams{
|
||||
ProtocolVersion: protocolVersion,
|
||||
Capabilities: map[string]any{},
|
||||
ClientInfo: clientInfo{Name: "cyrene", Version: "0.1.0"},
|
||||
sent := c.preferredProtocolVersion
|
||||
if sent == "" {
|
||||
sent = latestProtocolVersion
|
||||
}
|
||||
var res initializeResult
|
||||
if err := tr.Call(ctx, "initialize", params, &res); err != nil {
|
||||
return ServerInfo{}, err
|
||||
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{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.
|
||||
@@ -113,6 +199,87 @@ func (c *Client) sendInitialized(ctx context.Context, tr Transport) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -162,7 +329,8 @@ func (c *Client) DiscoverTools(ctx context.Context) ([]sdk.Tool, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close closes the transport and marks the client closed. It is idempotent.
|
||||
// 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 {
|
||||
@@ -171,6 +339,10 @@ func (c *Client) Close() error {
|
||||
}
|
||||
c.state = stateClosed
|
||||
tr := c.tr
|
||||
if c.keepaliveCancel != nil {
|
||||
c.keepaliveCancel()
|
||||
c.keepaliveCancel = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if tr != nil {
|
||||
return tr.Close()
|
||||
@@ -178,9 +350,18 @@ func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTransport selects the transport matching cfg: stdio when Command is set, SSE
|
||||
// when URL is set.
|
||||
// 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
|
||||
|
||||
Reference in new issue
Block a user