Files
Cyrene_v2/internal/capability/mcp/capabilities_test.go
T
AskaEth a1083a322f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(mcp): 协议层换成官方 Model Context Protocol SDK,删自研实现(裁决 10.4.6 + AGENTS §9.5)
- 新增依赖: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)
2026-10-04 13:53:47 +08:00

120 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}