feat(mcp): 协议层换成官方 Model Context Protocol SDK,删自研实现(裁决 10.4.6 + AGENTS §9.5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增依赖: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:
AskaEth committed 2026-10-04 13:53:47 +08:00
1 parent 7b05b4bfa9
commit a1083a322f
26 files changed
+1903 -3011

No files matched your search

+132 -167
View File
@@ -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)
}