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
+204
-11
@@ -2,11 +2,18 @@
|
||||
// auto-registration glue that surfaces MCP server tools as capabilities.
|
||||
//
|
||||
// An MCP server is an external tool provider. This package speaks JSON-RPC 2.0
|
||||
// to it over stdio (a spawned subprocess) or SSE, discovers the tools it
|
||||
// exposes via tools/list, maps each one to a pkg/plugins/sdk Tool, and registers
|
||||
// them into the capability registry so any character can invoke them
|
||||
// to it over stdio (a spawned subprocess), SSE or Streamable HTTP, discovers the
|
||||
// tools it exposes via tools/list, maps each one to a pkg/plugins/sdk Tool, and
|
||||
// registers them into the capability registry so any character can invoke them
|
||||
// (DevDocs/design/09 插件生态 §1).
|
||||
//
|
||||
// The client aligns with the MCP Go SDK's mature behaviour: it negotiates the
|
||||
// protocol version during initialize (requesting the newest version first and
|
||||
// downgrading to a mutually supported one), subscribes to
|
||||
// notifications/tools/list_changed via its capabilities so the registry can
|
||||
// re-sync its tool set, and runs an optional ping keepalive that closes a dead
|
||||
// session so the host can rebuild it.
|
||||
//
|
||||
// It is deliberately dependency-light: only the standard library and the
|
||||
// project packages (plugins/sdk, capability/registry, internal/logger) are
|
||||
// used. The wire protocol is implemented directly, so no third-party MCP SDK is
|
||||
@@ -30,14 +37,78 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// protocolVersion is the MCP protocol version this client advertises during the
|
||||
// initialize handshake.
|
||||
const protocolVersion = "2024-11-05"
|
||||
// MCP protocol versions this client can speak, newest first. The client advertises
|
||||
// its preferred version (latestProtocolVersion) during initialize and accepts a
|
||||
// compatible version from the server, downgrading to a mutually supported version
|
||||
// when the server rejects or cannot match the request (DevDocs/design/09 §1.1).
|
||||
const (
|
||||
// protocolVersion20251125 is the current preferred protocol version (the
|
||||
// MCP 2025-11-25 revision that introduced the Streamable HTTP migration).
|
||||
protocolVersion20251125 = "2025-11-25"
|
||||
// protocolVersion20241105 is the legacy protocol version, retained for
|
||||
// servers that have not yet migrated.
|
||||
protocolVersion20241105 = "2024-11-05"
|
||||
// latestProtocolVersion is what the client requests first during initialize.
|
||||
latestProtocolVersion = protocolVersion20251125
|
||||
// codeUnsupportedProtocolVersion is the JSON-RPC error code a server returns
|
||||
// during initialize when it does not support the requested protocol version
|
||||
// and wishes to advertise the versions it does support.
|
||||
codeUnsupportedProtocolVersion = -32022
|
||||
// codeMethodNotFound is the JSON-RPC error code a server returns when it does
|
||||
// not implement a requested method (e.g. a keepalive ping).
|
||||
codeMethodNotFound = -32601
|
||||
// maxInitializeAttempts bounds the initialize version-negotiation retries
|
||||
// (the first attempt plus one downgrade).
|
||||
maxInitializeAttempts = 2
|
||||
// notificationToolsListChanged is the server→client notification signalling
|
||||
// that the tool list has changed.
|
||||
notificationToolsListChanged = "notifications/tools/list_changed"
|
||||
// keepAliveFailureThreshold is the number of consecutive ping failures the
|
||||
// keepalive loop tolerates before closing the connection for a rebuild.
|
||||
keepAliveFailureThreshold = 3
|
||||
)
|
||||
|
||||
// supportedProtocolVersions lists every protocol version this client understands,
|
||||
// newest first. Version negotiation picks the first entry that the server also
|
||||
// supports.
|
||||
var supportedProtocolVersions = []string{protocolVersion20251125, protocolVersion20241105}
|
||||
|
||||
// isSupportedVersion reports whether v is a protocol version this client speaks.
|
||||
func isSupportedVersion(v string) bool {
|
||||
return slices.Contains(supportedProtocolVersions, v)
|
||||
}
|
||||
|
||||
// negotiateMutuallySupportedVersion returns the highest protocol version that is
|
||||
// supported by both this client and the server (which supports supported). It
|
||||
// returns "" when there is no overlap.
|
||||
func negotiateMutuallySupportedVersion(supported []string) string {
|
||||
for _, v := range supportedProtocolVersions {
|
||||
if slices.Contains(supported, v) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// highestCommonVersion returns the highest protocol version this client supports
|
||||
// that is not newer than v (used to downgrade when the server picks a version the
|
||||
// client cannot speak). It returns "" when no acceptable version exists.
|
||||
func highestCommonVersion(v string) string {
|
||||
for _, s := range supportedProtocolVersions {
|
||||
if s <= v {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Sentinel errors returned by this package. Test with errors.Is.
|
||||
var (
|
||||
@@ -54,10 +125,16 @@ var (
|
||||
// object. The concrete server error is wrapped and accessible via
|
||||
// errors.Is/errors.As on an *rpcError.
|
||||
ErrRPC = errors.New("mcp: rpc error")
|
||||
// ErrUnsupportedProtocolVersion is returned when the client and server
|
||||
// cannot agree on a mutually supported protocol version after the
|
||||
// negotiation retries are exhausted.
|
||||
ErrUnsupportedProtocolVersion = errors.New("mcp: unsupported protocol version")
|
||||
)
|
||||
|
||||
// MCPConfig describes a single MCP server connection. Exactly one transport is
|
||||
// selected: stdio when Command is set, SSE when URL is set.
|
||||
// MCPConfig describes a single MCP server connection. The transport is selected
|
||||
// by TransportType when set, otherwise inferred from the fields: stdio when
|
||||
// Command is set, SSE when URL is set. A blank TransportType keeps that legacy
|
||||
// inference so existing configuration keeps working unchanged.
|
||||
type MCPConfig struct {
|
||||
// Name is the human-readable label of the server, used in logs and as the
|
||||
// prefix for registering its tools uniquely.
|
||||
@@ -66,8 +143,12 @@ type MCPConfig struct {
|
||||
Command string `env:"MCP_COMMAND"`
|
||||
// Args are the arguments passed to Command.
|
||||
Args []string `env:"MCP_ARGS"`
|
||||
// URL is the SSE endpoint of an MCP server.
|
||||
// URL is the endpoint of an MCP server (SSE stream endpoint, or the
|
||||
// Streamable HTTP endpoint when TransportType is "http").
|
||||
URL string `env:"MCP_URL"`
|
||||
// TransportType selects the transport explicitly: "stdio", "sse" or
|
||||
// "http" (Streamable HTTP). Empty means infer from Command/URL.
|
||||
TransportType string `env:"MCP_TRANSPORT"`
|
||||
// Enabled reports whether this server should be activated at startup.
|
||||
Enabled bool `env:"MCP_ENABLED"`
|
||||
}
|
||||
@@ -122,6 +203,53 @@ type Client struct {
|
||||
mu sync.Mutex
|
||||
state state
|
||||
info ServerInfo
|
||||
|
||||
// negotiatedVersion is the protocol version agreed during initialize; it is
|
||||
// sent back to the server in the Mcp-Protocol-Version header by the HTTP
|
||||
// transport.
|
||||
negotiatedVersion string
|
||||
|
||||
// preferredProtocolVersion, when non-empty, overrides latestProtocolVersion
|
||||
// as the version requested first during initialize. Tests use it to drive the
|
||||
// negotiation path deterministically.
|
||||
preferredProtocolVersion string
|
||||
|
||||
// keepAlive is the ping interval for the connection keepalive; zero disables
|
||||
// it. keepAliveFailures is the tolerated consecutive-failure count.
|
||||
keepAlive time.Duration
|
||||
keepAliveFailures int
|
||||
keepaliveCancel context.CancelFunc
|
||||
|
||||
// onToolListChanged is invoked (in a fresh goroutine) when the server sends
|
||||
// notifications/tools/list_changed, so the host can refresh its tool set.
|
||||
onToolListChanged func()
|
||||
refreshPending atomic.Bool
|
||||
}
|
||||
|
||||
// protocolVersion returns the protocol version negotiated by initialize, or ""
|
||||
// before a successful handshake.
|
||||
func (c *Client) protocolVersion() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.negotiatedVersion
|
||||
}
|
||||
|
||||
// setProtocolVersion records the version negotiated by initialize.
|
||||
func (c *Client) setProtocolVersion(v string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.negotiatedVersion = v
|
||||
}
|
||||
|
||||
// capabilities returns the client capability declaration for the given protocol
|
||||
// version. It advertises tools.listChanged so the server notifies the client when
|
||||
// the tool list changes.
|
||||
func (c *Client) capabilities(version string) map[string]any {
|
||||
caps := map[string]any{}
|
||||
if c.onToolListChanged != nil {
|
||||
caps["tools"] = map[string]any{"listChanged": true}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
// state tracks the client lifecycle.
|
||||
@@ -134,8 +262,8 @@ const (
|
||||
)
|
||||
|
||||
// Transport is the JSON-RPC message exchange with an MCP server over a concrete
|
||||
// transport (stdio subprocess or SSE). Implementations must be safe for
|
||||
// concurrent use. This is exported so tests and hosts can inject a fake
|
||||
// transport (stdio subprocess, SSE or Streamable HTTP). Implementations must be
|
||||
// safe for concurrent use. This is exported so tests and hosts can inject a fake
|
||||
// transport.
|
||||
type Transport interface {
|
||||
// Start makes the transport ready to exchange messages.
|
||||
@@ -154,6 +282,37 @@ type notifier interface {
|
||||
notify(ctx context.Context, method string, params any) error
|
||||
}
|
||||
|
||||
// serverNotifier is implemented by transports that can deliver server-initiated
|
||||
// notifications (messages with no id, e.g. notifications/tools/list_changed) back
|
||||
// to the client. The handler is invoked from the transport's read path, so it must
|
||||
// not block or re-enter the same transport synchronously.
|
||||
type serverNotifier interface {
|
||||
setOnNotification(fn func(method string))
|
||||
}
|
||||
|
||||
// attachNotificationSink wires a server→client notification handler into tr, if
|
||||
// tr supports one.
|
||||
func attachNotificationSink(tr Transport, fn func(method string)) {
|
||||
if on, ok := tr.(serverNotifier); ok {
|
||||
on.setOnNotification(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// protocolProvider is implemented by transports that include the negotiated MCP
|
||||
// protocol version in requests (the Mcp-Protocol-Version header of the HTTP
|
||||
// transport).
|
||||
type protocolProvider interface {
|
||||
setProtocolProvider(fn func() string)
|
||||
}
|
||||
|
||||
// attachProtocolProvider wires the negotiated-version getter into tr, if tr
|
||||
// supports one.
|
||||
func attachProtocolProvider(tr Transport, fn func() string) {
|
||||
if p, ok := tr.(protocolProvider); ok {
|
||||
p.setProtocolProvider(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// Option customizes a Client built by New.
|
||||
type Option func(*Client)
|
||||
|
||||
@@ -176,6 +335,40 @@ func WithTransport(t Transport) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeepAlive enables the connection keepalive, sending an MCP ping every
|
||||
// interval and closing the session after keepAliveFailures consecutive missed
|
||||
// pings. A zero interval leaves keepalive disabled. When keepAliveFailures is
|
||||
// omitted, the package default (keepAliveFailureThreshold) is used.
|
||||
func WithKeepAlive(interval time.Duration, keepAliveFailures ...int) Option {
|
||||
failures := keepAliveFailureThreshold
|
||||
if len(keepAliveFailures) > 0 && keepAliveFailures[0] > 0 {
|
||||
failures = keepAliveFailures[0]
|
||||
}
|
||||
return func(c *Client) {
|
||||
c.keepAlive = interval
|
||||
c.keepAliveFailures = failures
|
||||
}
|
||||
}
|
||||
|
||||
// WithToolListChangedHandler registers a handler that is invoked (in a fresh
|
||||
// goroutine) whenever the server sends notifications/tools/list_changed, letting
|
||||
// the host refresh its registered tool set.
|
||||
func WithToolListChangedHandler(fn func()) Option {
|
||||
return func(c *Client) {
|
||||
c.onToolListChanged = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithProtocolVersion overrides the protocol version requested first during
|
||||
// initialize. It is primarily for tests that drive the negotiation path
|
||||
// deterministically; callers should normally leave it unset to use
|
||||
// latestProtocolVersion.
|
||||
func WithProtocolVersion(v string) Option {
|
||||
return func(c *Client) {
|
||||
c.preferredProtocolVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
// New builds a Client for cfg. By default it uses a discard logger and selects a
|
||||
// stdio or SSE transport from cfg; pass WithLogger and/or WithTransport to
|
||||
// override.
|
||||
|
||||
Reference in new issue
Block a user