- 新增依赖:github.com/modelcontextprotocol/go-sdk v1.8.0(direct)+ github.com/google/jsonschema-go v0.4.3 (SDK 自带依赖,直接用于 schema 校验/默认值,避免再造一份);go.sum 纯新增 - 删除自研协议层:rpc.go / stdio.go / http.go / sse.go 及其测试 - 用 SDK 的成熟逻辑:命令传输(stdio 子进程)/ SSE / Streamable HTTP、协议版本协商与 -32022 回退、keepalive(含失败阈值)、tools/list_changed 订阅、能力协商(InitializeResult.Capabilities) - 保留自研的四件事(其余零自研):配置→传输、发现→registry 注册、能力族推断、schema 缓存(Prepare 命中不重复解析) - 能力闸诚实化:服务器声明 capabilities 但不含 tools 时 ListTools/DiscoverTools 返回 ErrCapabilityNotDeclared(不静默空集);完全不声明 capabilities 的老式服务器仍按 legacy 处理 - 复核:build 绿 + capability 全包测试绿(mcp 42 用例,含真子进程 stdio / httptest Streamable HTTP / 内存传输三类假 server,不依赖外网)+ `-race` 干净;变异 2 条(featuresFrom 恒 true / 删 applySchema 调用)已还原 - 无 REST/WS/env/数据目录变更;包内 API 有增删(见回报),仓外调用方签名未变(未改 cmd/cyrene 与 plugin/manager)
120 lines
4.5 KiB
Go
120 lines
4.5 KiB
Go
package mcp
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
|
||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||
)
|
||
|
||
// TestFeaturesFrom_DeclarationMatrix 覆盖「声明了才算可用」的判定矩阵。
|
||
func TestFeaturesFrom_DeclarationMatrix(t *testing.T) {
|
||
t.Parallel()
|
||
host := hostFeatures{sampling: true, elicitation: true, roots: true}
|
||
tests := []struct {
|
||
name string
|
||
caps map[string]any
|
||
wantTools bool
|
||
wantPrompts bool
|
||
wantSampling bool
|
||
}{
|
||
{name: "未声明任何能力(legacy 服务器)", caps: map[string]any{}, wantTools: true, wantSampling: false},
|
||
{name: "只有 tools", caps: map[string]any{"tools": map[string]any{}}, wantTools: true, wantSampling: true},
|
||
{name: "只有 logging", caps: map[string]any{"logging": map[string]any{}}, wantTools: false, wantSampling: true},
|
||
{name: "tools+prompts", caps: map[string]any{"tools": map[string]any{}, "prompts": map[string]any{}}, wantTools: true, wantPrompts: true, wantSampling: true},
|
||
{name: "tools 为 null 视为未声明", caps: map[string]any{"tools": nil}, wantTools: false, wantSampling: true},
|
||
}
|
||
for _, tt := range tests {
|
||
tt := tt
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
got := featuresFrom(tt.caps, host)
|
||
if got.Tools != tt.wantTools {
|
||
t.Fatalf("Tools = %v, 期望 %v(caps=%v)", got.Tools, tt.wantTools, tt.caps)
|
||
}
|
||
if got.Prompts != tt.wantPrompts {
|
||
t.Fatalf("Prompts = %v, 期望 %v", got.Prompts, tt.wantPrompts)
|
||
}
|
||
if got.Sampling != tt.wantSampling {
|
||
t.Fatalf("Sampling = %v, 期望 %v", got.Sampling, tt.wantSampling)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestFeaturesFrom_HostNotConfigured 宿主没配回调时,即使服务器声明了能力也不可用。
|
||
func TestFeaturesFrom_HostNotConfigured(t *testing.T) {
|
||
t.Parallel()
|
||
got := featuresFrom(map[string]any{"tools": map[string]any{}}, hostFeatures{})
|
||
if got.Sampling || got.Elicitation || got.Roots {
|
||
t.Fatalf("宿主未配置任何客户端能力,却推断为可用:%+v", got)
|
||
}
|
||
}
|
||
|
||
// TestClient_SamplingHandler_RefusedWhenNotDeclared 服务器未声明能力族时,
|
||
// 包装后的 sampling 回调必须直接拒绝,不放行给宿主。
|
||
func TestClient_SamplingHandler_RefusedWhenNotDeclared(t *testing.T) {
|
||
t.Parallel()
|
||
called := false
|
||
c := New(MCPConfig{Name: "s"}, WithSamplingHandler(func(context.Context, *mcpsdk.CreateMessageRequest) (*mcpsdk.CreateMessageResult, error) {
|
||
called = true
|
||
return &mcpsdk.CreateMessageResult{}, nil
|
||
}))
|
||
h := c.wrapSampling()
|
||
if h == nil {
|
||
t.Fatal("配了 sampling 回调,wrapSampling 却返回 nil")
|
||
}
|
||
// 未连接(等价服务器什么都没声明):必须拒绝。
|
||
if _, err := h(context.Background(), &mcpsdk.CreateMessageRequest{}); !errors.Is(err, ErrCapabilityNotDeclared) {
|
||
t.Fatalf("未声明时 error = %v, 期望 ErrCapabilityNotDeclared", err)
|
||
}
|
||
if called {
|
||
t.Fatal("宿主回调在服务器未声明能力时被调用了")
|
||
}
|
||
|
||
// 服务器声明过能力族 → 放行。
|
||
c.mu.Lock()
|
||
c.info.Features = featuresFrom(map[string]any{"tools": map[string]any{}}, c.hostFeatures())
|
||
c.mu.Unlock()
|
||
if _, err := h(context.Background(), &mcpsdk.CreateMessageRequest{}); err != nil {
|
||
t.Fatalf("已声明时 error = %v, 期望放行", err)
|
||
}
|
||
if !called {
|
||
t.Fatal("已声明时宿主回调没有被调用")
|
||
}
|
||
}
|
||
|
||
// TestClient_SamplingHandler_RefusedWhenHostNotConfigured 没配回调就不包装(返回 nil)。
|
||
func TestClient_SamplingHandler_RefusedWhenHostNotConfigured(t *testing.T) {
|
||
t.Parallel()
|
||
c := New(MCPConfig{Name: "s"})
|
||
if c.wrapSampling() != nil {
|
||
t.Fatal("未配置 sampling 回调时不应注册 SDK handler")
|
||
}
|
||
if c.wrapElicitation() != nil {
|
||
t.Fatal("未配置 elicitation 回调时不应注册 SDK handler")
|
||
}
|
||
}
|
||
|
||
// TestClient_ClientCapabilities_OnlyConfigured 客户端只宣告宿主真正具备的能力。
|
||
func TestClient_ClientCapabilities_OnlyConfigured(t *testing.T) {
|
||
t.Parallel()
|
||
bare := New(MCPConfig{Name: "s"}).clientCapabilities()
|
||
if bare.Sampling != nil || bare.Elicitation != nil || bare.RootsV2 != nil {
|
||
t.Fatalf("默认客户端能力应为空:%+v", bare)
|
||
}
|
||
full := New(MCPConfig{Name: "s"},
|
||
WithSamplingHandler(func(context.Context, *mcpsdk.CreateMessageRequest) (*mcpsdk.CreateMessageResult, error) {
|
||
return nil, nil
|
||
}),
|
||
WithElicitationHandler(func(context.Context, *mcpsdk.ElicitRequest) (*mcpsdk.ElicitResult, error) {
|
||
return nil, nil
|
||
}),
|
||
WithRoots(&mcpsdk.Root{URI: "file:///workspace"}),
|
||
).clientCapabilities()
|
||
if full.Sampling == nil || full.Elicitation == nil || full.RootsV2 == nil {
|
||
t.Fatalf("配置齐全时客户端能力不全:%+v", full)
|
||
}
|
||
}
|