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)
This commit is contained in:
1 parent
7b05b4bfa9
commit
a1083a322f
26 files changed
+1903
-3011
No files matched your search
@@ -4,244 +4,99 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestClient_New(t *testing.T) {
|
||||
// TestClient_Connect_NegotiatesOlderProtocolVersion 协议版本不硬编码:
|
||||
// 服务器只支持旧版本时,SDK 回退协商成功,协商结果保留在 ServerInfo 里。
|
||||
func TestClient_Connect_NegotiatesOlderProtocolVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(MCPConfig{Name: "calc", Command: "mcp-calc"})
|
||||
if c.cfg.Name != "calc" {
|
||||
t.Fatalf("cfg.Name = %q, want calc", c.cfg.Name)
|
||||
}
|
||||
if c.state != stateNew {
|
||||
t.Fatalf("state = %v, want stateNew", c.state)
|
||||
}
|
||||
}
|
||||
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "old-mcp", Version: "0.9.0"}, &mcpsdk.ServerOptions{
|
||||
SupportedProtocolVersions: []string{"2024-11-05"},
|
||||
})
|
||||
addEchoAndAdd(srv)
|
||||
|
||||
func TestClient_Connect_FakeTransport(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 ft.starts != 1 {
|
||||
t.Fatalf("starts = %d, want 1", ft.starts)
|
||||
}
|
||||
if !ft.hasMethod("initialize") {
|
||||
t.Fatalf("initialize was not called; methods = %v", ft.calledMethods())
|
||||
}
|
||||
if c.state != stateConnected {
|
||||
t.Fatalf("state = %v, want stateConnected", c.state)
|
||||
}
|
||||
if c.info.Name != "fake-server" {
|
||||
t.Fatalf("info.Name = %q, want fake-server", c.info.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_Idempotent(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("first Connect: %v", err)
|
||||
}
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("second Connect: %v", err)
|
||||
}
|
||||
if ft.starts != 1 {
|
||||
t.Fatalf("starts = %d, want 1 (second connect must not restart)", ft.starts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_NoTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(MCPConfig{Name: "empty"})
|
||||
err := c.Connect(context.Background())
|
||||
if !errors.Is(err, ErrNoTransport) {
|
||||
t.Fatalf("Connect error = %v, want ErrNoTransport", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_Closed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
err := c.Connect(context.Background())
|
||||
if !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("Connect error = %v, want ErrClosed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_StartError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{startErr: errors.New("boom")}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
if err := c.Connect(context.Background()); err == nil {
|
||||
t.Fatal("Connect succeeded, want error")
|
||||
}
|
||||
if ft.closes != 1 {
|
||||
t.Fatalf("closes = %d, want 1 (failed Start must Close the transport)", ft.closes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_InitError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{initErr: errors.New("init boom")}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
if err := c.Connect(context.Background()); err == nil {
|
||||
t.Fatal("Connect succeeded, want error")
|
||||
}
|
||||
if ft.closes != 1 {
|
||||
t.Fatalf("closes = %d, want 1 (failed initialize must Close the transport)", ft.closes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ListTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{
|
||||
tools: []ToolInfo{{Name: "add", Description: "Adds", InputSchema: map[string]any{"type": "object"}}},
|
||||
}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
c := connectFakeClient(t, srv)
|
||||
if got := c.ServerInfo().ProtocolVersion; got != "2024-11-05" {
|
||||
t.Fatalf("协商后的协议版本 = %q, 期望 2024-11-05", got)
|
||||
}
|
||||
tools, err := c.ListTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools: %v", err)
|
||||
}
|
||||
if len(tools) != 1 || tools[0].Name != "add" || tools[0].Description != "Adds" {
|
||||
t.Fatalf("tools = %+v, want [add/Adds]", tools)
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("旧协议下发现 %d 把工具, 期望 2", len(tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ListTools_NotConnected(t *testing.T) {
|
||||
// TestWrapConnectErr_MapsUnsupportedVersion SDK 报的 -32022 必须映射到本包哨兵错误,
|
||||
// 其它错误原样透传(不误判)。
|
||||
func TestWrapConnectErr_MapsUnsupportedVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(MCPConfig{Name: "s", Command: "x"})
|
||||
_, err := c.ListTools(context.Background())
|
||||
if !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("ListTools error = %v, want ErrNotConnected", err)
|
||||
mapped := wrapConnectErr(&sdkjsonrpc.Error{Code: mcpsdk.CodeUnsupportedProtocolVersion, Message: "unsupported protocol version"})
|
||||
if !errors.Is(mapped, ErrUnsupportedProtocolVersion) {
|
||||
t.Fatalf("wrapConnectErr = %v, 期望 ErrUnsupportedProtocolVersion", mapped)
|
||||
}
|
||||
if other := wrapConnectErr(errors.New("boom")); errors.Is(other, ErrUnsupportedProtocolVersion) {
|
||||
t.Fatalf("无关错误被误判:%v", other)
|
||||
}
|
||||
if other := wrapConnectErr(&sdkjsonrpc.Error{Code: -32601, Message: "method not found"}); errors.Is(other, ErrUnsupportedProtocolVersion) {
|
||||
t.Fatalf("其它 JSON-RPC 错误码被误判:%v", other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallTool(t *testing.T) {
|
||||
// TestClient_Connect_IdempotentAndClosed Connect 幂等;Close 后不可再连接。
|
||||
func TestClient_Connect_IdempotentAndClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
c := connectFakeClient(t, newFakeServer())
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
res, err := c.CallTool(context.Background(), "add", map[string]any{"x": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool: %v", err)
|
||||
}
|
||||
if res.IsError {
|
||||
t.Fatal("IsError = true, want false")
|
||||
}
|
||||
if len(res.Content) != 1 || res.Content[0].Text != "hello" {
|
||||
t.Fatalf("Content = %+v, want [hello]", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallTool_BadArgs(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)
|
||||
}
|
||||
_, err := c.CallTool(context.Background(), "add", "not-a-map")
|
||||
if err == nil {
|
||||
t.Fatal("CallTool with non-object args succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallTool_NotConnected(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(MCPConfig{Name: "s", Command: "x"})
|
||||
_, err := c.CallTool(context.Background(), "add", map[string]any{})
|
||||
if !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("CallTool error = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DiscoverTools_MapsToSDK(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{
|
||||
tools: []ToolInfo{{Name: "add", Description: "Adds", InputSchema: map[string]any{"type": "object"}}},
|
||||
}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
tools, err := c.DiscoverTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(tools))
|
||||
}
|
||||
if tools[0].Name() != "add" || tools[0].Description() != "Adds" {
|
||||
t.Fatalf("tool = %q/%q, want add/Adds", tools[0].Name(), tools[0].Description())
|
||||
}
|
||||
params, err := tools[0].Parameters()
|
||||
if err != nil {
|
||||
t.Fatalf("Parameters: %v", err)
|
||||
}
|
||||
if params["type"] != "object" {
|
||||
t.Fatalf("params[type] = %v, want object", params["type"])
|
||||
}
|
||||
out, err := tools[0].Execute(context.Background(), map[string]any{"x": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if out != "hello" {
|
||||
t.Fatalf("Execute result = %v, want hello", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_DiscoverTools_NilSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{tools: []ToolInfo{{Name: "nop", InputSchema: nil}}}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
tools, err := c.DiscoverTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
params, err := tools[0].Parameters()
|
||||
if err != nil {
|
||||
t.Fatalf("Parameters: %v", err)
|
||||
}
|
||||
if params["type"] != "object" {
|
||||
t.Fatalf("params[type] = %v, want object", params["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Close_ClosesTransportAndForbidsUse(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)
|
||||
t.Fatalf("重复 Connect 应幂等返回 nil, 得到 %v", err)
|
||||
}
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if ft.closes != 1 {
|
||||
t.Fatalf("closes = %d, want 1", ft.closes)
|
||||
}
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
t.Fatalf("重复 Close 应幂等, 得到 %v", err)
|
||||
}
|
||||
if ft.closes != 1 {
|
||||
t.Fatalf("closes = %d, want 1 after idempotent Close", ft.closes)
|
||||
if err := c.Connect(context.Background()); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("Close 后 Connect error = %v, 期望 ErrClosed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_Connect_NoTransport 没有任何传输信息时报 ErrNoTransport。
|
||||
func TestClient_Connect_NoTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(MCPConfig{Name: "none"})
|
||||
if err := c.Connect(context.Background()); !errors.Is(err, ErrNoTransport) {
|
||||
t.Fatalf("Connect error = %v, 期望 ErrNoTransport", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_UseBeforeConnect 未连接时各入口都返回 ErrNotConnected,不 panic。
|
||||
func TestClient_UseBeforeConnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := New(MCPConfig{Name: "none", Command: "unused"})
|
||||
if _, err := c.ListTools(context.Background()); !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("ListTools after Close = %v, want ErrNotConnected", err)
|
||||
t.Fatalf("ListTools error = %v, 期望 ErrNotConnected", err)
|
||||
}
|
||||
if _, err := c.CallTool(context.Background(), "echo", map[string]any{}); !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("CallTool error = %v, 期望 ErrNotConnected", err)
|
||||
}
|
||||
if c.Session() != nil {
|
||||
t.Fatal("未连接时 Session() 应返回 nil")
|
||||
}
|
||||
if c.Name() != "none" {
|
||||
t.Fatalf("Name() = %q", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_CallTool_RequiresJSONObject 参数不是 JSON 对象时直接拒绝,不发请求。
|
||||
func TestClient_CallTool_RequiresJSONObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := connectFakeClient(t, newFakeServer())
|
||||
if _, err := c.CallTool(context.Background(), "echo", "not-an-object"); err == nil {
|
||||
t.Fatal("字符串参数没有被拒绝")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user