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
@@ -2,187 +2,152 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// fakeTransport is a scripted Transport for tests. It records starts, closes and
|
||||
// the methods it was asked to call so assertions can verify the client's usage.
|
||||
// It also lets tests script the initialize handshake, simulate server→client
|
||||
// notifications and control the ping result.
|
||||
type fakeTransport struct {
|
||||
startErr error
|
||||
closeErr error
|
||||
initErr error
|
||||
listErr error
|
||||
callErr error
|
||||
pingErr error
|
||||
tools []ToolInfo
|
||||
// stdioHelperEnv 标记「测试二进制以 stdio MCP server 身份运行」。
|
||||
const stdioHelperEnv = "CYRENE_MCP_STDIO_HELPER"
|
||||
|
||||
// initErrors is a per-call queue of initialize errors. When non-empty one
|
||||
// entry is consumed per initialize call; once exhausted the handshake
|
||||
// succeeds. initErr is returned for every call when initErrors is empty.
|
||||
initErrors []error
|
||||
// initVersions records the protocolVersion sent in each initialize call.
|
||||
initVersions []string
|
||||
// initResponseVersion is the protocolVersion echoed on a successful
|
||||
// initialize; default 2024-11-05.
|
||||
initResponseVersion string
|
||||
|
||||
mu sync.Mutex
|
||||
starts int
|
||||
closes int
|
||||
methods []string
|
||||
pings int
|
||||
onNotifyFn func(method string)
|
||||
activeTools []ToolInfo
|
||||
}
|
||||
|
||||
// Start records a start.
|
||||
func (f *fakeTransport) Start(_ context.Context) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.starts++
|
||||
f.activeTools = f.tools
|
||||
return f.startErr
|
||||
}
|
||||
|
||||
// Call answers the MCP methods the client issues.
|
||||
func (f *fakeTransport) Call(_ context.Context, method string, params any, result any) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.methods = append(f.methods, method)
|
||||
switch method {
|
||||
case "initialize":
|
||||
if p, ok := params.(initializeParams); ok {
|
||||
f.initVersions = append(f.initVersions, p.ProtocolVersion)
|
||||
}
|
||||
if len(f.initErrors) > 0 {
|
||||
err := f.initErrors[0]
|
||||
f.initErrors = f.initErrors[1:]
|
||||
return err
|
||||
}
|
||||
if f.initErr != nil {
|
||||
return f.initErr
|
||||
}
|
||||
if r, ok := result.(*initializeResult); ok {
|
||||
r.ServerInfo = serverInfo{Name: "fake-server", Version: "1.0"}
|
||||
r.ProtocolVersion = f.initResponseVersion
|
||||
if r.ProtocolVersion == "" {
|
||||
r.ProtocolVersion = protocolVersion20241105
|
||||
}
|
||||
f.activeTools = f.tools
|
||||
}
|
||||
case "tools/list":
|
||||
if f.listErr != nil {
|
||||
return f.listErr
|
||||
}
|
||||
if r, ok := result.(*listToolsResult); ok {
|
||||
for _, t := range f.activeTools {
|
||||
r.Tools = append(r.Tools, toolSpec{Name: t.Name, Description: t.Description, InputSchema: t.InputSchema})
|
||||
}
|
||||
}
|
||||
case "tools/call":
|
||||
if f.callErr != nil {
|
||||
return f.callErr
|
||||
}
|
||||
if r, ok := result.(*callToolResult); ok {
|
||||
r.Content = []contentBlock{{Type: "text", Text: "hello"}}
|
||||
}
|
||||
case "ping":
|
||||
f.pings++
|
||||
if f.pingErr != nil {
|
||||
return f.pingErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notify records a notification method.
|
||||
func (f *fakeTransport) notify(_ context.Context, method string, _ any) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.methods = append(f.methods, "notify:"+method)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setOnNotification records the client's server→client notification handler so
|
||||
// tests can deliver one via notifyServer.
|
||||
func (f *fakeTransport) setOnNotification(fn func(method string)) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.onNotifyFn = fn
|
||||
}
|
||||
|
||||
// notifyServer simulates a server→client notification (e.g.
|
||||
// notifications/tools/list_changed) delivered to the client's handler.
|
||||
func (f *fakeTransport) notifyServer(method string) {
|
||||
f.mu.Lock()
|
||||
fn := f.onNotifyFn
|
||||
f.mu.Unlock()
|
||||
if fn != nil {
|
||||
fn(method)
|
||||
// echoToolSpec 返回入参 name(string,默认 "world")的回显工具定义。
|
||||
func echoToolSpec() *mcpsdk.Tool {
|
||||
return &mcpsdk.Tool{
|
||||
Name: "echo",
|
||||
Description: "回显问候语",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "default": "world"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Close records a close.
|
||||
func (f *fakeTransport) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.closes++
|
||||
return f.closeErr
|
||||
// addToolSpec 返回入参 a、b(均为必填 number)的加法工具定义。
|
||||
func addToolSpec() *mcpsdk.Tool {
|
||||
return &mcpsdk.Tool{
|
||||
Name: "add",
|
||||
Description: "两数相加",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"a", "b"},
|
||||
"properties": map[string]any{
|
||||
"a": map[string]any{"type": "number"},
|
||||
"b": map[string]any{"type": "number"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// calledMethods returns a snapshot of the recorded methods.
|
||||
func (f *fakeTransport) calledMethods() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]string, len(f.methods))
|
||||
copy(out, f.methods)
|
||||
// echoHandler 按 schema 解参并回显;arguments 在 SDK 里是 json.RawMessage。
|
||||
func echoHandler(_ context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if len(req.Params.Arguments) > 0 {
|
||||
if err := json.Unmarshal(req.Params.Arguments, &in); err != nil {
|
||||
return nil, fmt.Errorf("decode echo args: %w", err)
|
||||
}
|
||||
}
|
||||
return &mcpsdk.CallToolResult{
|
||||
Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "hello " + in.Name}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addHandler 求和并同时返回文本与结构化结果。
|
||||
func addHandler(_ context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
|
||||
var in struct {
|
||||
A float64 `json:"a"`
|
||||
B float64 `json:"b"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params.Arguments, &in); err != nil {
|
||||
return nil, fmt.Errorf("decode add args: %w", err)
|
||||
}
|
||||
sum := in.A + in.B
|
||||
return &mcpsdk.CallToolResult{
|
||||
Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: fmt.Sprintf("%g", sum)}},
|
||||
StructuredContent: map[string]any{"sum": sum},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addEchoAndAdd 往 srv 挂上两把测试工具。
|
||||
func addEchoAndAdd(srv *mcpsdk.Server) {
|
||||
srv.AddTool(echoToolSpec(), echoHandler)
|
||||
srv.AddTool(addToolSpec(), addHandler)
|
||||
}
|
||||
|
||||
// newFakeServer 构建一台真实的 MCP server(官方 SDK 服务端),挂上 echo/add。
|
||||
func newFakeServer() *mcpsdk.Server {
|
||||
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "fake-mcp", Version: "1.0.0"}, nil)
|
||||
addEchoAndAdd(srv)
|
||||
return srv
|
||||
}
|
||||
|
||||
// newEmptyServer 构建一台不提供任何工具的服务器(只声明 logging 能力)。
|
||||
func newEmptyServer() *mcpsdk.Server {
|
||||
return mcpsdk.NewServer(&mcpsdk.Implementation{Name: "empty-mcp", Version: "1.0.0"}, nil)
|
||||
}
|
||||
|
||||
// startFakeServer 在 SDK 内存传输上跑 srv,返回客户端侧传输。
|
||||
func startFakeServer(t *testing.T, srv *mcpsdk.Server) mcpsdk.Transport {
|
||||
t.Helper()
|
||||
serverT, clientT := mcpsdk.NewInMemoryTransports()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { _ = srv.Run(ctx, serverT) }()
|
||||
t.Cleanup(cancel)
|
||||
return clientT
|
||||
}
|
||||
|
||||
// connectFakeClient 通过内存传输连上一台 fake server。
|
||||
func connectFakeClient(t *testing.T, srv *mcpsdk.Server, opts ...Option) *Client {
|
||||
t.Helper()
|
||||
opts = append(opts, WithTransport(startFakeServer(t, srv)))
|
||||
c := New(MCPConfig{Name: "fake"}, opts...)
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
// toolNames 取出工具名列表。
|
||||
func toolNames(tools []ToolInfo) []string {
|
||||
out := make([]string, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, t.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasMethod reports whether method was recorded.
|
||||
func (f *fakeTransport) hasMethod(method string) bool {
|
||||
for _, m := range f.calledMethods() {
|
||||
if m == method {
|
||||
return true
|
||||
// waitFor 轮询 cond 直到成立或超时。
|
||||
func waitFor(t *testing.T, timeout time.Duration, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
t.Fatalf("等待超时:%s", what)
|
||||
}
|
||||
|
||||
// initSentVersions returns the protocol versions the client requested across all
|
||||
// initialize calls.
|
||||
func (f *fakeTransport) initSentVersions() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]string, len(f.initVersions))
|
||||
copy(out, f.initVersions)
|
||||
return out
|
||||
}
|
||||
|
||||
// pingCount returns the recorded ping request count.
|
||||
func (f *fakeTransport) pingCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.pings
|
||||
}
|
||||
|
||||
// setTools replaces the tool list served on subsequent tools/list calls.
|
||||
func (f *fakeTransport) setTools(tools []ToolInfo) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.tools = tools
|
||||
f.activeTools = tools
|
||||
}
|
||||
|
||||
// unsupportedProtocolError builds a JSON-RPC error that rejects the requested
|
||||
// protocol version and advertises the supported set, as a real MCP server does.
|
||||
func unsupportedProtocolError(supported ...string) error {
|
||||
return fmt.Errorf("mcp initialize: %w: %w", ErrRPC, &rpcError{
|
||||
Code: codeUnsupportedProtocolVersion,
|
||||
Message: "unsupported protocol version",
|
||||
Data: map[string]any{"supported": supported},
|
||||
})
|
||||
// TestMCPStdioHelper 是 stdio 打桩用的辅助进程入口:以真实 MCP server 身份
|
||||
// 在 stdin/stdout 上讲协议。stdout 是协议通道,因此诊断只写 stderr,且退出时
|
||||
// 直接 os.Exit(避免测试框架往 stdout 打 PASS/ok 破坏协议帧)。
|
||||
func TestMCPStdioHelper(t *testing.T) {
|
||||
if os.Getenv(stdioHelperEnv) != "1" {
|
||||
t.Skip("仅作为 stdio 辅助进程入口")
|
||||
}
|
||||
srv := newFakeServer()
|
||||
if err := srv.Run(context.Background(), &mcpsdk.StdioTransport{}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "stdio helper server:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
Reference in new issue
Block a user