Files
Cyrene_v2/internal/capability/mcp/enhancements_test.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

195 lines
6.9 KiB
Go

package mcp
import (
"context"
"errors"
"fmt"
"reflect"
"testing"
"time"
)
func TestClient_Connect_NegotiatesDowngradeOnUnsupportedError(t *testing.T) {
t.Parallel()
ft := &fakeTransport{
initErrors: []error{unsupportedProtocolError("2024-11-05")},
initResponseVersion: protocolVersion20241105,
}
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
got := ft.initSentVersions()
if !reflect.DeepEqual(got, []string{"2025-11-25", "2024-11-05"}) {
t.Fatalf("init versions = %v, want [2025-11-25 2024-11-05]", got)
}
if c.protocolVersion() != protocolVersion20241105 {
t.Fatalf("protocolVersion = %q, want %q", c.protocolVersion(), protocolVersion20241105)
}
}
func TestClient_Connect_AcceptsServerSupportedVersion(t *testing.T) {
t.Parallel()
ft := &fakeTransport{}
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if got := ft.initSentVersions(); !reflect.DeepEqual(got, []string{"2025-11-25"}) {
t.Fatalf("init versions = %v, want [2025-11-25]", got)
}
// The fake echoes the legacy version; the client must accept it as supported.
if c.protocolVersion() != protocolVersion20241105 {
t.Fatalf("protocolVersion = %q, want %q", c.protocolVersion(), protocolVersion20241105)
}
}
func TestClient_Connect_NoMutualVersion(t *testing.T) {
t.Parallel()
// The server only supports a version the client cannot speak, so negotiation
// must exhaust and surface ErrUnsupportedProtocolVersion.
ft := &fakeTransport{initErrors: []error{unsupportedProtocolError("2000-01-01")}}
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
err := c.Connect(context.Background())
if !errors.Is(err, ErrUnsupportedProtocolVersion) {
t.Fatalf("Connect error = %v, want ErrUnsupportedProtocolVersion", err)
}
}
func TestClient_Connect_ServerReturnsUnsupportedVersion(t *testing.T) {
t.Parallel()
// A server that echoes a version this client does not support (and cannot
// downgrade to) must fail the handshake with ErrUnsupportedProtocolVersion.
ft := &fakeTransport{initResponseVersion: "2099-01-01"}
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
err := c.Connect(context.Background())
if !errors.Is(err, ErrUnsupportedProtocolVersion) {
t.Fatalf("Connect error = %v, want ErrUnsupportedProtocolVersion", err)
}
}
func TestClient_ToolListChangedInvokesHandler(t *testing.T) {
t.Parallel()
called := make(chan struct{}, 1)
ft := &fakeTransport{tools: []ToolInfo{{Name: "add", InputSchema: map[string]any{}}}}
c := New(MCPConfig{Name: "s", Command: "x"},
WithTransport(ft),
WithToolListChangedHandler(func() { called <- struct{}{} }))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
// No handler invoked for unrelated notifications.
ft.notifyServer("notifications/prompts/list_changed")
select {
case <-called:
t.Fatal("handler invoked for a non-tool notification")
case <-time.After(50 * time.Millisecond):
}
// The real notification must invoke the handler.
ft.notifyServer(notificationToolsListChanged)
select {
case <-called:
case <-time.After(time.Second):
t.Fatal("tool list changed handler was not invoked")
}
}
func TestClient_KeepAlive_SendsPings(t *testing.T) {
t.Parallel()
ft := &fakeTransport{}
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft), WithKeepAlive(20*time.Millisecond, 3))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer c.Close()
waitFor(t, time.Second, func() bool { return ft.pingCount() >= 1 })
}
func TestClient_KeepAlive_FailureClosesSession(t *testing.T) {
t.Parallel()
ft := &fakeTransport{pingErr: errors.New("dead peer")}
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft), WithKeepAlive(20*time.Millisecond, 1))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
// A single failed ping (threshold 1) must close the client/transport.
waitFor(t, time.Second, func() bool { return c.transport() == nil })
waitFor(t, time.Second, func() bool {
ft.mu.Lock()
defer ft.mu.Unlock()
return ft.closes >= 1
})
}
func TestClient_KeepAlive_StopsOnUnsupportedPing(t *testing.T) {
t.Parallel()
ft := &fakeTransport{pingErr: fmt.Errorf("mcp ping: %w: %w", ErrRPC, &rpcError{Code: codeMethodNotFound, Message: "method not found"})}
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft), WithKeepAlive(20*time.Millisecond, 1))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer c.Close()
// A method-not-found ping must stop keepalive without closing the session.
waitFor(t, time.Second, func() bool { return ft.pingCount() >= 1 })
time.Sleep(80 * time.Millisecond)
if c.transport() == nil {
t.Fatal("client was closed after an unsupported ping, want it to stay connected")
}
if ft.pingCount() > 1 {
t.Fatalf("keepalive kept pinging after method-not-found; pings = %d", ft.pingCount())
}
}
func TestNewTransport_SelectsTransportType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg MCPConfig
want any
}{
{name: "http", cfg: MCPConfig{URL: "http://x", TransportType: "http"}, want: &streamableHTTPTransport{}},
{name: "streamable-alias", cfg: MCPConfig{URL: "http://x", TransportType: "streamable"}, want: &streamableHTTPTransport{}},
{name: "sse", cfg: MCPConfig{URL: "http://x", TransportType: "sse"}, want: &sseTransport{}},
{name: "stdio", cfg: MCPConfig{Command: "x", TransportType: "stdio"}, want: &stdioTransport{}},
{name: "infer-stdio", cfg: MCPConfig{Command: "x"}, want: &stdioTransport{}},
{name: "infer-sse", cfg: MCPConfig{URL: "http://x"}, want: &sseTransport{}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tr, err := newTransport(tt.cfg, defaultLogger())
if err != nil {
t.Fatalf("newTransport: %v", err)
}
if reflect.TypeOf(tr) != reflect.TypeOf(tt.want) {
t.Fatalf("transport type = %T, want %T", tr, tt.want)
}
})
}
}
func TestNewTransport_ExplicitTypeSelectsEvenWithoutField(t *testing.T) {
t.Parallel()
// Selecting an explicit transport does not validate the field; Start does.
if tr, err := newTransport(MCPConfig{TransportType: "http"}, defaultLogger()); err != nil {
t.Fatalf("newTransport(http): %v", err)
} else if _, ok := tr.(*streamableHTTPTransport); !ok {
t.Fatalf("transport type = %T, want *streamableHTTPTransport", tr)
}
}
// waitFor polls cond until it returns true or the deadline passes, failing the
// test on timeout.
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition not met before timeout")
}