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
@@ -3,8 +3,10 @@ module git.yeij.top/AskaEth/Cyrene_v2
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -22,9 +24,14 @@ require (
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
modernc.org/libc v1.75.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
|
||||
@@ -4,6 +4,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -26,6 +32,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
|
||||
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -38,19 +46,29 @@ github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWb
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
|
||||
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// hostFeatures 表示宿主侧配置了哪些客户端能力(决定向服务器宣告什么)。
|
||||
type hostFeatures struct {
|
||||
sampling bool
|
||||
elicitation bool
|
||||
roots bool
|
||||
}
|
||||
|
||||
// hostFeatures 返回宿主侧已配置的客户端能力。
|
||||
func (c *Client) hostFeatures() hostFeatures {
|
||||
return hostFeatures{
|
||||
sampling: c.sampling != nil,
|
||||
elicitation: c.elicitation != nil,
|
||||
roots: len(c.roots) > 0,
|
||||
}
|
||||
}
|
||||
|
||||
// clientCapabilities 构造客户端能力声明:只宣告宿主真的具备的能力。
|
||||
// 显式传非 nil 的 Capabilities 会关掉 SDK 默认宣告的 roots——本包默认不宣告任何
|
||||
// 能力,避免对未声明能力的服务器暴露工作区根目录或代跑 LLM。
|
||||
func (c *Client) clientCapabilities() *mcpsdk.ClientCapabilities {
|
||||
caps := &mcpsdk.ClientCapabilities{}
|
||||
if c.sampling != nil {
|
||||
caps.Sampling = &mcpsdk.SamplingCapabilities{}
|
||||
}
|
||||
if c.elicitation != nil {
|
||||
caps.Elicitation = &mcpsdk.ElicitationCapabilities{}
|
||||
}
|
||||
if len(c.roots) > 0 {
|
||||
caps.RootsV2 = &mcpsdk.RootCapabilities{}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
// serverInfoFrom 把 SDK 会话的 initialize 结果整理成本包的 ServerInfo,
|
||||
// 其中 capabilities 原文保留、对客户端可读(design/09 §1.1)。
|
||||
func serverInfoFrom(session *mcpsdk.ClientSession, host hostFeatures) ServerInfo {
|
||||
ir := session.InitializeResult()
|
||||
if ir == nil {
|
||||
return ServerInfo{Capabilities: map[string]any{}}
|
||||
}
|
||||
info := ServerInfo{Instructions: ir.Instructions, ProtocolVersion: ir.ProtocolVersion}
|
||||
if ir.ServerInfo != nil {
|
||||
info.Name = ir.ServerInfo.Name
|
||||
info.Version = ir.ServerInfo.Version
|
||||
}
|
||||
info.Capabilities = capabilitiesMap(ir.Capabilities)
|
||||
info.Features = featuresFrom(info.Capabilities, host)
|
||||
return info
|
||||
}
|
||||
|
||||
// capabilitiesMap 把 SDK 解析出的服务器能力序列化回原文 map 保留下来。
|
||||
// 它只做无损转换,不做增删;极端情况下序列化失败则返回空 map(等价服务器未声明)。
|
||||
func capabilitiesMap(caps *mcpsdk.ServerCapabilities) map[string]any {
|
||||
if caps == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
data, err := json.Marshal(caps)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// featuresFrom 依据服务器 initialize 声明推断可用能力族(design/09 §1.1:
|
||||
// 声明了才算可用,未声明不得调用):
|
||||
//
|
||||
// - tools / prompts / resources / logging / completions 直接按服务器是否声明该字段;
|
||||
// - 老式服务器完全不发 capabilities(nil 或空)时按 legacy 处理:只认 tools/list
|
||||
// 是唯一发现入口(Tools=true),其余一律不可用;
|
||||
// - sampling / elicitation / roots 属于客户端侧能力,服务器不会声明「自身支持」它们。
|
||||
// 因此判定为「宿主配置了回调」且「服务器声明过能力族(即确实是会主动交互的对端)」
|
||||
// 两者同时成立才可用——什么都没声明的服务器不得回调这些能力。
|
||||
func featuresFrom(caps map[string]any, host hostFeatures) ServerFeatures {
|
||||
declared := len(caps) > 0
|
||||
f := ServerFeatures{
|
||||
Tools: !declared || hasCap(caps, "tools"),
|
||||
Prompts: hasCap(caps, "prompts"),
|
||||
Resources: hasCap(caps, "resources"),
|
||||
Logging: hasCap(caps, "logging"),
|
||||
Completions: hasCap(caps, "completions"),
|
||||
}
|
||||
f.Sampling = host.sampling && declared
|
||||
f.Elicitation = host.elicitation && declared
|
||||
f.Roots = host.roots && declared
|
||||
return f
|
||||
}
|
||||
|
||||
// hasCap 报告原始 capabilities 里是否声明了名为 name 的能力。
|
||||
func hasCap(caps map[string]any, name string) bool {
|
||||
v, ok := caps[name]
|
||||
return ok && v != nil
|
||||
}
|
||||
|
||||
// wrapSampling 包装宿主 sampling 回调:服务器未声明能力族时拒绝,
|
||||
// 不静默放行(design/09 §1.1)。
|
||||
func (c *Client) wrapSampling() func(context.Context, *mcpsdk.CreateMessageRequest) (*mcpsdk.CreateMessageResult, error) {
|
||||
h := c.sampling
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, req *mcpsdk.CreateMessageRequest) (*mcpsdk.CreateMessageResult, error) {
|
||||
if !c.Features().Sampling {
|
||||
return nil, fmt.Errorf("mcp sampling %q: %w", c.cfg.Name, ErrCapabilityNotDeclared)
|
||||
}
|
||||
return h(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
// wrapElicitation 包装宿主 elicitation 回调,判定同 wrapSampling。
|
||||
func (c *Client) wrapElicitation() func(context.Context, *mcpsdk.ElicitRequest) (*mcpsdk.ElicitResult, error) {
|
||||
h := c.elicitation
|
||||
if h == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, req *mcpsdk.ElicitRequest) (*mcpsdk.ElicitResult, error) {
|
||||
if !c.Features().Elicitation {
|
||||
return nil, fmt.Errorf("mcp elicitation %q: %w", c.cfg.Name, ErrCapabilityNotDeclared)
|
||||
}
|
||||
return h(ctx, req)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+130
-337
@@ -2,210 +2,155 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// initializeParams is the MCP initialize request body.
|
||||
type initializeParams struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities map[string]any `json:"capabilities"`
|
||||
ClientInfo clientInfo `json:"clientInfo"`
|
||||
}
|
||||
|
||||
// initializeResult is the MCP initialize response body.
|
||||
type initializeResult struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Capabilities map[string]any `json:"capabilities"`
|
||||
ServerInfo serverInfo `json:"serverInfo"`
|
||||
}
|
||||
|
||||
// clientInfo identifies the client to the server.
|
||||
type clientInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// serverInfo identifies the server to the client.
|
||||
type serverInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Connect selects the transport (unless one was injected), starts it, completes
|
||||
// the MCP initialize handshake, and marks the client connected. It is
|
||||
// idempotent; a second call while connected returns nil.
|
||||
// Connect 选择传输(未注入时)、建立官方 SDK 会话并完成 initialize 握手。
|
||||
// 幂等:已连接时直接返回 nil;已关闭时返回 ErrClosed。
|
||||
func (c *Client) Connect(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
switch c.state {
|
||||
case stateClosed:
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, ErrClosed)
|
||||
case stateConnected:
|
||||
c.mu.Unlock()
|
||||
same, err := c.begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if same {
|
||||
return nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
tr := c.tr
|
||||
if tr == nil {
|
||||
var err error
|
||||
if tr, err = newTransport(c.cfg, c.log); err != nil {
|
||||
if tr, err = newTransport(c.cfg); err != nil {
|
||||
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tr.Start(ctx); err != nil {
|
||||
tr.Close()
|
||||
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, err)
|
||||
}
|
||||
// Wire server→client notification routing (list_changed) before the server
|
||||
// can start sending them after notifications/initialized.
|
||||
attachNotificationSink(tr, c.handleServerNotification)
|
||||
|
||||
info, err := c.initialize(ctx, tr)
|
||||
session, err := c.newSDKClient().Connect(ctx, tr, c.sessionOptions())
|
||||
if err != nil {
|
||||
tr.Close()
|
||||
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, err)
|
||||
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
|
||||
}
|
||||
// Let the transport echo the negotiated protocol version (Streamable HTTP).
|
||||
attachProtocolProvider(tr, c.protocolVersion)
|
||||
c.sendInitialized(ctx, tr)
|
||||
|
||||
info := serverInfoFrom(session, c.hostFeatures())
|
||||
c.mu.Lock()
|
||||
c.session = session
|
||||
c.tr = tr
|
||||
c.info = info
|
||||
c.state = stateConnected
|
||||
c.info = info
|
||||
c.mu.Unlock()
|
||||
|
||||
c.startKeepalive()
|
||||
c.log.Debug("mcp connected", "name", c.cfg.Name, "server", info.Name, "protocol", c.protocolVersion())
|
||||
c.log.Debug("mcp connected", "name", c.cfg.Name, "server", info.Name,
|
||||
"version", info.Version, "protocol", info.ProtocolVersion,
|
||||
"tools_declared", info.Features.Tools)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize performs the MCP initialize handshake on an already-connected
|
||||
// client. It returns the server identity.
|
||||
func (c *Client) Initialize(ctx context.Context) (ServerInfo, error) {
|
||||
tr := c.transport()
|
||||
if tr == nil {
|
||||
return ServerInfo{}, fmt.Errorf("mcp initialize %q: %w", c.cfg.Name, ErrNotConnected)
|
||||
// Close 关闭 SDK 会话并标记客户端已关闭。幂等。
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
if c.state == stateClosed {
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
return c.initialize(ctx, tr)
|
||||
session := c.session
|
||||
c.session = nil
|
||||
c.state = stateClosed
|
||||
c.mu.Unlock()
|
||||
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil {
|
||||
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initialize performs the MCP initialize handshake with protocol version
|
||||
// negotiation (DevDocs/design/09 §1.1). It does not hard-code a single version:
|
||||
// it first requests latestProtocolVersion, and if the server rejects that version
|
||||
// (a JSON-RPC error with code codeUnsupportedProtocolVersion carrying the
|
||||
// supported set) or replies with a version this client cannot speak, it downgrades
|
||||
// once to the highest mutually supported version and retries. It records the
|
||||
// agreed version so the HTTP transport can echo it back as Mcp-Protocol-Version.
|
||||
func (c *Client) initialize(ctx context.Context, tr Transport) (ServerInfo, error) {
|
||||
sent := c.preferredProtocolVersion
|
||||
if sent == "" {
|
||||
sent = latestProtocolVersion
|
||||
// ListTools 返回服务器暴露的工具(SDK 自动翻页)。
|
||||
// 服务器在 initialize 里没有声明 tools 能力族时拒绝调用、不发出 RPC:
|
||||
// design/09 §1.1「声明了才算可用,未声明不得调用」。
|
||||
func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
|
||||
session := c.connected()
|
||||
if session == nil {
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, ErrNotConnected)
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxInitializeAttempts; attempt++ {
|
||||
params := initializeParams{
|
||||
ProtocolVersion: sent,
|
||||
Capabilities: c.capabilities(sent),
|
||||
ClientInfo: clientInfo{Name: "cyrene", Version: "0.1.0"},
|
||||
}
|
||||
var res initializeResult
|
||||
err := tr.Call(ctx, "initialize", params, &res)
|
||||
if !c.Features().Tools {
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, ErrCapabilityNotDeclared)
|
||||
}
|
||||
out := make([]ToolInfo, 0, 8)
|
||||
for tool, err := range session.Tools(ctx, nil) {
|
||||
if err != nil {
|
||||
// Server rejected the requested version and may advertise the versions
|
||||
// it supports; downgrade to the highest mutually supported one.
|
||||
if supported, ok := unsupportedVersions(err); ok {
|
||||
if v := negotiateMutuallySupportedVersion(supported); v != "" && v != sent {
|
||||
c.log.Debug("mcp downgrading protocol version",
|
||||
"name", c.cfg.Name, "requested", sent, "version", v)
|
||||
sent = v
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return ServerInfo{}, fmt.Errorf("mcp initialize %q: %w: %w", c.cfg.Name, ErrUnsupportedProtocolVersion, err)
|
||||
}
|
||||
return ServerInfo{}, err
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
|
||||
}
|
||||
negotiated := res.ProtocolVersion
|
||||
if negotiated == "" {
|
||||
negotiated = sent
|
||||
}
|
||||
if !isSupportedVersion(negotiated) {
|
||||
// The server replied with a version this client does not speak;
|
||||
// downgrade once to the highest version we both accept.
|
||||
if v := highestCommonVersion(negotiated); v != "" && v != sent {
|
||||
c.log.Debug("mcp renegotiating protocol version",
|
||||
"name", c.cfg.Name, "server", negotiated, "version", v)
|
||||
sent = v
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return ServerInfo{}, fmt.Errorf("mcp initialize %q: %w: server advertised %q", c.cfg.Name, ErrUnsupportedProtocolVersion, negotiated)
|
||||
}
|
||||
c.setProtocolVersion(negotiated)
|
||||
return ServerInfo{Name: res.ServerInfo.Name, Version: res.ServerInfo.Version}, nil
|
||||
out = append(out, toolInfoFrom(tool))
|
||||
}
|
||||
return ServerInfo{}, fmt.Errorf("mcp initialize %q: %w: %w", c.cfg.Name, ErrUnsupportedProtocolVersion, lastErr)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isMethodNotFound reports whether err wraps a JSON-RPC "method not found" error
|
||||
// (code -32601), which a peer returns when it does not implement a method such as
|
||||
// the keepalive ping.
|
||||
func isMethodNotFound(err error) bool {
|
||||
var re *rpcError
|
||||
return errors.As(err, &re) && re.Code == codeMethodNotFound
|
||||
}
|
||||
|
||||
// unsupportedVersions reports the protocol versions the server supports, extracted
|
||||
// from the data field of a codeUnsupportedProtocolVersion JSON-RPC error. It
|
||||
// reports ok=false when err does not carry such an error or the data is unusable.
|
||||
func unsupportedVersions(err error) ([]string, bool) {
|
||||
var re *rpcError
|
||||
if !errors.As(err, &re) || re.Code != codeUnsupportedProtocolVersion {
|
||||
return nil, false
|
||||
}
|
||||
if re.Data == nil {
|
||||
return nil, false
|
||||
}
|
||||
data, err := json.Marshal(re.Data)
|
||||
// CallTool 调用服务器上的工具。args 必须是 JSON 对象(map[string]any)。
|
||||
// 调用前用 schema 缓存里的 input schema 校验参数并补默认值。
|
||||
func (c *Client) CallTool(ctx context.Context, name string, args any) (ToolResult, error) {
|
||||
arguments, err := normalizeArgs(args)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
|
||||
}
|
||||
var payload struct {
|
||||
Supported []string `json:"supported"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil || len(payload.Supported) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return payload.Supported, true
|
||||
return c.callTool(ctx, name, arguments, nil)
|
||||
}
|
||||
|
||||
// sendInitialized best-effort sends the notifications/initialized advisory ack.
|
||||
func (c *Client) sendInitialized(ctx context.Context, tr Transport) {
|
||||
n, ok := tr.(notifier)
|
||||
if !ok {
|
||||
return
|
||||
// callTool 是 CallTool 的内部实现,fallback 是缓存未命中时可用的 input schema
|
||||
// (来自 tools/list 的发现结果)。
|
||||
func (c *Client) callTool(ctx context.Context, name string, args map[string]any, fallback map[string]any) (ToolResult, error) {
|
||||
session := c.connected()
|
||||
if session == nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, ErrNotConnected)
|
||||
}
|
||||
if err := n.notify(ctx, "notifications/initialized", map[string]any{}); err != nil {
|
||||
c.log.Debug("mcp initialized notification failed", "name", c.cfg.Name, "err", err)
|
||||
prepared, err := c.schemas.Prepare(c.cfg.Name, name, fallback, args)
|
||||
if err != nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
|
||||
}
|
||||
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
|
||||
if err != nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
|
||||
}
|
||||
return ToolResult{
|
||||
Content: toContentBlocks(res.Content),
|
||||
Structured: res.StructuredContent,
|
||||
IsError: res.IsError,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DiscoverTools 列出服务器工具,把每把工具包装成 sdk.Tool 并把 input schema
|
||||
// 放进缓存,使 MCP server 看起来就是一个普通工具提供者。
|
||||
func (c *Client) DiscoverTools(ctx context.Context) ([]sdk.Tool, error) {
|
||||
tools, err := c.ListTools(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]sdk.Tool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
c.cacheSchema(t)
|
||||
out = append(out, &mcpTool{client: c, info: t})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cacheSchema 把一次发现到的 input schema 解析并放进缓存;解析失败只降级为
|
||||
// 「调用时不校验」,不影响工具接入。
|
||||
func (c *Client) cacheSchema(t ToolInfo) {
|
||||
if err := c.schemas.Put(c.cfg.Name, t.Name, t.InputSchema); err != nil {
|
||||
c.log.Warn("mcp tool input schema not resolvable; validation skipped",
|
||||
"name", c.cfg.Name, "tool", t.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleServerNotification is the transport-level sink for server→client
|
||||
// notifications. It offloads the actual work to a fresh goroutine because it runs
|
||||
// on the transport read path and must not block or re-enter the same transport
|
||||
// synchronously; refreshPending coalesces bursts so at most one refresh runs at a
|
||||
// time.
|
||||
func (c *Client) handleServerNotification(method string) {
|
||||
if method != notificationToolsListChanged || c.onToolListChanged == nil {
|
||||
// handleToolListChanged 是 SDK 的 notifications/tools/list_changed 回调:
|
||||
// 先让 schema 缓存失效(design/09 §1.1),再合并触发宿主的增量刷新。
|
||||
// 它可能运行在 SDK 的读路径上,因此真正的刷新放到新 goroutine 里。
|
||||
func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedRequest) {
|
||||
c.schemas.Invalidate(c.cfg.Name)
|
||||
if c.onToolListChanged == nil {
|
||||
return
|
||||
}
|
||||
if !c.refreshPending.CompareAndSwap(false, true) {
|
||||
@@ -217,163 +162,41 @@ func (c *Client) handleServerNotification(method string) {
|
||||
}()
|
||||
}
|
||||
|
||||
// ping sends a JSON-RPC ping request and reports whether the server responded.
|
||||
// Pings are used by the keepalive loop to detect a dead connection.
|
||||
func (c *Client) ping(ctx context.Context) error {
|
||||
tr := c.transport()
|
||||
if tr == nil {
|
||||
return fmt.Errorf("mcp ping: %w", ErrNotConnected)
|
||||
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
|
||||
func (c *Client) newSDKClient() *mcpsdk.Client {
|
||||
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
|
||||
Logger: c.log,
|
||||
Capabilities: c.clientCapabilities(),
|
||||
CreateMessageHandler: c.wrapSampling(),
|
||||
ElicitationHandler: c.wrapElicitation(),
|
||||
ToolListChangedHandler: c.handleToolListChanged,
|
||||
KeepAlive: c.keepAlive,
|
||||
KeepAliveFailureThreshold: c.keepAliveFailures,
|
||||
})
|
||||
if len(c.roots) > 0 {
|
||||
client.AddRoots(c.roots...)
|
||||
}
|
||||
if err := tr.Call(ctx, "ping", map[string]any{}, nil); err != nil {
|
||||
return fmt.Errorf("mcp ping: %w", err)
|
||||
}
|
||||
return nil
|
||||
return client
|
||||
}
|
||||
|
||||
// startKeepalive launches the ping keepalive loop when configured. It closes the
|
||||
// client once keepAliveFailureThreshold consecutive pings fail, so the host can
|
||||
// rebuild the session. The loop exits when the client is closed.
|
||||
func (c *Client) startKeepalive() {
|
||||
interval := c.keepAlive
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.mu.Lock()
|
||||
c.keepaliveCancel = cancel
|
||||
c.mu.Unlock()
|
||||
threshold := c.keepAliveFailures
|
||||
if threshold < 1 {
|
||||
threshold = keepAliveFailureThreshold
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
failures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pingCtx, pingCancel := context.WithTimeout(context.Background(), interval/2)
|
||||
err := c.ping(pingCtx)
|
||||
pingCancel()
|
||||
if err != nil {
|
||||
// A peer that does not implement ping is still healthy; stop
|
||||
// keepalive rather than treating every ping as a failure.
|
||||
if isMethodNotFound(err) {
|
||||
c.log.Debug("mcp keepalive stopped; server does not support ping", "name", c.cfg.Name)
|
||||
return
|
||||
}
|
||||
failures++
|
||||
c.log.Warn("mcp keepalive ping failed", "name", c.cfg.Name, "err", err, "failures", failures)
|
||||
if failures < threshold {
|
||||
continue
|
||||
}
|
||||
c.log.Error("mcp keepalive ping failed; closing session", "name", c.cfg.Name, "failures", failures)
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
failures = 0
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ListTools returns the tools exposed by the connected MCP server.
|
||||
func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
|
||||
tr := c.transport()
|
||||
if tr == nil {
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, ErrNotConnected)
|
||||
}
|
||||
var res listToolsResult
|
||||
if err := tr.Call(ctx, "tools/list", map[string]any{}, &res); err != nil {
|
||||
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
|
||||
}
|
||||
out := make([]ToolInfo, 0, len(res.Tools))
|
||||
for _, t := range res.Tools {
|
||||
out = append(out, ToolInfo{Name: t.Name, Description: t.Description, InputSchema: t.InputSchema})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CallTool invokes a tool on the connected MCP server.
|
||||
func (c *Client) CallTool(ctx context.Context, name string, args any) (ToolResult, error) {
|
||||
tr := c.transport()
|
||||
if tr == nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, ErrNotConnected)
|
||||
}
|
||||
arguments, err := normalizeArgs(args)
|
||||
if err != nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
|
||||
}
|
||||
params := callToolParams{Name: name, Arguments: arguments}
|
||||
var res callToolResult
|
||||
if err := tr.Call(ctx, "tools/call", params, &res); err != nil {
|
||||
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
|
||||
}
|
||||
return ToolResult{Content: toContentBlocks(res.Content), IsError: res.IsError}, nil
|
||||
}
|
||||
|
||||
// DiscoverTools lists the server tools and maps each to an sdk.Tool so the MCP
|
||||
// server appears as a normal tool provider.
|
||||
func (c *Client) DiscoverTools(ctx context.Context) ([]sdk.Tool, error) {
|
||||
tools, err := c.ListTools(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]sdk.Tool, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, &mcpTool{client: c, info: t})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Close closes the transport and marks the client closed. It is idempotent. It
|
||||
// also cancels the keepalive loop so no ping goroutine outlives the client.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
if c.state == stateClosed {
|
||||
c.mu.Unlock()
|
||||
// sessionOptions 返回 SDK 会话选项;宿主固定了协议版本时才返回非 nil。
|
||||
func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
|
||||
if c.preferredProtocolVersion == "" {
|
||||
return nil
|
||||
}
|
||||
c.state = stateClosed
|
||||
tr := c.tr
|
||||
if c.keepaliveCancel != nil {
|
||||
c.keepaliveCancel()
|
||||
c.keepaliveCancel = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if tr != nil {
|
||||
return tr.Close()
|
||||
}
|
||||
return nil
|
||||
return &mcpsdk.ClientSessionOptions{ProtocolVersion: c.preferredProtocolVersion}
|
||||
}
|
||||
|
||||
// newTransport selects the transport matching cfg. An explicit TransportType
|
||||
// ("stdio", "sse" or "http") wins; otherwise the transport is inferred from the
|
||||
// fields, preserving the legacy behaviour of stdio-when-Command and SSE-when-URL.
|
||||
func newTransport(cfg MCPConfig, log *slog.Logger) (Transport, error) {
|
||||
switch cfg.TransportType {
|
||||
case "http", "streamable":
|
||||
return newHTTPTransport(cfg, log), nil
|
||||
case "sse":
|
||||
return newSSETransport(cfg, log), nil
|
||||
case "stdio":
|
||||
return newStdioTransport(cfg, log), nil
|
||||
}
|
||||
switch {
|
||||
case cfg.Command != "":
|
||||
return newStdioTransport(cfg, log), nil
|
||||
case cfg.URL != "":
|
||||
return newSSETransport(cfg, log), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("mcp: %w", ErrNoTransport)
|
||||
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
|
||||
func wrapConnectErr(err error) error {
|
||||
var wire *sdkjsonrpc.Error
|
||||
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
|
||||
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// normalizeArgs converts an agent-supplied argument value into the MCP arguments
|
||||
// object, requiring a JSON object.
|
||||
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
|
||||
func normalizeArgs(args any) (map[string]any, error) {
|
||||
if args == nil {
|
||||
return map[string]any{}, nil
|
||||
@@ -383,33 +206,3 @@ func normalizeArgs(args any) (map[string]any, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("arguments must be a JSON object, got %T", args)
|
||||
}
|
||||
|
||||
// listToolsResult is the MCP tools/list response body.
|
||||
type listToolsResult struct {
|
||||
Tools []toolSpec `json:"tools"`
|
||||
}
|
||||
|
||||
// toolSpec is a single tool in a tools/list response.
|
||||
type toolSpec struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
}
|
||||
|
||||
// callToolParams is the MCP tools/call request body.
|
||||
type callToolParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments any `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// callToolResult is the MCP tools/call response body.
|
||||
type callToolResult struct {
|
||||
Content []contentBlock `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
|
||||
// contentBlock is a wire content block in a tools/call response.
|
||||
type contentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
@@ -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("字符串参数没有被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// TestClient_DiscoverTools_InMemory 走真实 SDK 内存传输打通「连接 → 工具自动发现」。
|
||||
func TestClient_DiscoverTools_InMemory(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := connectFakeClient(t, newFakeServer())
|
||||
|
||||
info := c.ServerInfo()
|
||||
if info.Name != "fake-mcp" || info.Version != "1.0.0" {
|
||||
t.Fatalf("ServerInfo = %+v, 期望 fake-mcp/1.0.0", info)
|
||||
}
|
||||
if info.ProtocolVersion == "" {
|
||||
t.Fatal("ProtocolVersion 为空,协商结果没有保留")
|
||||
}
|
||||
if _, ok := info.Capabilities["tools"]; !ok {
|
||||
t.Fatalf("服务器声明的 capabilities 没有保留 tools 字段:%v", info.Capabilities)
|
||||
}
|
||||
if !info.Features.Tools {
|
||||
t.Fatalf("Features.Tools = false,服务器已声明 tools:%v", info.Capabilities)
|
||||
}
|
||||
|
||||
tools, err := c.ListTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListTools: %v", err)
|
||||
}
|
||||
got := toolNames(tools)
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []string{"add", "echo"}) {
|
||||
t.Fatalf("tools = %v, 期望 [add echo]", got)
|
||||
}
|
||||
|
||||
discovered, err := c.DiscoverTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
if len(discovered) != 2 {
|
||||
t.Fatalf("DiscoverTools 返回 %d 把工具,期望 2", len(discovered))
|
||||
}
|
||||
if c.SchemaCache().Len() != 2 {
|
||||
t.Fatalf("schema 缓存条目 = %d,期望 2(发现即缓存)", c.SchemaCache().Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_DiscoverTools_StdioProcess 用真实子进程(stdio 打桩)证明 stdio 传输下
|
||||
// 工具自动发现能跑通:子进程就是本测试二进制,以 MCP server 身份讲协议。
|
||||
func TestClient_DiscoverTools_StdioProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Executable: %v", err)
|
||||
}
|
||||
c := New(MCPConfig{
|
||||
Name: "stdio-fake",
|
||||
Command: self,
|
||||
Args: []string{"-test.run=^TestMCPStdioHelper$"},
|
||||
Env: map[string]string{stdioHelperEnv: "1"},
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
tools, err := c.DiscoverTools(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
got := toolNames(toToolInfos(tools))
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []string{"add", "echo"}) {
|
||||
t.Fatalf("stdio 发现的工具 = %v, 期望 [add echo]", got)
|
||||
}
|
||||
|
||||
res, err := c.CallTool(ctx, "echo", map[string]any{"name": "stdio"})
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool: %v", err)
|
||||
}
|
||||
if text := contentText(res.Content); text != "hello stdio" {
|
||||
t.Fatalf("工具结果 = %q, 期望 hello stdio", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_DiscoverTools_StreamableHTTP 用 httptest 打桩 Streamable HTTP 端点,
|
||||
// 验证 URL 型传输 + bearer 鉴权 + 工具自动发现整条链。
|
||||
func TestClient_DiscoverTools_StreamableHTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newFakeServer()
|
||||
handler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return srv }, nil)
|
||||
var authorized atomic.Bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer sk-test" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
authorized.Store(true)
|
||||
handler.ServeHTTP(w, r)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := New(MCPConfig{Name: "http-fake", URL: ts.URL, TransportType: "http", APIKey: "sk-test"})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := c.Connect(ctx); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
tools, err := c.DiscoverTools(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("Streamable HTTP 发现 %d 把工具,期望 2", len(tools))
|
||||
}
|
||||
if !authorized.Load() {
|
||||
t.Fatal("服务器没有收到带 bearer 凭据的请求")
|
||||
}
|
||||
res, err := c.CallTool(ctx, "add", map[string]any{"a": 1, "b": 2})
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool: %v", err)
|
||||
}
|
||||
if text := contentText(res.Content); text != "3" {
|
||||
t.Fatalf("add 结果 = %q, 期望 3", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_ListTools_RefusesUndeclaredCapability 证明「未声明不得调用」:
|
||||
// 服务器只声明 logging,客户端拒绝发 tools/list。
|
||||
func TestClient_ListTools_RefusesUndeclaredCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := connectFakeClient(t, newEmptyServer())
|
||||
if c.Features().Tools {
|
||||
t.Fatalf("空服务器不应推断出 tools 能力:%v", c.ServerInfo().Capabilities)
|
||||
}
|
||||
_, err := c.ListTools(context.Background())
|
||||
if !errors.Is(err, ErrCapabilityNotDeclared) {
|
||||
t.Fatalf("ListTools error = %v, 期望 ErrCapabilityNotDeclared", err)
|
||||
}
|
||||
if _, err := c.DiscoverTools(context.Background()); !errors.Is(err, ErrCapabilityNotDeclared) {
|
||||
t.Fatalf("DiscoverTools error = %v, 期望 ErrCapabilityNotDeclared", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Register_AutoRegisters 打通「自动发现 → 注册进 capability registry
|
||||
// → 模型可调用」这条链(用 registry 里存的工具值真的发起一次调用)。
|
||||
func TestRegistry_Register_AutoRegisters(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
tr := startFakeServer(t, newFakeServer())
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc"}, WithTransport(tr))
|
||||
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
names := r.ToolNames()
|
||||
slices.Sort(names)
|
||||
if !slices.Equal(names, []string{"add", "echo"}) {
|
||||
t.Fatalf("ToolNames = %v, 期望 [add echo]", names)
|
||||
}
|
||||
|
||||
cap, err := reg.Get("echo")
|
||||
if err != nil {
|
||||
t.Fatalf("registry.Get(echo): %v", err)
|
||||
}
|
||||
if cap.Kind != registry.KindTool {
|
||||
t.Fatalf("echo 的 Kind = %q, 期望 %q", cap.Kind, registry.KindTool)
|
||||
}
|
||||
tool, ok := cap.Value.(sdk.Tool)
|
||||
if !ok {
|
||||
t.Fatalf("echo 的 Value 类型 = %T, 期望 sdk.Tool", cap.Value)
|
||||
}
|
||||
|
||||
// 不给参数调用:默认值 world 由 input schema 补上(证明 schema 校验/默认值生效)。
|
||||
out, err := tool.Execute(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(echo): %v", err)
|
||||
}
|
||||
if out != "hello world" {
|
||||
t.Fatalf("echo 结果 = %v, 期望 hello world(默认值未生效?)", out)
|
||||
}
|
||||
|
||||
// 必填参数缺失或类型错误时必须被 input schema 拦下,不把坏参数发给服务器。
|
||||
addCap, err := reg.Get("add")
|
||||
if err != nil {
|
||||
t.Fatalf("registry.Get(add): %v", err)
|
||||
}
|
||||
addTool := addCap.Value.(sdk.Tool)
|
||||
if _, err := addTool.Execute(context.Background(), map[string]any{"a": "not-a-number"}); err == nil {
|
||||
t.Fatal("非法参数没有被 input schema 拦下")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Register_PropagatesConnectError 连接失败要冒泡,不能静默注册空工具集。
|
||||
func TestRegistry_Register_PropagatesConnectError(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
r := NewRegistry(reg, MCPConfig{Name: "broken", Command: "definitely-not-a-real-binary-xyz"})
|
||||
if err := r.Register(context.Background()); err == nil {
|
||||
t.Fatal("Register 成功,期望连接错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Register_NilRegistry 空 registry 必须报错。
|
||||
func TestRegistry_Register_NilRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRegistry(nil, MCPConfig{Name: "calc"})
|
||||
if err := r.Register(context.Background()); err == nil {
|
||||
t.Fatal("Register 成功,期望 nil registry 错误")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_Unregister_RemovesTools 卸载要真从 registry 移除并关会话,且幂等。
|
||||
func TestRegistry_Unregister_RemovesTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
reg := registry.New()
|
||||
tr := startFakeServer(t, newFakeServer())
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc"}, WithTransport(tr))
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
if err := r.Unregister(context.Background()); err != nil {
|
||||
t.Fatalf("Unregister: %v", err)
|
||||
}
|
||||
if err := r.Unregister(context.Background()); err != nil {
|
||||
t.Fatalf("第二次 Unregister: %v", err)
|
||||
}
|
||||
for _, name := range []string{"add", "echo"} {
|
||||
if _, err := reg.Get(name); !errors.Is(err, registry.ErrNotFound) {
|
||||
t.Fatalf("Get(%q) 卸载后 = %v, 期望 ErrNotFound", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistry_ToolListChangedRefreshes 服务器运行中加工具 → 通知 → registry 增量刷新,
|
||||
// 同时 schema 缓存按服务器整表失效(design/09 §1.1)。
|
||||
func TestRegistry_ToolListChangedRefreshes(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newFakeServer()
|
||||
reg := registry.New()
|
||||
tr := startFakeServer(t, srv)
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc"}, WithTransport(tr))
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
_, _ = reg.Get("echo") // 让缓存先有命中记录,便于确认失效计数
|
||||
srv.AddTool(&mcpsdk.Tool{
|
||||
Name: "mul",
|
||||
Description: "两数相乘",
|
||||
InputSchema: addToolSpec().InputSchema,
|
||||
}, addHandler)
|
||||
|
||||
waitFor(t, 5*time.Second, "registry 出现新工具 mul", func() bool {
|
||||
_, err := reg.Get("mul")
|
||||
return err == nil
|
||||
})
|
||||
if _, err := reg.Get("echo"); err != nil {
|
||||
t.Fatalf("刷新后 echo 丢失:%v", err)
|
||||
}
|
||||
if got := r.ToolNames(); !slices.Contains(got, "mul") {
|
||||
t.Fatalf("ToolNames = %v, 期望含 mul", got)
|
||||
}
|
||||
}
|
||||
|
||||
// toToolInfos 把 sdk.Tool 列表还原成 ToolInfo(仅测试断言工具名用)。
|
||||
func toToolInfos(tools []sdk.Tool) []ToolInfo {
|
||||
out := make([]ToolInfo, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
info := ToolInfo{Name: t.Name(), Description: t.Description()}
|
||||
if t2, ok := t.(*mcpTool); ok {
|
||||
info = t2.info
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClient_Connect_NegotiatesDowngradeOnUnsupportedError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{
|
||||
initErrors: []error{unsupportedProtocolError("2024-11-05")},
|
||||
initResponseVersion: protocolVersion20241105,
|
||||
}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
got := ft.initSentVersions()
|
||||
if !reflect.DeepEqual(got, []string{"2025-11-25", "2024-11-05"}) {
|
||||
t.Fatalf("init versions = %v, want [2025-11-25 2024-11-05]", got)
|
||||
}
|
||||
if c.protocolVersion() != protocolVersion20241105 {
|
||||
t.Fatalf("protocolVersion = %q, want %q", c.protocolVersion(), protocolVersion20241105)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_AcceptsServerSupportedVersion(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 got := ft.initSentVersions(); !reflect.DeepEqual(got, []string{"2025-11-25"}) {
|
||||
t.Fatalf("init versions = %v, want [2025-11-25]", got)
|
||||
}
|
||||
// The fake echoes the legacy version; the client must accept it as supported.
|
||||
if c.protocolVersion() != protocolVersion20241105 {
|
||||
t.Fatalf("protocolVersion = %q, want %q", c.protocolVersion(), protocolVersion20241105)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_NoMutualVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
// The server only supports a version the client cannot speak, so negotiation
|
||||
// must exhaust and surface ErrUnsupportedProtocolVersion.
|
||||
ft := &fakeTransport{initErrors: []error{unsupportedProtocolError("2000-01-01")}}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
err := c.Connect(context.Background())
|
||||
if !errors.Is(err, ErrUnsupportedProtocolVersion) {
|
||||
t.Fatalf("Connect error = %v, want ErrUnsupportedProtocolVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Connect_ServerReturnsUnsupportedVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A server that echoes a version this client does not support (and cannot
|
||||
// downgrade to) must fail the handshake with ErrUnsupportedProtocolVersion.
|
||||
ft := &fakeTransport{initResponseVersion: "2099-01-01"}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft))
|
||||
err := c.Connect(context.Background())
|
||||
if !errors.Is(err, ErrUnsupportedProtocolVersion) {
|
||||
t.Fatalf("Connect error = %v, want ErrUnsupportedProtocolVersion", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ToolListChangedInvokesHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
called := make(chan struct{}, 1)
|
||||
ft := &fakeTransport{tools: []ToolInfo{{Name: "add", InputSchema: map[string]any{}}}}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"},
|
||||
WithTransport(ft),
|
||||
WithToolListChangedHandler(func() { called <- struct{}{} }))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
// No handler invoked for unrelated notifications.
|
||||
ft.notifyServer("notifications/prompts/list_changed")
|
||||
select {
|
||||
case <-called:
|
||||
t.Fatal("handler invoked for a non-tool notification")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
// The real notification must invoke the handler.
|
||||
ft.notifyServer(notificationToolsListChanged)
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tool list changed handler was not invoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_KeepAlive_SendsPings(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft), WithKeepAlive(20*time.Millisecond, 3))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
waitFor(t, time.Second, func() bool { return ft.pingCount() >= 1 })
|
||||
}
|
||||
|
||||
func TestClient_KeepAlive_FailureClosesSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{pingErr: errors.New("dead peer")}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft), WithKeepAlive(20*time.Millisecond, 1))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
// A single failed ping (threshold 1) must close the client/transport.
|
||||
waitFor(t, time.Second, func() bool { return c.transport() == nil })
|
||||
waitFor(t, time.Second, func() bool {
|
||||
ft.mu.Lock()
|
||||
defer ft.mu.Unlock()
|
||||
return ft.closes >= 1
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_KeepAlive_StopsOnUnsupportedPing(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{pingErr: fmt.Errorf("mcp ping: %w: %w", ErrRPC, &rpcError{Code: codeMethodNotFound, Message: "method not found"})}
|
||||
c := New(MCPConfig{Name: "s", Command: "x"}, WithTransport(ft), WithKeepAlive(20*time.Millisecond, 1))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
// A method-not-found ping must stop keepalive without closing the session.
|
||||
waitFor(t, time.Second, func() bool { return ft.pingCount() >= 1 })
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
if c.transport() == nil {
|
||||
t.Fatal("client was closed after an unsupported ping, want it to stay connected")
|
||||
}
|
||||
if ft.pingCount() > 1 {
|
||||
t.Fatalf("keepalive kept pinging after method-not-found; pings = %d", ft.pingCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTransport_SelectsTransportType(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg MCPConfig
|
||||
want any
|
||||
}{
|
||||
{name: "http", cfg: MCPConfig{URL: "http://x", TransportType: "http"}, want: &streamableHTTPTransport{}},
|
||||
{name: "streamable-alias", cfg: MCPConfig{URL: "http://x", TransportType: "streamable"}, want: &streamableHTTPTransport{}},
|
||||
{name: "sse", cfg: MCPConfig{URL: "http://x", TransportType: "sse"}, want: &sseTransport{}},
|
||||
{name: "stdio", cfg: MCPConfig{Command: "x", TransportType: "stdio"}, want: &stdioTransport{}},
|
||||
{name: "infer-stdio", cfg: MCPConfig{Command: "x"}, want: &stdioTransport{}},
|
||||
{name: "infer-sse", cfg: MCPConfig{URL: "http://x"}, want: &sseTransport{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr, err := newTransport(tt.cfg, defaultLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("newTransport: %v", err)
|
||||
}
|
||||
if reflect.TypeOf(tr) != reflect.TypeOf(tt.want) {
|
||||
t.Fatalf("transport type = %T, want %T", tr, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTransport_ExplicitTypeSelectsEvenWithoutField(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Selecting an explicit transport does not validate the field; Start does.
|
||||
if tr, err := newTransport(MCPConfig{TransportType: "http"}, defaultLogger()); err != nil {
|
||||
t.Fatalf("newTransport(http): %v", err)
|
||||
} else if _, ok := tr.(*streamableHTTPTransport); !ok {
|
||||
t.Fatalf("transport type = %T, want *streamableHTTPTransport", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// waitFor polls cond until it returns true or the deadline passes, failing the
|
||||
// test on timeout.
|
||||
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition not met before timeout")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mcpSessionIDHeader carries the Streamable HTTP session identifier returned by a
|
||||
// server that maintains a session (MCP 2025-11-25 Streamable HTTP).
|
||||
const mcpSessionIDHeader = "Mcp-Session-Id"
|
||||
|
||||
// maxHTTPResponse bounds how much of a single POST reply body is read before the
|
||||
// response is considered non-matching.
|
||||
const maxHTTPResponse = 1 << 20
|
||||
|
||||
// streamableHTTPTransport speaks JSON-RPC 2.0 to an MCP server over Streamable
|
||||
// HTTP (MCP 2025-11-25). Client→server messages are POSTed to the endpoint, and
|
||||
// the server answers either with a single application/json body or an SSE stream.
|
||||
// Server-initiated notifications (e.g. notifications/tools/list_changed) are
|
||||
// delivered over a standalone GET SSE stream opened at Start.
|
||||
type streamableHTTPTransport struct {
|
||||
cfg MCPConfig
|
||||
log *slog.Logger
|
||||
url string
|
||||
http *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
started bool
|
||||
session string
|
||||
protocol func() string
|
||||
|
||||
onNotification func(method string)
|
||||
|
||||
closeCh chan struct{}
|
||||
closeOnce sync.Once
|
||||
getCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// newHTTPTransport builds a Streamable HTTP transport for cfg.
|
||||
func newHTTPTransport(cfg MCPConfig, log *slog.Logger) *streamableHTTPTransport {
|
||||
return &streamableHTTPTransport{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
url: cfg.URL,
|
||||
http: http.DefaultClient,
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start validates the endpoint and opens the standalone GET SSE stream for
|
||||
// server-initiated messages. The GET stream is best-effort: a server that only
|
||||
// answers via POST replies 4xx, which we tolerate because request/response still
|
||||
// works over POST.
|
||||
func (s *streamableHTTPTransport) Start(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("mcp http start: %w", err)
|
||||
}
|
||||
if s.cfg.URL == "" {
|
||||
return fmt.Errorf("mcp http: %w", ErrNoTransport)
|
||||
}
|
||||
if !strings.HasPrefix(s.cfg.URL, "http://") && !strings.HasPrefix(s.cfg.URL, "https://") {
|
||||
return fmt.Errorf("mcp http: invalid url %q", s.cfg.URL)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.started = true
|
||||
s.mu.Unlock()
|
||||
|
||||
getCtx, cancel := context.WithCancel(context.Background())
|
||||
s.mu.Lock()
|
||||
s.getCancel = cancel
|
||||
s.mu.Unlock()
|
||||
go s.readStandalone(getCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setOnNotification records the handler invoked when the server sends a
|
||||
// server-initiated notification over the standalone SSE stream.
|
||||
func (s *streamableHTTPTransport) setOnNotification(fn func(method string)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.onNotification = fn
|
||||
}
|
||||
|
||||
// setProtocolProvider records the getter for the negotiated MCP protocol version,
|
||||
// echoing it back in the Mcp-Protocol-Version header on every request.
|
||||
func (s *streamableHTTPTransport) setProtocolProvider(fn func() string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.protocol = fn
|
||||
}
|
||||
|
||||
// Call POSTs a request and decodes the matching response, from either a JSON body
|
||||
// or an SSE stream returned by the server.
|
||||
func (s *streamableHTTPTransport) Call(ctx context.Context, method string, params any, result any) error {
|
||||
s.mu.Lock()
|
||||
if !s.started {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("mcp http %s: %w", method, ErrNotConnected)
|
||||
}
|
||||
s.nextID++
|
||||
id := strconv.Itoa(s.nextID)
|
||||
s.mu.Unlock()
|
||||
|
||||
reqBody, err := encodeRequest(id, method, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp http %s: %w", method, err)
|
||||
}
|
||||
resp, err := s.post(ctx, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp http %s: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
s.captureSession(resp)
|
||||
// Close the response body if the caller cancels, so a blocked streamed read
|
||||
// unblocks rather than leaking a goroutine.
|
||||
stopClose := context.AfterFunc(ctx, func() { _ = resp.Body.Close() })
|
||||
defer stopClose()
|
||||
|
||||
return s.decodeResponse(resp, id, method, result)
|
||||
}
|
||||
|
||||
// post sends a JSON-RPC request body to the endpoint with the streamable HTTP
|
||||
// headers and returns the response.
|
||||
func (s *streamableHTTPTransport) post(ctx context.Context, body []byte) (*http.Response, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.applyHeaders(req)
|
||||
return s.http.Do(req)
|
||||
}
|
||||
|
||||
// applyHeaders sets the Streamable HTTP request headers.
|
||||
func (s *streamableHTTPTransport) applyHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
s.applyAuth(req)
|
||||
s.mu.Lock()
|
||||
session := s.session
|
||||
proto := ""
|
||||
if s.protocol != nil {
|
||||
proto = s.protocol()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if session != "" {
|
||||
req.Header.Set(mcpSessionIDHeader, session)
|
||||
}
|
||||
if proto != "" {
|
||||
req.Header.Set("Mcp-Protocol-Version", proto)
|
||||
}
|
||||
}
|
||||
|
||||
// applyAuth sets the bearer credential an authenticated server requires. It is
|
||||
// a no-op when the config carries no API key.
|
||||
func (s *streamableHTTPTransport) applyAuth(req *http.Request) {
|
||||
if s.cfg.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// captureSession stores a session id returned by the server.
|
||||
func (s *streamableHTTPTransport) captureSession(resp *http.Response) {
|
||||
if sid := resp.Header.Get(mcpSessionIDHeader); sid != "" {
|
||||
s.mu.Lock()
|
||||
s.session = sid
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// decodeResponse reads a POST response and decodes the reply for id.
|
||||
func (s *streamableHTTPTransport) decodeResponse(resp *http.Response, id, method string, result any) error {
|
||||
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
|
||||
return s.readEventStream(resp.Body, resp, id, method, result)
|
||||
}
|
||||
// A single JSON body.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxHTTPResponse))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp http %s read: %w", method, err)
|
||||
}
|
||||
var rr rpcResponse
|
||||
if err := json.Unmarshal(bytes.TrimSpace(body), &rr); err != nil {
|
||||
return fmt.Errorf("mcp http %s decode: %w", method, err)
|
||||
}
|
||||
if !decodeIDString(rr, id) {
|
||||
return fmt.Errorf("mcp http %s: %w", method, ErrClosed)
|
||||
}
|
||||
return s.applyResponse(rr, method, result)
|
||||
}
|
||||
|
||||
// readEventStream parses an SSE stream and stops once the response matching id is
|
||||
// received. Notifications (no id) are routed to the handler.
|
||||
func (s *streamableHTTPTransport) readEventStream(r io.Reader, resp *http.Response, id, method string, result any) error {
|
||||
br := bufio.NewReader(r)
|
||||
var event, data string
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
if ctxErr := s.cancelErr(resp); ctxErr != nil && ctxErr != context.Canceled {
|
||||
return fmt.Errorf("mcp http %s: %w", method, ctxErr)
|
||||
}
|
||||
return fmt.Errorf("mcp http %s: %w", method, ErrClosed)
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch {
|
||||
case line == "":
|
||||
if data != "" {
|
||||
stop, herr := s.handleEvent(event, data, id, method, result)
|
||||
if herr != nil {
|
||||
return herr
|
||||
}
|
||||
if stop {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
event, data = "", ""
|
||||
case strings.HasPrefix(line, "event:"):
|
||||
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
d := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" {
|
||||
data = d
|
||||
} else {
|
||||
data += "\n" + d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleEvent dispatches a single SSE data payload. It returns true when the
|
||||
// event is the response being awaited, so the reader can stop.
|
||||
func (s *streamableHTTPTransport) handleEvent(event, data, id, method string, result any) (bool, error) {
|
||||
if data == "" {
|
||||
return false, nil
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
if resp.ID == nil {
|
||||
s.routeNotification(resp.Method)
|
||||
return false, nil
|
||||
}
|
||||
if decodeIDString(resp, id) {
|
||||
if err := s.applyResponse(resp, method, result); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// routeNotification hands a server-initiated notification to the client handler.
|
||||
func (s *streamableHTTPTransport) routeNotification(method string) {
|
||||
if method == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
fn := s.onNotification
|
||||
s.mu.Unlock()
|
||||
if fn != nil {
|
||||
fn(method)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelErr reports whether the response's request context was cancelled.
|
||||
func (s *streamableHTTPTransport) cancelErr(resp *http.Response) error {
|
||||
if resp.Request != nil && resp.Request.Context().Err() != nil {
|
||||
return resp.Request.Context().Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyResponse decodes a JSON-RPC response into result, surfacing an error
|
||||
// object if present.
|
||||
func (s *streamableHTTPTransport) applyResponse(resp rpcResponse, method string, result any) error {
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("mcp http %s: %w: %w", method, ErrRPC, resp.Error)
|
||||
}
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(resp.Result, result); err != nil {
|
||||
return fmt.Errorf("mcp http %s decode: %w", method, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notify POSTs a fire-and-forget JSON-RPC notification (no id). The server
|
||||
// typically answers with 202 Accepted and no body.
|
||||
func (s *streamableHTTPTransport) notify(ctx context.Context, method string, params any) error {
|
||||
s.mu.Lock()
|
||||
if !s.started {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("mcp notify %s: %w", method, ErrNotConnected)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
reqBody, err := encodeRequest(nil, method, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp notify %s: %w", method, err)
|
||||
}
|
||||
resp, err := s.post(ctx, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp notify %s: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
s.captureSession(resp)
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// readStandalone reads the standalone GET SSE stream for server-initiated
|
||||
// messages until the connection is closed or the stream terminates.
|
||||
func (s *streamableHTTPTransport) readStandalone(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
s.applyAuth(req)
|
||||
s.mu.Lock()
|
||||
session := s.session
|
||||
proto := ""
|
||||
if s.protocol != nil {
|
||||
proto = s.protocol()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if session != "" {
|
||||
req.Header.Set(mcpSessionIDHeader, session)
|
||||
}
|
||||
if proto != "" {
|
||||
req.Header.Set("Mcp-Protocol-Version", proto)
|
||||
}
|
||||
resp, err := s.http.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.log.Debug("mcp http get stream error", "name", s.cfg.Name, "err", err)
|
||||
s.sleepBackoff(ctx)
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close()
|
||||
s.log.Debug("mcp http get stream not accepted", "name", s.cfg.Name, "status", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
s.readStandaloneEvents(ctx, resp)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.sleepBackoff(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// readStandaloneEvents consumes one GET stream, routing notifications until it
|
||||
// ends or the connection is closed.
|
||||
func (s *streamableHTTPTransport) readStandaloneEvents(ctx context.Context, resp *http.Response) {
|
||||
stopClose := context.AfterFunc(ctx, func() { _ = resp.Body.Close() })
|
||||
defer stopClose()
|
||||
br := bufio.NewReader(resp.Body)
|
||||
var event, data string
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch {
|
||||
case line == "":
|
||||
if data != "" {
|
||||
s.handleEvent(event, data, "", "", nil)
|
||||
}
|
||||
event, data = "", ""
|
||||
case strings.HasPrefix(line, "event:"):
|
||||
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
d := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" {
|
||||
data = d
|
||||
} else {
|
||||
data += "\n" + d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sleepBackoff waits between standalone stream reconnect attempts, exiting early
|
||||
// when the connection is closed.
|
||||
func (s *streamableHTTPTransport) sleepBackoff(ctx context.Context) {
|
||||
select {
|
||||
case <-time.After(time.Second):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the standalone stream and marks the transport closed. It is
|
||||
// idempotent.
|
||||
func (s *streamableHTTPTransport) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.closeCh)
|
||||
s.mu.Lock()
|
||||
if s.getCancel != nil {
|
||||
s.getCancel()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// httpTestServer is a Streamable HTTP test server. Its GET handler keeps an SSE
|
||||
// stream open until the request context is done (so it does not leak), while POST
|
||||
// handling is supplied by postFn.
|
||||
func httpTestServer(t *testing.T, postFn func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
postFn(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
// httpJSONResponse writes a single JSON-RPC response echo back, using the request
|
||||
// id so the client matches it.
|
||||
func httpJSONResponse(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req rpcRequest
|
||||
_ = json.Unmarshal(body, &req)
|
||||
resp := `{"jsonrpc":"2.0","id":` + idJSON(req.ID) + `,"result":{"content":[{"type":"text","text":"direct"}]}}`
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(resp))
|
||||
}
|
||||
|
||||
func TestHTTP_Call_DirectJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httpTestServer(t, httpJSONResponse)
|
||||
defer srv.Close()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
defer tr.Close()
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
var res callToolResult
|
||||
if err := tr.Call(context.Background(), "tools/call", callToolParams{Name: "add"}, &res); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if len(res.Content) != 1 || res.Content[0].Text != "direct" {
|
||||
t.Fatalf("Content = %+v, want [direct]", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Call_EventStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httpTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req rpcRequest
|
||||
_ = json.Unmarshal(body, &req)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
f, _ := w.(http.Flusher)
|
||||
msg := `{"jsonrpc":"2.0","id":` + idJSON(req.ID) + `,"result":{"content":[{"type":"text","text":"stream"}]}}`
|
||||
_, _ = w.Write([]byte("event: message\ndata: " + msg + "\n\n"))
|
||||
f.Flush()
|
||||
})
|
||||
defer srv.Close()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
defer tr.Close()
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
var res callToolResult
|
||||
if err := tr.Call(context.Background(), "tools/call", callToolParams{Name: "add"}, &res); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if len(res.Content) != 1 || res.Content[0].Text != "stream" {
|
||||
t.Fatalf("Content = %+v, want [stream]", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_ServerNotification(t *testing.T) {
|
||||
t.Parallel()
|
||||
notif := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
f, _ := w.(http.Flusher)
|
||||
_, _ = w.Write([]byte("data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}\n\n"))
|
||||
f.Flush()
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
tr.setOnNotification(func(m string) { notif <- m })
|
||||
defer tr.Close()
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
select {
|
||||
case m := <-notif:
|
||||
if m != notificationToolsListChanged {
|
||||
t.Fatalf("notification = %q, want %q", m, notificationToolsListChanged)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no server notification received")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_SendsProtocolVersionHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
if v := r.Header.Get("Mcp-Protocol-Version"); v != "" {
|
||||
select {
|
||||
case got <- v:
|
||||
default:
|
||||
}
|
||||
}
|
||||
httpJSONResponse(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
tr.setProtocolProvider(func() string { return "2025-11-25" })
|
||||
defer tr.Close()
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := tr.Call(context.Background(), "tools/list", map[string]any{}, &listToolsResult{}); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
select {
|
||||
case v := <-got:
|
||||
if v != "2025-11-25" {
|
||||
t.Fatalf("protocol header = %q, want 2025-11-25", v)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Mcp-Protocol-Version header not sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Call_NotStarted(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s", URL: "http://example.invalid"}, defaultLogger())
|
||||
err := tr.Call(context.Background(), "tools/list", nil, nil)
|
||||
if !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("Call error = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Start_NoURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s"}, defaultLogger())
|
||||
if err := tr.Start(context.Background()); !errors.Is(err, ErrNoTransport) {
|
||||
t.Fatalf("Start error = %v, want ErrNoTransport", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Start_InvalidURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s", URL: "ftp://x"}, defaultLogger())
|
||||
if err := tr.Start(context.Background()); err == nil {
|
||||
t.Fatal("Start accepted a non-http url, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTP_Close_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s", URL: "http://example.invalid"}, defaultLogger())
|
||||
if err := tr.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := tr.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// TestClient_KeepAlive_SendsPings keepalive 接线:配了间隔后 ping 真的上了线路。
|
||||
// ping 本身由 SDK 负责,这里只验我们把配置传导到了 SDK 会话上。
|
||||
func TestClient_KeepAlive_SendsPings(t *testing.T) {
|
||||
t.Parallel()
|
||||
sn := &sniffTransport{inner: startFakeServer(t, newFakeServer())}
|
||||
c := New(MCPConfig{Name: "fake"},
|
||||
WithTransport(sn),
|
||||
// 固定到 keepalive 生效的旧协议版本,避免新版协议的订阅路径干扰。
|
||||
WithProtocolVersion("2025-11-25"),
|
||||
WithKeepAlive(20*time.Millisecond, 1))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if got := c.ServerInfo().ProtocolVersion; got != "2025-11-25" {
|
||||
t.Fatalf("协议版本 = %q, 期望 2025-11-25", got)
|
||||
}
|
||||
waitFor(t, 5*time.Second, "线路上出现 keepalive ping", func() bool { return sn.called("ping") })
|
||||
}
|
||||
|
||||
// TestClient_NoKeepAlive_NoPings 不配 keepalive 时不发 ping(反证:ping 不是 SDK 默认行为)。
|
||||
func TestClient_NoKeepAlive_NoPings(t *testing.T) {
|
||||
t.Parallel()
|
||||
sn := &sniffTransport{inner: startFakeServer(t, newFakeServer())}
|
||||
c := New(MCPConfig{Name: "fake"}, WithTransport(sn), WithProtocolVersion("2025-11-25"))
|
||||
if err := c.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if _, err := c.DiscoverTools(context.Background()); err != nil {
|
||||
t.Fatalf("DiscoverTools: %v", err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if sn.called("ping") {
|
||||
t.Fatal("没配 keepalive 却发了 ping")
|
||||
}
|
||||
if !sn.called("tools/list") {
|
||||
t.Fatalf("线路上没有 tools/list, 已见方法 = %v", sn.methods())
|
||||
}
|
||||
}
|
||||
|
||||
// sniffTransport 包一层 SDK 传输,记录客户端发出的 JSON-RPC 请求方法,
|
||||
// 用于在线路层断言客户端确实发了某个请求。
|
||||
type sniffTransport struct {
|
||||
inner mcpsdk.Transport
|
||||
mu sync.Mutex
|
||||
seen []string
|
||||
}
|
||||
|
||||
// Connect 实现 mcpsdk.Transport。
|
||||
func (t *sniffTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
|
||||
conn, err := t.inner.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sniffConn{Connection: conn, owner: t}, nil
|
||||
}
|
||||
|
||||
// called 报告某个方法是否出现过。
|
||||
func (t *sniffTransport) called(method string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, m := range t.seen {
|
||||
if m == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// methods 返回已见方法快照。
|
||||
func (t *sniffTransport) methods() []string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return append([]string(nil), t.seen...)
|
||||
}
|
||||
|
||||
// record 记录一个发出的方法名。
|
||||
func (t *sniffTransport) record(method string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.seen = append(t.seen, method)
|
||||
}
|
||||
|
||||
// sniffConn 拦截写入的请求消息,记录方法名后交给内层连接。
|
||||
type sniffConn struct {
|
||||
mcpsdk.Connection
|
||||
owner *sniffTransport
|
||||
}
|
||||
|
||||
// Write 记录请求方法后转发。
|
||||
func (c *sniffConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
|
||||
if req, ok := msg.(*sdkjsonrpc.Request); ok {
|
||||
c.owner.record(req.Method)
|
||||
}
|
||||
return c.Connection.Write(ctx, msg)
|
||||
}
|
||||
+191
-276
@@ -1,264 +1,172 @@
|
||||
// Package mcp provides a minimal Model Context Protocol (MCP) client and the
|
||||
// auto-registration glue that surfaces MCP server tools as capabilities.
|
||||
// Package mcp 用官方 Model Context Protocol Go SDK 接入外部 MCP server,
|
||||
// 并把服务器暴露的工具自动注册进 capability registry。
|
||||
//
|
||||
// An MCP server is an external tool provider. This package speaks JSON-RPC 2.0
|
||||
// to it over stdio (a spawned subprocess), SSE or Streamable HTTP, discovers the
|
||||
// tools it exposes via tools/list, maps each one to a pkg/plugins/sdk Tool, and
|
||||
// registers them into the capability registry so any character can invoke them
|
||||
// (DevDocs/design/09 插件生态 §1).
|
||||
// 协议层(协议版本协商、stdio / SSE / Streamable HTTP 传输、keepalive ping、
|
||||
// notifications/tools/list_changed 订阅)全部交给官方 SDK
|
||||
// (github.com/modelcontextprotocol/go-sdk/mcp)——本包不自造协议,只做四件事:
|
||||
//
|
||||
// The client aligns with the MCP Go SDK's mature behaviour: it negotiates the
|
||||
// protocol version during initialize (requesting the newest version first and
|
||||
// downgrading to a mutually supported one), subscribes to
|
||||
// notifications/tools/list_changed via its capabilities so the registry can
|
||||
// re-sync its tool set, and runs an optional ping keepalive that closes a dead
|
||||
// session so the host can rebuild it.
|
||||
// 1. 配置 → 传输选择(MCPConfig → SDK Transport,含 APIKey 鉴权);
|
||||
// 2. 工具自动发现 → 注册进 capability registry(Registry);
|
||||
// 3. 保留服务器声明的 capabilities 并推断可用能力族(ServerFeatures,
|
||||
// 裁决 10.4.6 / design/09 §1.1「声明了才算可用」);
|
||||
// 4. tools/list input schema 缓存(SchemaCache)+ 调用前校验与默认值填充。
|
||||
//
|
||||
// It is deliberately dependency-light: only the standard library and the
|
||||
// project packages (plugins/sdk, capability/registry, internal/logger) are
|
||||
// used. The wire protocol is implemented directly, so no third-party MCP SDK is
|
||||
// required.
|
||||
//
|
||||
// A caller usually wires it up as:
|
||||
// 典型接法:
|
||||
//
|
||||
// reg := registry.New()
|
||||
// mcpReg := mcp.New(reg, mcp.MCPConfig{Name: "calc", Command: "mcp-calc"})
|
||||
// mcpReg := mcp.NewRegistry(reg, mcp.MCPConfig{Name: "calc", Command: "mcp-calc"})
|
||||
// if err := mcpReg.Register(ctx); err != nil {
|
||||
// log.Fatal(err)
|
||||
// log.Warn("mcp server skipped", "error", err)
|
||||
// }
|
||||
// defer mcpReg.Unregister(ctx)
|
||||
//
|
||||
// which connects to the server, discovers its tools, and registers them. Call
|
||||
// sites then invoke the tools by their MCP name through the registry.
|
||||
// 注册后的工具就是普通 sdk.Tool:Dispatcher 每轮实时读 registry,
|
||||
// 因此热接入的 MCP 工具下一轮模型请求即作为 Request.Tools 注入,模型可直接调用。
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// MCP protocol versions this client can speak, newest first. The client advertises
|
||||
// its preferred version (latestProtocolVersion) during initialize and accepts a
|
||||
// compatible version from the server, downgrading to a mutually supported version
|
||||
// when the server rejects or cannot match the request (DevDocs/design/09 §1.1).
|
||||
// clientName / clientVersion 是 initialize 时上报给 MCP server 的 clientInfo。
|
||||
const (
|
||||
// protocolVersion20251125 is the current preferred protocol version (the
|
||||
// MCP 2025-11-25 revision that introduced the Streamable HTTP migration).
|
||||
protocolVersion20251125 = "2025-11-25"
|
||||
// protocolVersion20241105 is the legacy protocol version, retained for
|
||||
// servers that have not yet migrated.
|
||||
protocolVersion20241105 = "2024-11-05"
|
||||
// latestProtocolVersion is what the client requests first during initialize.
|
||||
latestProtocolVersion = protocolVersion20251125
|
||||
// codeUnsupportedProtocolVersion is the JSON-RPC error code a server returns
|
||||
// during initialize when it does not support the requested protocol version
|
||||
// and wishes to advertise the versions it does support.
|
||||
codeUnsupportedProtocolVersion = -32022
|
||||
// codeMethodNotFound is the JSON-RPC error code a server returns when it does
|
||||
// not implement a requested method (e.g. a keepalive ping).
|
||||
codeMethodNotFound = -32601
|
||||
// maxInitializeAttempts bounds the initialize version-negotiation retries
|
||||
// (the first attempt plus one downgrade).
|
||||
maxInitializeAttempts = 2
|
||||
// notificationToolsListChanged is the server→client notification signalling
|
||||
// that the tool list has changed.
|
||||
notificationToolsListChanged = "notifications/tools/list_changed"
|
||||
// keepAliveFailureThreshold is the number of consecutive ping failures the
|
||||
// keepalive loop tolerates before closing the connection for a rebuild.
|
||||
keepAliveFailureThreshold = 3
|
||||
clientName = "cyrene"
|
||||
clientVersion = "0.1.0"
|
||||
)
|
||||
|
||||
// supportedProtocolVersions lists every protocol version this client understands,
|
||||
// newest first. Version negotiation picks the first entry that the server also
|
||||
// supports.
|
||||
var supportedProtocolVersions = []string{protocolVersion20251125, protocolVersion20241105}
|
||||
// keepAliveFailureThreshold 是 keepalive 关闭会话前容忍的连续 ping 失败次数默认值。
|
||||
const keepAliveFailureThreshold = 3
|
||||
|
||||
// isSupportedVersion reports whether v is a protocol version this client speaks.
|
||||
func isSupportedVersion(v string) bool {
|
||||
return slices.Contains(supportedProtocolVersions, v)
|
||||
}
|
||||
|
||||
// negotiateMutuallySupportedVersion returns the highest protocol version that is
|
||||
// supported by both this client and the server (which supports supported). It
|
||||
// returns "" when there is no overlap.
|
||||
func negotiateMutuallySupportedVersion(supported []string) string {
|
||||
for _, v := range supportedProtocolVersions {
|
||||
if slices.Contains(supported, v) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// highestCommonVersion returns the highest protocol version this client supports
|
||||
// that is not newer than v (used to downgrade when the server picks a version the
|
||||
// client cannot speak). It returns "" when no acceptable version exists.
|
||||
func highestCommonVersion(v string) string {
|
||||
for _, s := range supportedProtocolVersions {
|
||||
if s <= v {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Sentinel errors returned by this package. Test with errors.Is.
|
||||
// 本包对外哨兵错误,一律用 errors.Is 判定。
|
||||
var (
|
||||
// ErrNotConnected is returned when an operation requires an established
|
||||
// connection but the client is not yet connected or is closed.
|
||||
// ErrNotConnected 表示操作要求已建立的连接,但客户端尚未连接或已关闭。
|
||||
ErrNotConnected = errors.New("mcp: not connected")
|
||||
// ErrClosed is returned when operating on a client or transport that has
|
||||
// been closed.
|
||||
// ErrClosed 表示在已关闭的客户端上执行操作。
|
||||
ErrClosed = errors.New("mcp: closed")
|
||||
// ErrNoTransport is returned when the configuration selects no transport
|
||||
// (neither Command nor URL is set).
|
||||
// ErrNoTransport 表示配置既没有 Command 也没有 URL,选不出传输。
|
||||
ErrNoTransport = errors.New("mcp: no transport configured")
|
||||
// ErrRPC is returned when the MCP server replies with a JSON-RPC error
|
||||
// object. The concrete server error is wrapped and accessible via
|
||||
// errors.Is/errors.As on an *rpcError.
|
||||
ErrRPC = errors.New("mcp: rpc error")
|
||||
// ErrUnsupportedProtocolVersion is returned when the client and server
|
||||
// cannot agree on a mutually supported protocol version after the
|
||||
// negotiation retries are exhausted.
|
||||
// ErrUnsupportedProtocolVersion 表示客户端与服务器无法就协议版本达成一致。
|
||||
ErrUnsupportedProtocolVersion = errors.New("mcp: unsupported protocol version")
|
||||
// ErrCapabilityNotDeclared 表示调用了服务器在 initialize 里未声明的能力族
|
||||
// (design/09 §1.1「声明了才算可用,未声明不得调用」)。
|
||||
ErrCapabilityNotDeclared = errors.New("mcp: capability not declared by server")
|
||||
)
|
||||
|
||||
// MCPConfig describes a single MCP server connection. The transport is selected
|
||||
// by TransportType when set, otherwise inferred from the fields: stdio when
|
||||
// Command is set, SSE when URL is set. A blank TransportType keeps that legacy
|
||||
// inference so existing configuration keeps working unchanged.
|
||||
// MCPConfig 描述一台 MCP server 的连接方式。
|
||||
// TransportType 非空时按其选择传输;为空则沿用历史推断:
|
||||
// 有 Command 走 stdio,有 URL 走 SSE(TransportType 可显式改走 Streamable HTTP)。
|
||||
type MCPConfig struct {
|
||||
// Name is the human-readable label of the server, used in logs and as the
|
||||
// prefix for registering its tools uniquely.
|
||||
// Name 是服务器标签,用于日志与工具去重前缀。
|
||||
Name string `env:"MCP_NAME"`
|
||||
// Command is the executable path to spawn for a stdio MCP server.
|
||||
// Command 是 stdio 模式下要拉起的可执行文件。
|
||||
Command string `env:"MCP_COMMAND"`
|
||||
// Args are the arguments passed to Command.
|
||||
// Args 是传给 Command 的参数。
|
||||
Args []string `env:"MCP_ARGS"`
|
||||
// URL is the endpoint of an MCP server (SSE stream endpoint, or the
|
||||
// Streamable HTTP endpoint when TransportType is "http").
|
||||
// URL 是 SSE 端点(TransportType="sse")或 Streamable HTTP 端点("http"/"streamable")。
|
||||
URL string `env:"MCP_URL"`
|
||||
// TransportType selects the transport explicitly: "stdio", "sse" or
|
||||
// "http" (Streamable HTTP). Empty means infer from Command/URL.
|
||||
// TransportType 显式选择传输:"stdio"、"sse"、"http"(等价 "streamable")。
|
||||
TransportType string `env:"MCP_TRANSPORT"`
|
||||
// APIKey is the bearer credential sent to an http/sse server as an
|
||||
// Authorization header. Empty means the server needs no auth.
|
||||
// APIKey 是 http/sse 服务器用的 bearer 凭据,作为 Authorization 头发送。
|
||||
APIKey string `env:"MCP_API_KEY"`
|
||||
// Env is extra environment for a stdio subprocess, merged over the parent
|
||||
// process environment (extra keys win). It is ignored by the HTTP transports.
|
||||
// Env 是 stdio 子进程的额外环境变量,叠加在父进程环境之上(同名以这里为准)。
|
||||
Env map[string]string `env:"MCP_ENV"`
|
||||
// Enabled reports whether this server should be activated at startup.
|
||||
// Enabled 表示该服务器是否在启动时接入。
|
||||
Enabled bool `env:"MCP_ENABLED"`
|
||||
}
|
||||
|
||||
// Config holds the set of configured MCP servers.
|
||||
// Config 是一组 MCP server 配置。
|
||||
type Config struct {
|
||||
// Servers is the ordered list of MCP server configurations.
|
||||
// Servers 是按顺序排列的服务器配置。
|
||||
Servers []MCPConfig
|
||||
}
|
||||
|
||||
// ServerInfo describes the MCP server reported by the initialize handshake.
|
||||
// ServerFeatures 是从服务器 initialize 声明里推断出的能力族可用性。
|
||||
// 规则见 featuresFrom:声明了才算可用,未声明不得调用(design/09 §1.1)。
|
||||
type ServerFeatures struct {
|
||||
// Tools 表示服务器声明提供工具(tools/list、tools/call 可用)。
|
||||
Tools bool
|
||||
// Prompts 表示服务器声明提供提示模板。
|
||||
Prompts bool
|
||||
// Resources 表示服务器声明提供资源。
|
||||
Resources bool
|
||||
// Logging 表示服务器声明会发日志消息。
|
||||
Logging bool
|
||||
// Completions 表示服务器声明支持参数自动补全。
|
||||
Completions bool
|
||||
// Sampling 表示允许服务器回调宿主代跑 LLM:宿主配了回调,
|
||||
// 且服务器声明过能力族(即确实是会主动交互的对端)才为真。
|
||||
Sampling bool
|
||||
// Elicitation 表示允许服务器回调宿主向用户追加提问,判定同 Sampling。
|
||||
Elicitation bool
|
||||
// Roots 表示允许向服务器暴露工作区根目录,判定同 Sampling。
|
||||
Roots bool
|
||||
}
|
||||
|
||||
// ServerInfo 是 initialize 握手的结果,服务器声明的 capabilities 原样保留。
|
||||
type ServerInfo struct {
|
||||
// Name is the server-provided name.
|
||||
// Name 是服务器自报名称。
|
||||
Name string
|
||||
// Version is the server-provided version string.
|
||||
// Version 是服务器自报版本。
|
||||
Version string
|
||||
// Instructions 是服务器给出的使用说明(可注入系统提示)。
|
||||
Instructions string
|
||||
// ProtocolVersion 是协商后实际使用的 MCP 协议版本。
|
||||
ProtocolVersion string
|
||||
// Capabilities 是服务器声明的 capabilities 原文(未做删改),对客户端可读。
|
||||
Capabilities map[string]any
|
||||
// Features 是按 Capabilities 推断出的可用能力族。
|
||||
Features ServerFeatures
|
||||
}
|
||||
|
||||
// ToolInfo is a tool exposed by an MCP server.
|
||||
type ToolInfo struct {
|
||||
// Name is the tool identifier used to invoke it.
|
||||
Name string
|
||||
// Description explains what the tool does.
|
||||
Description string
|
||||
// InputSchema is the tool's JSON Schema, typically
|
||||
// {"type":"object","properties":{...}}.
|
||||
InputSchema map[string]any
|
||||
}
|
||||
// SamplingHandler 由宿主提供:服务器请求客户端代跑一次 LLM(sampling/createMessage)时调用。
|
||||
type SamplingHandler func(context.Context, *mcpsdk.CreateMessageRequest) (*mcpsdk.CreateMessageResult, error)
|
||||
|
||||
// ContentBlock is a single item in a tool call result (MCP content block).
|
||||
type ContentBlock struct {
|
||||
// Type is the block type, most commonly "text".
|
||||
Type string `json:"type"`
|
||||
// Text is the textual payload.
|
||||
Text string `json:"text"`
|
||||
}
|
||||
// ElicitationHandler 由宿主提供:服务器向用户索要补充输入(elicitation/create)时调用。
|
||||
type ElicitationHandler func(context.Context, *mcpsdk.ElicitRequest) (*mcpsdk.ElicitResult, error)
|
||||
|
||||
// ToolResult is the structured result of a tools/call.
|
||||
type ToolResult struct {
|
||||
// Content holds the response content blocks.
|
||||
Content []ContentBlock
|
||||
// IsError reports whether the server flagged the call as an error.
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// Client is a minimal MCP client bound to a single server described by cfg. It
|
||||
// is safe for concurrent use; all calls are serialized internally.
|
||||
// Client 是一台 MCP server 的客户端。它内部持有一个官方 SDK 会话,
|
||||
// 并发安全:Connect/Close 互斥,其余调用由 SDK 串行化。
|
||||
type Client struct {
|
||||
cfg MCPConfig
|
||||
log *slog.Logger
|
||||
tr Transport
|
||||
mu sync.Mutex
|
||||
state state
|
||||
info ServerInfo
|
||||
cfg MCPConfig
|
||||
log *slog.Logger
|
||||
|
||||
// negotiatedVersion is the protocol version agreed during initialize; it is
|
||||
// sent back to the server in the Mcp-Protocol-Version header by the HTTP
|
||||
// transport.
|
||||
negotiatedVersion string
|
||||
mu sync.Mutex
|
||||
state state
|
||||
session *mcpsdk.ClientSession
|
||||
info ServerInfo
|
||||
|
||||
// preferredProtocolVersion, when non-empty, overrides latestProtocolVersion
|
||||
// as the version requested first during initialize. Tests use it to drive the
|
||||
// negotiation path deterministically.
|
||||
// tr 是注入或按 cfg 选出的传输;schemas 是 tools/list input schema 缓存。
|
||||
tr mcpsdk.Transport
|
||||
schemas *SchemaCache
|
||||
|
||||
// preferredProtocolVersion 非空时固定 initialize 请求的协议版本(测试/排障用)。
|
||||
preferredProtocolVersion string
|
||||
|
||||
// keepAlive is the ping interval for the connection keepalive; zero disables
|
||||
// it. keepAliveFailures is the tolerated consecutive-failure count.
|
||||
// keepAlive 是 keepalive ping 间隔,零值关闭;keepAliveFailures 是容忍失败数。
|
||||
keepAlive time.Duration
|
||||
keepAliveFailures int
|
||||
keepaliveCancel context.CancelFunc
|
||||
|
||||
// onToolListChanged is invoked (in a fresh goroutine) when the server sends
|
||||
// notifications/tools/list_changed, so the host can refresh its tool set.
|
||||
sampling SamplingHandler
|
||||
elicitation ElicitationHandler
|
||||
roots []*mcpsdk.Root
|
||||
|
||||
// onToolListChanged 在服务器发 notifications/tools/list_changed 时被调用
|
||||
// (新 goroutine 里,已被 refreshPending 合并),用于增量刷新 registry。
|
||||
onToolListChanged func()
|
||||
refreshPending atomic.Bool
|
||||
}
|
||||
|
||||
// protocolVersion returns the protocol version negotiated by initialize, or ""
|
||||
// before a successful handshake.
|
||||
func (c *Client) protocolVersion() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.negotiatedVersion
|
||||
}
|
||||
|
||||
// setProtocolVersion records the version negotiated by initialize.
|
||||
func (c *Client) setProtocolVersion(v string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.negotiatedVersion = v
|
||||
}
|
||||
|
||||
// capabilities returns the client capability declaration for the given protocol
|
||||
// version. It advertises tools.listChanged so the server notifies the client when
|
||||
// the tool list changes.
|
||||
func (c *Client) capabilities(version string) map[string]any {
|
||||
caps := map[string]any{}
|
||||
if c.onToolListChanged != nil {
|
||||
caps["tools"] = map[string]any{"listChanged": true}
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
// state tracks the client lifecycle.
|
||||
// state 是客户端生命周期。
|
||||
type state int
|
||||
|
||||
const (
|
||||
@@ -267,63 +175,10 @@ const (
|
||||
stateClosed
|
||||
)
|
||||
|
||||
// Transport is the JSON-RPC message exchange with an MCP server over a concrete
|
||||
// transport (stdio subprocess, SSE or Streamable HTTP). Implementations must be
|
||||
// safe for concurrent use. This is exported so tests and hosts can inject a fake
|
||||
// transport.
|
||||
type Transport interface {
|
||||
// Start makes the transport ready to exchange messages.
|
||||
Start(ctx context.Context) error
|
||||
// Call sends a JSON-RPC request and decodes the matching response into
|
||||
// result. Params is marshaled into the request body; result is unmarshaled
|
||||
// from the response's result field.
|
||||
Call(ctx context.Context, method string, params any, result any) error
|
||||
// Close releases every underlying resource (subprocess, streams, sockets).
|
||||
Close() error
|
||||
}
|
||||
|
||||
// notifier is implemented by transports that can send a JSON-RPC notification
|
||||
// (a request with no id and no response).
|
||||
type notifier interface {
|
||||
notify(ctx context.Context, method string, params any) error
|
||||
}
|
||||
|
||||
// serverNotifier is implemented by transports that can deliver server-initiated
|
||||
// notifications (messages with no id, e.g. notifications/tools/list_changed) back
|
||||
// to the client. The handler is invoked from the transport's read path, so it must
|
||||
// not block or re-enter the same transport synchronously.
|
||||
type serverNotifier interface {
|
||||
setOnNotification(fn func(method string))
|
||||
}
|
||||
|
||||
// attachNotificationSink wires a server→client notification handler into tr, if
|
||||
// tr supports one.
|
||||
func attachNotificationSink(tr Transport, fn func(method string)) {
|
||||
if on, ok := tr.(serverNotifier); ok {
|
||||
on.setOnNotification(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// protocolProvider is implemented by transports that include the negotiated MCP
|
||||
// protocol version in requests (the Mcp-Protocol-Version header of the HTTP
|
||||
// transport).
|
||||
type protocolProvider interface {
|
||||
setProtocolProvider(fn func() string)
|
||||
}
|
||||
|
||||
// attachProtocolProvider wires the negotiated-version getter into tr, if tr
|
||||
// supports one.
|
||||
func attachProtocolProvider(tr Transport, fn func() string) {
|
||||
if p, ok := tr.(protocolProvider); ok {
|
||||
p.setProtocolProvider(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// Option customizes a Client built by New.
|
||||
// Option 定制 New 构建的 Client。
|
||||
type Option func(*Client)
|
||||
|
||||
// WithLogger overrides the client's logger. The given logger is tagged with the
|
||||
// source capability/mcp.
|
||||
// WithLogger 覆盖客户端日志器;给定的日志器会被标上 source=capability/mcp。
|
||||
func WithLogger(l *slog.Logger) Option {
|
||||
return func(c *Client) {
|
||||
if l != nil {
|
||||
@@ -332,19 +187,15 @@ func WithLogger(l *slog.Logger) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithTransport overrides the transport used by the client. This is primarily
|
||||
// for tests that inject a fake transport instead of spawning a subprocess or
|
||||
// dialing SSE.
|
||||
func WithTransport(t Transport) Option {
|
||||
// WithTransport 注入传输(官方 SDK 的 Transport),主要用于测试。
|
||||
func WithTransport(t mcpsdk.Transport) Option {
|
||||
return func(c *Client) {
|
||||
c.tr = t
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeepAlive enables the connection keepalive, sending an MCP ping every
|
||||
// interval and closing the session after keepAliveFailures consecutive missed
|
||||
// pings. A zero interval leaves keepalive disabled. When keepAliveFailures is
|
||||
// omitted, the package default (keepAliveFailureThreshold) is used.
|
||||
// WithKeepAlive 打开 keepalive:每 interval 发一次 ping,连续失败 keepAliveFailures
|
||||
// 次即关闭会话(由 SDK 执行)。间隔为零则关闭 keepalive;失败次数缺省用包默认值。
|
||||
func WithKeepAlive(interval time.Duration, keepAliveFailures ...int) Option {
|
||||
failures := keepAliveFailureThreshold
|
||||
if len(keepAliveFailures) > 0 && keepAliveFailures[0] > 0 {
|
||||
@@ -356,33 +207,60 @@ func WithKeepAlive(interval time.Duration, keepAliveFailures ...int) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithToolListChangedHandler registers a handler that is invoked (in a fresh
|
||||
// goroutine) whenever the server sends notifications/tools/list_changed, letting
|
||||
// the host refresh its registered tool set.
|
||||
// WithToolListChangedHandler 注册工具清单变更回调,由 registry 用于增量刷新。
|
||||
func WithToolListChangedHandler(fn func()) Option {
|
||||
return func(c *Client) {
|
||||
c.onToolListChanged = fn
|
||||
}
|
||||
}
|
||||
|
||||
// WithProtocolVersion overrides the protocol version requested first during
|
||||
// initialize. It is primarily for tests that drive the negotiation path
|
||||
// deterministically; callers should normally leave it unset to use
|
||||
// latestProtocolVersion.
|
||||
// WithProtocolVersion 固定 initialize 请求的协议版本;不设置则用 SDK 最新版本,
|
||||
// 服务器拒绝时由 SDK 自动回退到双方都支持的版本。
|
||||
func WithProtocolVersion(v string) Option {
|
||||
return func(c *Client) {
|
||||
c.preferredProtocolVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
// New builds a Client for cfg. By default it uses a discard logger and selects a
|
||||
// stdio or SSE transport from cfg; pass WithLogger and/or WithTransport to
|
||||
// override.
|
||||
// WithSchemaCache 复用外部 schema 缓存;不设置则每个客户端自建一份。
|
||||
func WithSchemaCache(cache *SchemaCache) Option {
|
||||
return func(c *Client) {
|
||||
if cache != nil {
|
||||
c.schemas = cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithSamplingHandler 注册 sampling 回调。设了才向服务器宣告 sampling 能力;
|
||||
// 服务器未声明能力族时回调会被拒绝(ErrCapabilityNotDeclared)。
|
||||
func WithSamplingHandler(h SamplingHandler) Option {
|
||||
return func(c *Client) {
|
||||
c.sampling = h
|
||||
}
|
||||
}
|
||||
|
||||
// WithElicitationHandler 注册 elicitation 回调。设了才向服务器宣告 elicitation 能力。
|
||||
func WithElicitationHandler(h ElicitationHandler) Option {
|
||||
return func(c *Client) {
|
||||
c.elicitation = h
|
||||
}
|
||||
}
|
||||
|
||||
// WithRoots 注册工作区根目录。设了才向服务器宣告 roots 能力。
|
||||
func WithRoots(roots ...*mcpsdk.Root) Option {
|
||||
return func(c *Client) {
|
||||
c.roots = append([]*mcpsdk.Root(nil), roots...)
|
||||
}
|
||||
}
|
||||
|
||||
// New 构建 cfg 对应的 Client,默认用丢弃日志器并自动选传输;
|
||||
// 用 WithLogger / WithTransport 覆盖。
|
||||
func New(cfg MCPConfig, opts ...Option) *Client {
|
||||
c := &Client{
|
||||
cfg: cfg,
|
||||
log: defaultLogger(),
|
||||
state: stateNew,
|
||||
cfg: cfg,
|
||||
log: defaultLogger(),
|
||||
state: stateNew,
|
||||
schemas: NewSchemaCache(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
@@ -390,19 +268,56 @@ func New(cfg MCPConfig, opts ...Option) *Client {
|
||||
return c
|
||||
}
|
||||
|
||||
// defaultLogger returns a discard logger tagged with the mcp source. Hosts
|
||||
// should prefer WithLogger so records are observable.
|
||||
// defaultLogger 返回带 capability/mcp 来源标签的丢弃日志器;宿主应传 WithLogger。
|
||||
func defaultLogger() *slog.Logger {
|
||||
base := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
return logger.WithSource(base, "capability/mcp")
|
||||
}
|
||||
|
||||
// transport returns the established transport, or nil when not connected.
|
||||
func (c *Client) transport() Transport {
|
||||
// Name 返回服务器标签。
|
||||
func (c *Client) Name() string { return c.cfg.Name }
|
||||
|
||||
// ServerInfo 返回 initialize 握手结果;未连接时为零值。
|
||||
func (c *Client) ServerInfo() ServerInfo {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.state != stateConnected || c.tr == nil {
|
||||
return c.info
|
||||
}
|
||||
|
||||
// Features 返回按服务器声明推断出的能力族可用性;未连接时全为 false。
|
||||
func (c *Client) Features() ServerFeatures {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.info.Features
|
||||
}
|
||||
|
||||
// SchemaCache 返回该客户端使用的 input schema 缓存。
|
||||
func (c *Client) SchemaCache() *SchemaCache { return c.schemas }
|
||||
|
||||
// Session 返回底层官方 SDK 会话(未连接时为 nil),供需要 SDK 原生能力的调用方使用。
|
||||
func (c *Client) Session() *mcpsdk.ClientSession {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.state != stateConnected {
|
||||
return nil
|
||||
}
|
||||
return c.tr
|
||||
return c.session
|
||||
}
|
||||
|
||||
// connected 返回已建立的 SDK 会话,未连接时返回 nil。
|
||||
func (c *Client) connected() *mcpsdk.ClientSession {
|
||||
return c.Session()
|
||||
}
|
||||
|
||||
// begin 做 Connect 前的状态检查:已连接返回 same=true(幂等),已关闭返回错误。
|
||||
func (c *Client) begin() (same bool, err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
switch c.state {
|
||||
case stateClosed:
|
||||
return false, fmt.Errorf("mcp connect %q: %w", c.cfg.Name, ErrClosed)
|
||||
case stateConnected:
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnvWith_OverridesAndPreserves(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []string{"PATH=/usr/bin", "HOME=/root"}
|
||||
got := envWith(base, map[string]string{"HOME": "/tmp", "MCP_TOKEN": "abc"})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3 (%v)", len(got), got)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, kv := range got {
|
||||
seen[kv] = true
|
||||
}
|
||||
if !seen["PATH=/usr/bin"] || !seen["HOME=/tmp"] || !seen["MCP_TOKEN=abc"] {
|
||||
t.Fatalf("merged env = %v", got)
|
||||
}
|
||||
if len(envWith(base, nil)) != len(base) {
|
||||
t.Fatal("empty extra changed the base environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPTransport_SendsAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := make(chan string, 1)
|
||||
srv := httpTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r.Header.Get("Authorization")
|
||||
httpJSONResponse(w, r)
|
||||
})
|
||||
defer srv.Close()
|
||||
tr := newHTTPTransport(MCPConfig{Name: "s", URL: srv.URL, APIKey: "sk-1234"}, defaultLogger())
|
||||
defer tr.Close()
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if err := tr.Call(context.Background(), "tools/list", nil, &listToolsResult{}); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if auth := <-got; auth != "Bearer sk-1234" {
|
||||
t.Fatalf("Authorization = %q, want bearer key", auth)
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
)
|
||||
|
||||
func sampleTools() []ToolInfo {
|
||||
return []ToolInfo{
|
||||
{Name: "add", Description: "Adds numbers", InputSchema: map[string]any{"type": "object"}},
|
||||
{Name: "sub", Description: "Subtracts numbers", InputSchema: map[string]any{"type": "object"}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Register_AutoRegisters(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{tools: sampleTools()}
|
||||
reg := registry.New()
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc", Command: "x"}, WithTransport(ft))
|
||||
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
for _, name := range []string{"add", "sub"} {
|
||||
cap, err := reg.Get(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%q): %v", name, err)
|
||||
}
|
||||
if cap.Kind != registry.KindTool {
|
||||
t.Fatalf("Kind(%q) = %q, want %q", name, cap.Kind, registry.KindTool)
|
||||
}
|
||||
}
|
||||
if !ft.hasMethod("tools/list") {
|
||||
t.Fatalf("tools/list was not called; methods = %v", ft.calledMethods())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Register_PropagatesConnectError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{startErr: errors.New("boom")}
|
||||
reg := registry.New()
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc", Command: "x"}, WithTransport(ft))
|
||||
if err := r.Register(context.Background()); err == nil {
|
||||
t.Fatal("Register succeeded, want connect error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Register_DuplicateTool(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{tools: []ToolInfo{{Name: "add", InputSchema: map[string]any{}}}}
|
||||
reg := registry.New()
|
||||
// Pre-register a tool with the same name so the MCP registration collides.
|
||||
_ = reg.RegisterTool(&testTool{name: "add"})
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc", Command: "x"}, WithTransport(ft))
|
||||
err := r.Register(context.Background())
|
||||
if !errors.Is(err, registry.ErrDuplicate) {
|
||||
t.Fatalf("Register error = %v, want registry.ErrDuplicate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Register_NilRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{}
|
||||
r := NewRegistry(nil, MCPConfig{Name: "calc", Command: "x"}, WithTransport(ft))
|
||||
if err := r.Register(context.Background()); err == nil {
|
||||
t.Fatal("Register with nil registry succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Unregister_RemovesTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{tools: sampleTools()}
|
||||
reg := registry.New()
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc", Command: "x"}, WithTransport(ft))
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
if err := r.Unregister(context.Background()); err != nil {
|
||||
t.Fatalf("Unregister: %v", err)
|
||||
}
|
||||
for _, name := range []string{"add", "sub"} {
|
||||
if _, err := reg.Get(name); !errors.Is(err, registry.ErrNotFound) {
|
||||
t.Fatalf("Get(%q) after Unregister = %v, want ErrNotFound", name, err)
|
||||
}
|
||||
}
|
||||
if ft.closes != 1 {
|
||||
t.Fatalf("closes = %d, want 1 (Unregister must close the client)", ft.closes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Unregister_Idempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{}
|
||||
reg := registry.New()
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc", Command: "x"}, WithTransport(ft))
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
if err := r.Unregister(context.Background()); err != nil {
|
||||
t.Fatalf("first Unregister: %v", err)
|
||||
}
|
||||
if err := r.Unregister(context.Background()); err != nil {
|
||||
t.Fatalf("second Unregister: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_ToolListChangedRefreshes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{tools: sampleTools()}
|
||||
reg := registry.New()
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc", Command: "x"}, WithTransport(ft))
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
// The server's tool list changes: sub is dropped, mul appears.
|
||||
ft.setTools([]ToolInfo{
|
||||
{Name: "add", Description: "Adds numbers", InputSchema: map[string]any{"type": "object"}},
|
||||
{Name: "mul", Description: "Multiplies numbers", InputSchema: map[string]any{"type": "object"}},
|
||||
})
|
||||
ft.notifyServer(notificationToolsListChanged)
|
||||
|
||||
waitFor(t, time.Second, func() bool {
|
||||
_, err := reg.Get("mul")
|
||||
return err == nil
|
||||
})
|
||||
if _, err := reg.Get("add"); err != nil {
|
||||
t.Fatalf("Get(add) after refresh = %v, want present", err)
|
||||
}
|
||||
if _, err := reg.Get("sub"); !errors.Is(err, registry.ErrNotFound) {
|
||||
t.Fatalf("Get(sub) after refresh = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_ToolListChangedPreservesForeignTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
ft := &fakeTransport{tools: sampleTools()}
|
||||
reg := registry.New()
|
||||
_ = reg.RegisterTool(&testTool{name: "add"}) // a foreign 'add' before refresh
|
||||
r := NewRegistry(reg, MCPConfig{Name: "calc", Command: "x"}, WithTransport(ft))
|
||||
if err := r.Register(context.Background()); err != nil {
|
||||
// 'add' collides with the foreign tool; Register must surface the dup.
|
||||
if !errors.Is(err, registry.ErrDuplicate) {
|
||||
t.Fatalf("Register error = %v, want registry.ErrDuplicate", err)
|
||||
}
|
||||
}
|
||||
// After the collision, sub is registered, 'add' stays foreign.
|
||||
ft.setTools([]ToolInfo{{Name: "sub", Description: "Subtracts", InputSchema: map[string]any{}}})
|
||||
ft.notifyServer(notificationToolsListChanged)
|
||||
waitFor(t, time.Second, func() bool {
|
||||
_, err := reg.Get("sub")
|
||||
return err == nil
|
||||
})
|
||||
// The foreign 'add' must be preserved even though it is not in the server list.
|
||||
if _, err := reg.Get("add"); err != nil {
|
||||
t.Fatalf("Get(add) after refresh = %v, want foreign tool preserved", err)
|
||||
}
|
||||
}
|
||||
|
||||
// testTool is a minimal sdk.Tool used to pre-register a colliding name.
|
||||
type testTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (t *testTool) Name() string { return t.name }
|
||||
func (t *testTool) Description() string { return "desc " + t.name }
|
||||
func (t *testTool) Parameters() (map[string]any, error) { return map[string]any{}, nil }
|
||||
func (t *testTool) Execute(_ context.Context, _ any) (any, error) { return nil, nil }
|
||||
@@ -1,53 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// rpcRequest is a JSON-RPC 2.0 request. Notifications omit ID so the field is
|
||||
// tagged omitempty. Params is any so callers pass a struct or a JSON object.
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// rpcResponse is a JSON-RPC 2.0 response. Result is kept raw until the caller
|
||||
// decodes it into the concrete result type. Method is populated only for
|
||||
// server-initiated notifications (no id), so transports can route them to the
|
||||
// client's notification handler.
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// rpcError is a JSON-RPC error object. It satisfies error so it can be wrapped
|
||||
// and surfaced with %w.
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements error.
|
||||
func (e *rpcError) Error() string {
|
||||
return fmt.Sprintf("code %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// encodeRequest marshals a JSON-RPC request. An id of nil produces a
|
||||
// notification (no id field).
|
||||
func encodeRequest(id any, method string, params any) ([]byte, error) {
|
||||
return json.Marshal(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params})
|
||||
}
|
||||
|
||||
// decodeIDString reports whether res carries the string id expected for a JSON
|
||||
// string id request.
|
||||
func decodeIDString(res rpcResponse, id string) bool {
|
||||
s, ok := res.ID.(string)
|
||||
return ok && s == id
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
)
|
||||
|
||||
// schemaMap 把 SDK 传来的 input schema 规整成 map[string]any;nil 或无法识别时
|
||||
// 退化为「无约束的对象 schema」,保证 Parameters() 永远能返回可用形状。
|
||||
func schemaMap(v any) map[string]any {
|
||||
switch s := v.(type) {
|
||||
case nil:
|
||||
return emptyObjectSchema()
|
||||
case map[string]any:
|
||||
return s
|
||||
case json.RawMessage:
|
||||
return decodeSchema(s)
|
||||
case []byte:
|
||||
return decodeSchema(s)
|
||||
default:
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return emptyObjectSchema()
|
||||
}
|
||||
return decodeSchema(data)
|
||||
}
|
||||
}
|
||||
|
||||
// emptyObjectSchema 返回无约束的空对象 schema。
|
||||
func emptyObjectSchema() map[string]any {
|
||||
return map[string]any{"type": "object", "properties": map[string]any{}}
|
||||
}
|
||||
|
||||
// decodeSchema 把 JSON 文本解成 schema map,失败时退回空对象 schema。
|
||||
func decodeSchema(data []byte) map[string]any {
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return emptyObjectSchema()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveSchema 把原始 schema 解析成可校验、可补默认值的已解析 schema。
|
||||
func resolveSchema(raw map[string]any) (*jsonschema.Resolved, error) {
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal input schema: %w", err)
|
||||
}
|
||||
var s jsonschema.Schema
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return nil, fmt.Errorf("parse input schema: %w", err)
|
||||
}
|
||||
resolved, err := s.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve input schema: %w", err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// applySchema 按已解析的 input schema 先补默认值、再校验参数。
|
||||
// resolved 为 nil 表示 schema 不可解析,此时降级为不校验。
|
||||
// ApplyDefaults 对病态默认值会 panic,这里兜住转成错误,避免拖垮调用方。
|
||||
func applySchema(resolved *jsonschema.Resolved, args map[string]any) (err error) {
|
||||
if resolved == nil {
|
||||
return nil
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("input schema default application panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
if err = resolved.ApplyDefaults(&args); err != nil {
|
||||
return fmt.Errorf("apply input schema defaults: %w", err)
|
||||
}
|
||||
if err = resolved.Validate(args); err != nil {
|
||||
return fmt.Errorf("arguments do not match input schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
)
|
||||
|
||||
// SchemaCache 缓存 tools/list 里已解析的 input schema(design/09 §1.1)。
|
||||
//
|
||||
// 键 = 服务器名 + 工具名;收到 notifications/tools/list_changed 时按服务器整表失效。
|
||||
// 命中时 CallTool 直接用缓存 schema 校验参数并补默认值,不重复拉取/解析 schema。
|
||||
// 并发安全;命中/未命中计数对调用方可读,便于验证缓存真的生效。
|
||||
type SchemaCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*cachedSchema
|
||||
hits int
|
||||
misses int
|
||||
}
|
||||
|
||||
// cachedSchema 是一条缓存:原始 schema(模型可见的形状)+ 已解析结果。
|
||||
// resolved 为 nil 表示 schema 解析失败,调用时降级为不校验。
|
||||
type cachedSchema struct {
|
||||
raw map[string]any
|
||||
resolved *jsonschema.Resolved
|
||||
}
|
||||
|
||||
// NewSchemaCache 构建空缓存。
|
||||
func NewSchemaCache() *SchemaCache {
|
||||
return &SchemaCache{entries: map[string]*cachedSchema{}}
|
||||
}
|
||||
|
||||
// Put 解析并缓存一条 input schema。解析失败时仍缓存原始 schema(校验降级)并返回
|
||||
// 解析错误,由调用方决定是否告警。
|
||||
func (c *SchemaCache) Put(server, tool string, schema map[string]any) error {
|
||||
if schema == nil {
|
||||
schema = emptyObjectSchema()
|
||||
}
|
||||
entry := &cachedSchema{raw: schema}
|
||||
resolved, err := resolveSchema(schema)
|
||||
entry.resolved = resolved
|
||||
|
||||
c.mu.Lock()
|
||||
c.entries[cacheKey(server, tool)] = entry
|
||||
c.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Get 返回缓存的原始 input schema。
|
||||
func (c *SchemaCache) Get(server, tool string) (map[string]any, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.entries[cacheKey(server, tool)]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return entry.raw, true
|
||||
}
|
||||
|
||||
// Prepare 返回「已补默认值、已校验」的参数:
|
||||
//
|
||||
// - 命中缓存:用缓存 schema 补默认值 + 校验(计一次命中),不再重复拉取/解析;
|
||||
// - 未命中:有 fallback(本次 tools/list 发现结果)则解析后缓存再用;没有可用
|
||||
// schema 时原样返回(计一次未命中),即首次工具清单到达前的直接调用降级为不校验。
|
||||
//
|
||||
// 传给服务器的是返回的参数值;args 为 nil 时按空对象处理。
|
||||
func (c *SchemaCache) Prepare(server, tool string, fallback, args map[string]any) (map[string]any, error) {
|
||||
entry := c.lookup(server, tool)
|
||||
if entry == nil {
|
||||
if fallback == nil {
|
||||
return args, nil
|
||||
}
|
||||
// 解析失败不阻断调用:Put 已把原始 schema 放进缓存,resolved 为 nil 即不校验。
|
||||
_ = c.Put(server, tool, fallback)
|
||||
entry = c.lookup(server, tool)
|
||||
}
|
||||
if entry == nil || entry.resolved == nil {
|
||||
return args, nil
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
if err := applySchema(entry.resolved, args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// lookup 取一条缓存并记账(命中/未命中)。
|
||||
func (c *SchemaCache) lookup(server, tool string) *cachedSchema {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.entries[cacheKey(server, tool)]
|
||||
if !ok {
|
||||
c.misses++
|
||||
return nil
|
||||
}
|
||||
c.hits++
|
||||
return entry
|
||||
}
|
||||
|
||||
// Invalidate 让某台服务器的全部缓存失效;工具清单变化时调用。
|
||||
func (c *SchemaCache) Invalidate(server string) {
|
||||
prefix := server + keySeparator
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for k := range c.entries {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(c.entries, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear 清空所有缓存。
|
||||
func (c *SchemaCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries = map[string]*cachedSchema{}
|
||||
}
|
||||
|
||||
// Len 返回当前缓存条目数。
|
||||
func (c *SchemaCache) Len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.entries)
|
||||
}
|
||||
|
||||
// Stats 返回命中次数与未命中次数。
|
||||
func (c *SchemaCache) Stats() (hits, misses int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.hits, c.misses
|
||||
}
|
||||
|
||||
// keySeparator 分隔服务器名与工具名,避免不同服务器同名工具互相覆盖。
|
||||
const keySeparator = "\x00"
|
||||
|
||||
// cacheKey 拼接缓存键。
|
||||
func cacheKey(server, tool string) string {
|
||||
return server + keySeparator + tool
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSchemaCache_PutGetInvalidate 覆盖缓存基本语义与按服务器失效。
|
||||
func TestSchemaCache_PutGetInvalidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := NewSchemaCache()
|
||||
schema := map[string]any{"type": "object", "properties": map[string]any{"a": map[string]any{"type": "number"}}}
|
||||
if err := cache.Put("s1", "tool", schema); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if err := cache.Put("s2", "tool", schema); err != nil {
|
||||
t.Fatalf("Put(s2): %v", err)
|
||||
}
|
||||
if _, ok := cache.Get("s1", "tool"); !ok {
|
||||
t.Fatal("Get(s1,tool) 未命中")
|
||||
}
|
||||
if cache.Len() != 2 {
|
||||
t.Fatalf("Len = %d, 期望 2", cache.Len())
|
||||
}
|
||||
|
||||
// 只失效 s1,s2 的同名工具必须留下。
|
||||
cache.Invalidate("s1")
|
||||
if _, ok := cache.Get("s1", "tool"); ok {
|
||||
t.Fatal("Invalidate(s1) 后仍然命中")
|
||||
}
|
||||
if _, ok := cache.Get("s2", "tool"); !ok {
|
||||
t.Fatal("Invalidate(s1) 误删了 s2 的缓存")
|
||||
}
|
||||
|
||||
cache.Clear()
|
||||
if cache.Len() != 0 {
|
||||
t.Fatalf("Clear 后 Len = %d, 期望 0", cache.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCache_Prepare_AppliesDefaultsAndValidates 命中缓存时补默认值并校验。
|
||||
func TestSchemaCache_Prepare_AppliesDefaultsAndValidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := NewSchemaCache()
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "default": "world"},
|
||||
"n": map[string]any{"type": "number"},
|
||||
},
|
||||
"required": []string{"n"},
|
||||
}
|
||||
if err := cache.Put("s", "t", schema); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
out, err := cache.Prepare("s", "t", nil, map[string]any{"n": 1})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if out["name"] != "world" {
|
||||
t.Fatalf("默认值未生效:%v", out)
|
||||
}
|
||||
|
||||
if _, err := cache.Prepare("s", "t", nil, map[string]any{"n": "x"}); err == nil {
|
||||
t.Fatal("类型错误的参数没有被拦下")
|
||||
}
|
||||
if _, err := cache.Prepare("s", "t", nil, map[string]any{}); err == nil {
|
||||
t.Fatal("缺必填参数没有被拦下")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCache_Prepare_HitsCacheWithoutReparsing 证明「命中缓存」这条要求:
|
||||
// 发现阶段已缓存 → 后续调用全是命中,未命中计数为 0(没有重复拉取/解析)。
|
||||
func TestSchemaCache_Prepare_HitsCacheWithoutReparsing(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := NewSchemaCache()
|
||||
if err := cache.Put("s", "t", emptyObjectSchema()); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := cache.Prepare("s", "t", nil, map[string]any{}); err != nil {
|
||||
t.Fatalf("Prepare #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
hits, misses := cache.Stats()
|
||||
if hits != 3 || misses != 0 {
|
||||
t.Fatalf("Stats = (%d hits, %d misses), 期望 (3, 0)", hits, misses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCache_Prepare_MissFillsFromFallback 缓存未命中时用发现结果兜底填入,
|
||||
// 且失效后第一次调用计一次未命中。
|
||||
func TestSchemaCache_Prepare_MissFillsFromFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := NewSchemaCache()
|
||||
fallback := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "default": "fallback"},
|
||||
},
|
||||
}
|
||||
out, err := cache.Prepare("s", "t", fallback, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare: %v", err)
|
||||
}
|
||||
if out["name"] != "fallback" {
|
||||
t.Fatalf("兜底 schema 的默认值未生效:%v", out)
|
||||
}
|
||||
if _, misses := cache.Stats(); misses != 1 {
|
||||
t.Fatalf("misses = %d, 期望 1", misses)
|
||||
}
|
||||
if cache.Len() != 1 {
|
||||
t.Fatalf("未命中后没有回填缓存:Len = %d", cache.Len())
|
||||
}
|
||||
|
||||
cache.Invalidate("s")
|
||||
if _, err := cache.Prepare("s", "t", nil, map[string]any{"x": 1}); err != nil {
|
||||
t.Fatalf("无 schema 时应降级为不校验:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCache_Put_BadSchemaDegrades 无法解析的 schema 只降级为「不校验」,不阻断调用。
|
||||
func TestSchemaCache_Put_BadSchemaDegrades(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := NewSchemaCache()
|
||||
bad := map[string]any{"type": "object", "properties": map[string]any{"a": map[string]any{"$ref": "#/$defs/does-not-exist"}}}
|
||||
err := cache.Put("s", "t", bad)
|
||||
if err == nil {
|
||||
t.Fatal("病态 schema 未报错,期望解析失败可见")
|
||||
}
|
||||
if _, ok := cache.Get("s", "t"); !ok {
|
||||
t.Fatal("解析失败时原始 schema 没有缓存下来(应降级保留)")
|
||||
}
|
||||
if _, err := cache.Prepare("s", "t", nil, map[string]any{"whatever": true}); err != nil {
|
||||
t.Fatalf("降级路径不应报错:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaCache_Prepare_NilSchemaSafe nil schema 走空对象兜底,不 panic。
|
||||
func TestSchemaCache_Prepare_NilSchemaSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
cache := NewSchemaCache()
|
||||
if err := cache.Put("s", "t", nil); err != nil {
|
||||
t.Fatalf("Put(nil): %v", err)
|
||||
}
|
||||
if _, err := cache.Prepare("s", "t", nil, nil); err != nil {
|
||||
t.Fatalf("Prepare(nil args): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// sseTransport speaks JSON-RPC 2.0 to an MCP server over SSE. It opens a
|
||||
// long-lived GET stream for server-to-client messages and POSTs client-to-server
|
||||
// requests to the message endpoint (discovered via an endpoint event, falling
|
||||
// back to the configured URL).
|
||||
type sseTransport struct {
|
||||
cfg MCPConfig
|
||||
log *slog.Logger
|
||||
url string
|
||||
endpoint string
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[string]chan []byte
|
||||
resp *http.Response
|
||||
body io.ReadCloser
|
||||
closeCh chan struct{}
|
||||
closeOnce sync.Once
|
||||
started bool
|
||||
onNotification func(method string)
|
||||
}
|
||||
|
||||
// setOnNotification records the handler invoked when the server sends a
|
||||
// server-initiated notification (a message with no id) over the SSE stream.
|
||||
func (s *sseTransport) setOnNotification(fn func(method string)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.onNotification = fn
|
||||
}
|
||||
|
||||
// maxSSEResponse bounds how much of a POST reply body is read before falling
|
||||
// back to the SSE stream.
|
||||
const maxSSEResponse = 1 << 20
|
||||
|
||||
// newSSETransport builds an SSE transport for cfg.
|
||||
func newSSETransport(cfg MCPConfig, log *slog.Logger) *sseTransport {
|
||||
return &sseTransport{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
url: cfg.URL,
|
||||
endpoint: cfg.URL,
|
||||
pending: make(map[string]chan []byte),
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start opens the SSE stream and launches the reader goroutine.
|
||||
func (s *sseTransport) Start(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("mcp sse start: %w", err)
|
||||
}
|
||||
if s.cfg.URL == "" {
|
||||
return fmt.Errorf("mcp sse: %w", ErrNoTransport)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, s.cfg.URL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp sse get: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
s.applyAuth(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp sse connect %s: %w", s.cfg.URL, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("mcp sse connect %s: status %d", s.cfg.URL, resp.StatusCode)
|
||||
}
|
||||
s.resp = resp
|
||||
s.body = resp.Body
|
||||
s.started = true
|
||||
go s.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLoop parses SSE events and dispatches message events to pending calls.
|
||||
func (s *sseTransport) readLoop() {
|
||||
r := bufio.NewReader(s.body)
|
||||
var event, data string
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch {
|
||||
case line == "":
|
||||
if data != "" {
|
||||
s.dispatch(event, data)
|
||||
}
|
||||
event, data = "", ""
|
||||
case strings.HasPrefix(line, "event:"):
|
||||
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
d := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if data == "" {
|
||||
data = d
|
||||
} else {
|
||||
data += "\n" + d
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatch handles a single SSE event.
|
||||
func (s *sseTransport) dispatch(event, data string) {
|
||||
switch event {
|
||||
case "endpoint":
|
||||
if e := strings.TrimSpace(data); e != "" {
|
||||
s.mu.Lock()
|
||||
s.endpoint = e
|
||||
s.mu.Unlock()
|
||||
}
|
||||
case "message":
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
if resp.ID == nil {
|
||||
// A server-initiated notification; hand it to the client's handler.
|
||||
// The handler must not re-enter this transport synchronously.
|
||||
if resp.Method != "" {
|
||||
s.mu.Lock()
|
||||
fn := s.onNotification
|
||||
s.mu.Unlock()
|
||||
if fn != nil {
|
||||
fn(resp.Method)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
id, ok := resp.ID.(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
ch, has := s.pending[id]
|
||||
delete(s.pending, id)
|
||||
s.mu.Unlock()
|
||||
if has {
|
||||
ch <- []byte(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call POSTs a request and waits for a response, either from the POST reply body
|
||||
// or from the SSE stream.
|
||||
func (s *sseTransport) Call(ctx context.Context, method string, params any, result any) error {
|
||||
s.mu.Lock()
|
||||
if !s.started {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("mcp sse %s: %w", method, ErrNotConnected)
|
||||
}
|
||||
s.nextID++
|
||||
id := strconv.Itoa(s.nextID)
|
||||
ch := make(chan []byte, 1)
|
||||
s.pending[id] = ch
|
||||
endpoint := s.endpoint
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.pending, id)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
reqBody, err := encodeRequest(id, method, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp sse %s: %w", method, err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp sse %s: %w", method, err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
s.applyAuth(httpReq)
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp sse %s post: %w", method, err)
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxSSEResponse))
|
||||
resp.Body.Close()
|
||||
|
||||
var direct rpcResponse
|
||||
if json.Unmarshal(bytes.TrimSpace(body), &direct) == nil && decodeIDString(direct, id) {
|
||||
return s.applyResponse(direct, method, result)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-ch:
|
||||
var rr rpcResponse
|
||||
if err := json.Unmarshal(data, &rr); err != nil {
|
||||
return fmt.Errorf("mcp sse %s decode: %w", method, err)
|
||||
}
|
||||
return s.applyResponse(rr, method, result)
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("mcp sse %s: %w", method, ctx.Err())
|
||||
case <-s.closeCh:
|
||||
return fmt.Errorf("mcp sse %s: %w", method, ErrClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// applyResponse decodes a JSON-RPC response into result, surfacing an error
|
||||
// object if present.
|
||||
func (s *sseTransport) applyResponse(resp rpcResponse, method string, result any) error {
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("mcp sse %s: %w: %w", method, ErrRPC, resp.Error)
|
||||
}
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(resp.Result, result); err != nil {
|
||||
return fmt.Errorf("mcp sse %s decode: %w", method, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notify POSTs a fire-and-forget JSON-RPC notification.
|
||||
func (s *sseTransport) notify(ctx context.Context, method string, params any) error {
|
||||
s.mu.Lock()
|
||||
started := s.started
|
||||
endpoint := s.endpoint
|
||||
s.mu.Unlock()
|
||||
if !started {
|
||||
return fmt.Errorf("mcp sse %s: %w", method, ErrNotConnected)
|
||||
}
|
||||
reqBody, err := encodeRequest(nil, method, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp notify %s: %w", method, err)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp notify %s: %w", method, err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
s.applyAuth(httpReq)
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp notify %s: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyAuth sets the bearer credential an authenticated server requires. It is
|
||||
// a no-op when the config carries no API key.
|
||||
func (s *sseTransport) applyAuth(req *http.Request) {
|
||||
if s.cfg.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the SSE stream and any pending responses.
|
||||
func (s *sseTransport) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.closeCh)
|
||||
if s.body != nil {
|
||||
_ = s.body.Close()
|
||||
}
|
||||
if s.resp != nil && s.resp.Body != nil {
|
||||
_ = s.resp.Body.Close()
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pending = make(map[string]chan []byte)
|
||||
s.mu.Unlock()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// postRespondServer serves a GET stream that ends immediately and answers every
|
||||
// POST directly with a JSON-RPC response body (the non-streaming reply path).
|
||||
func postRespondServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req rpcRequest
|
||||
_ = json.Unmarshal(body, &req)
|
||||
resp := `{"jsonrpc":"2.0","id":` + idJSON(req.ID) + `,"result":{"content":[{"type":"text","text":"direct"}]}}`
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(resp))
|
||||
}))
|
||||
}
|
||||
|
||||
// streamServer pushes responses to the POST caller back through the SSE stream.
|
||||
func streamServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
push := make(chan string, 8)
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
f, _ := w.(http.Flusher)
|
||||
_, _ = w.Write([]byte("event: endpoint\ndata: " + srv.URL + "\n\n"))
|
||||
f.Flush()
|
||||
for {
|
||||
select {
|
||||
case data := <-push:
|
||||
_, _ = w.Write([]byte("event: message\ndata: " + data + "\n\n"))
|
||||
f.Flush()
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
default:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req rpcRequest
|
||||
_ = json.Unmarshal(body, &req)
|
||||
resp := `{"jsonrpc":"2.0","id":` + idJSON(req.ID) + `,"result":{"content":[{"type":"text","text":"stream"}]}}`
|
||||
push <- resp
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
}))
|
||||
return srv
|
||||
}
|
||||
|
||||
// idJSON renders a request id as a JSON string literal when it is a string.
|
||||
func idJSON(id any) string {
|
||||
if s, ok := id.(string); ok {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
return `null`
|
||||
}
|
||||
|
||||
func TestSSE_Call_DirectResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := postRespondServer(t)
|
||||
defer srv.Close()
|
||||
tr := newSSETransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
defer tr.Close()
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
var res callToolResult
|
||||
if err := tr.Call(context.Background(), "tools/call", callToolParams{Name: "add", Arguments: map[string]any{}}, &res); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if len(res.Content) != 1 || res.Content[0].Text != "direct" {
|
||||
t.Fatalf("Content = %+v, want [direct]", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_Call_MessageEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := streamServer(t)
|
||||
defer srv.Close()
|
||||
tr := newSSETransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
defer tr.Close()
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
var res callToolResult
|
||||
if err := tr.Call(context.Background(), "tools/call", callToolParams{Name: "add", Arguments: map[string]any{}}, &res); err != nil {
|
||||
t.Fatalf("Call: %v", err)
|
||||
}
|
||||
if len(res.Content) != 1 || res.Content[0].Text != "stream" {
|
||||
t.Fatalf("Content = %+v, want [stream]", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_Start_Non200(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
tr := newSSETransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
if err := tr.Start(context.Background()); err == nil {
|
||||
t.Fatal("Start succeeded on a non-200 response, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_Call_NotStarted(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newSSETransport(MCPConfig{Name: "s", URL: "http://example.invalid"}, defaultLogger())
|
||||
err := tr.Call(context.Background(), "tools/list", nil, nil)
|
||||
if !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("Call error = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_Call_ContextTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A server whose GET stream stays open and whose POST replies with no body
|
||||
// and never pushes forces the Call to wait on its context deadline.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr := newSSETransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer tr.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
err := tr.Call(ctx, "tools/call", callToolParams{Name: "add"}, &callToolResult{})
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("Call error = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_Start_NoURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := newSSETransport(MCPConfig{Name: "s"}, defaultLogger())
|
||||
err := tr.Start(context.Background())
|
||||
if !errors.Is(err, ErrNoTransport) {
|
||||
t.Fatalf("Start error = %v, want ErrNoTransport", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSE_Close_ClosesPendingAndBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := postRespondServer(t)
|
||||
defer srv.Close()
|
||||
tr := newSSETransport(MCPConfig{Name: "s", URL: srv.URL}, defaultLogger())
|
||||
if err := tr.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
// Put a sentinel pending call in place.
|
||||
tr.mu.Lock()
|
||||
tr.pending["999"] = make(chan []byte, 1)
|
||||
tr.mu.Unlock()
|
||||
if err := tr.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
tr.mu.Lock()
|
||||
empty := len(tr.pending) == 0
|
||||
tr.mu.Unlock()
|
||||
if !empty {
|
||||
t.Fatalf("pending not cleared after Close")
|
||||
}
|
||||
if err := tr.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// stdioTransport runs an MCP server as a child process and speaks
|
||||
// newline-delimited JSON-RPC 2.0 to its stdin/stdout.
|
||||
type stdioTransport struct {
|
||||
cfg MCPConfig
|
||||
log *slog.Logger
|
||||
line *lineRPC
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
closeOnce sync.Once
|
||||
closed bool
|
||||
onNotification func(method string)
|
||||
}
|
||||
|
||||
// setOnNotification records the handler invoked when the server sends a
|
||||
// notification frame (a JSON-RPC message with no id). It is wired by the client
|
||||
// before initialize so server notifications are not lost.
|
||||
func (s *stdioTransport) setOnNotification(fn func(method string)) {
|
||||
s.onNotification = fn
|
||||
if s.line != nil {
|
||||
s.line.onNotification = fn
|
||||
}
|
||||
}
|
||||
|
||||
// newStdioTransport builds a stdio transport for cfg.
|
||||
func newStdioTransport(cfg MCPConfig, log *slog.Logger) *stdioTransport {
|
||||
return &stdioTransport{cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
// Start spawns the subprocess and wires up the request/response streams.
|
||||
func (s *stdioTransport) Start(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("mcp stdio start: %w", err)
|
||||
}
|
||||
if s.cfg.Command == "" {
|
||||
return fmt.Errorf("mcp stdio: %w", ErrNoTransport)
|
||||
}
|
||||
cmd := exec.Command(s.cfg.Command, s.cfg.Args...)
|
||||
cmd.Env = envWith(os.Environ(), s.cfg.Env)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp stdio stdin: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp stdio stdout: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp stdio stderr: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("mcp stdio start %q: %w", s.cfg.Command, err)
|
||||
}
|
||||
s.cmd = cmd
|
||||
s.stdin = stdin
|
||||
s.line = &lineRPC{in: stdin, out: bufio.NewReader(stdout), log: s.log, onNotification: s.onNotification}
|
||||
go s.drainStderr(stderr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Call sends a request over stdio and decodes the response.
|
||||
func (s *stdioTransport) Call(ctx context.Context, method string, params any, result any) error {
|
||||
if s.line == nil {
|
||||
return fmt.Errorf("mcp stdio %s: %w", method, ErrNotConnected)
|
||||
}
|
||||
return s.line.call(ctx, method, params, result)
|
||||
}
|
||||
|
||||
// Close terminates the child process and closes its streams.
|
||||
func (s *stdioTransport) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
s.closed = true
|
||||
if s.stdin != nil {
|
||||
_ = s.stdin.Close()
|
||||
}
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
if s.cmd != nil {
|
||||
_ = s.cmd.Wait()
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainStderr reads and logs the child's standard error at debug level so server
|
||||
// diagnostics are visible without blocking the process.
|
||||
func (s *stdioTransport) drainStderr(r io.Reader) {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
s.log.Debug("mcp server stderr", "name", s.cfg.Name, "msg", strings.TrimSpace(string(buf[:n])))
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lineRPC carries newline-delimited JSON-RPC 2.0 over a stream pair. Calls are
|
||||
// serialized so at most one request is in flight at a time, which keeps id
|
||||
// matching simple and deterministic.
|
||||
type lineRPC struct {
|
||||
in io.Writer
|
||||
out *bufio.Reader
|
||||
log *slog.Logger
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
onNotification func(method string)
|
||||
}
|
||||
|
||||
// call writes a request and reads until the matching response arrives.
|
||||
func (l *lineRPC) call(ctx context.Context, method string, params any, result any) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return fmt.Errorf("mcp %s: %w", method, err)
|
||||
}
|
||||
l.nextID++
|
||||
id := strconv.Itoa(l.nextID)
|
||||
req, err := encodeRequest(id, method, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp %s: %w", method, err)
|
||||
}
|
||||
req = append(req, '\n')
|
||||
if _, err := l.in.Write(req); err != nil {
|
||||
return fmt.Errorf("mcp %s write: %w", method, err)
|
||||
}
|
||||
return l.readResponse(id, method, result)
|
||||
}
|
||||
|
||||
// readResponse reads lines until a response matching id arrives, skipping blank
|
||||
// lines, malformed frames and notifications (no id).
|
||||
func (l *lineRPC) readResponse(id, method string, result any) error {
|
||||
for {
|
||||
line, err := l.out.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp %s read: %w", method, err)
|
||||
}
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
l.log.Debug("mcp ignoring malformed frame", "line", string(line))
|
||||
continue
|
||||
}
|
||||
if resp.ID == nil {
|
||||
// A server-initiated notification (no id); hand it to the client's
|
||||
// handler if one is installed. The handler must not re-enter this
|
||||
// transport synchronously (see the serverNotifier contract).
|
||||
if resp.Method != "" && l.onNotification != nil {
|
||||
l.onNotification(resp.Method)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !decodeIDString(resp, id) {
|
||||
continue
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("mcp %s: %w: %w", method, ErrRPC, resp.Error)
|
||||
}
|
||||
if result != nil {
|
||||
if err := json.Unmarshal(resp.Result, result); err != nil {
|
||||
return fmt.Errorf("mcp %s decode: %w", method, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// notify writes a fire-and-forget JSON-RPC notification (no id).
|
||||
func (l *lineRPC) notify(ctx context.Context, method string, params any) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
req, err := encodeRequest(nil, method, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp notify %s: %w", method, err)
|
||||
}
|
||||
req = append(req, '\n')
|
||||
if _, err := l.in.Write(req); err != nil {
|
||||
return fmt.Errorf("mcp notify %s write: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// envWith returns base with extra merged over it (an extra key overrides a
|
||||
// same-named base entry). A nil/empty extra returns base unchanged.
|
||||
func envWith(base []string, extra map[string]string) []string {
|
||||
if len(extra) == 0 {
|
||||
return base
|
||||
}
|
||||
out := make([]string, 0, len(base)+len(extra))
|
||||
for _, kv := range base {
|
||||
if k, _, ok := strings.Cut(kv, "="); ok {
|
||||
if _, dup := extra[k]; dup {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
for k, v := range extra {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newLineRPC builds a lineRPC reading the supplied pre-encoded responses.
|
||||
func newLineRPC(responses string) *lineRPC {
|
||||
return &lineRPC{
|
||||
in: &bytes.Buffer{},
|
||||
out: bufio.NewReader(bytes.NewReader([]byte(responses))),
|
||||
log: defaultLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_Call_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := `{"jsonrpc":"2.0","id":"1","result":{"ok":true}}` + "\n"
|
||||
l := newLineRPC(out)
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := l.call(context.Background(), "tools/call", map[string]any{}, &res); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if !res.OK {
|
||||
t.Fatal("res.OK = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_Call_SkipsNotificationThenMatches(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := `{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" +
|
||||
`{"jsonrpc":"2.0","id":"1","result":{}}` + "\n"
|
||||
l := newLineRPC(out)
|
||||
var res map[string]any
|
||||
if err := l.call(context.Background(), "initialize", map[string]any{}, &res); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_Call_SkipsMismatchedID(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := `{"jsonrpc":"2.0","id":"2","result":{}}` + "\n" +
|
||||
`{"jsonrpc":"2.0","id":"1","result":{"ok":true}}` + "\n"
|
||||
l := newLineRPC(out)
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := l.call(context.Background(), "tools/call", nil, &res); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if !res.OK {
|
||||
t.Fatal("res.OK = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_Call_RPCError(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := `{"jsonrpc":"2.0","id":"1","error":{"code":-32000,"message":"boom"}}` + "\n"
|
||||
l := newLineRPC(out)
|
||||
err := l.call(context.Background(), "tools/call", nil, nil)
|
||||
if !errors.Is(err, ErrRPC) {
|
||||
t.Fatalf("err = %v, want ErrRPC", err)
|
||||
}
|
||||
var rpcErr *rpcError
|
||||
if !errors.As(err, &rpcErr) {
|
||||
t.Fatalf("err = %v, want *rpcError in chain", err)
|
||||
}
|
||||
if rpcErr.Message != "boom" {
|
||||
t.Fatalf("rpcErr.Message = %q, want boom", rpcErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_Call_LastResponseDecodeError(t *testing.T) {
|
||||
t.Parallel()
|
||||
// A matching id whose result does not fit the target type must surface a
|
||||
// decode error.
|
||||
out := `{"jsonrpc":"2.0","id":"1","result":{"ok":"not-a-bool"}}` + "\n"
|
||||
l := newLineRPC(out)
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
err := l.call(context.Background(), "tools/list", nil, &res)
|
||||
if err == nil {
|
||||
t.Fatal("call with mismatched result succeeded, want decode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_Call_EOF(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := newLineRPC("")
|
||||
err := l.call(context.Background(), "tools/list", nil, nil)
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("err = %v, want io.EOF", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_Call_MalformedFrameIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
out := `not json` + "\n" + `{"jsonrpc":"2.0","id":"1","result":{"ok":true}}` + "\n"
|
||||
l := newLineRPC(out)
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := l.call(context.Background(), "tools/call", nil, &res); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if !res.OK {
|
||||
t.Fatal("res.OK = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_WriteError(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := &lineRPC{in: failWriter{}, out: bufio.NewReader(bytes.NewReader(nil)), log: defaultLogger()}
|
||||
err := l.call(context.Background(), "tools/list", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("call with failing writer succeeded, want error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "write") {
|
||||
t.Fatalf("err = %v, want write error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_Notify_WritesNoID(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
l := &lineRPC{in: &buf, out: bufio.NewReader(bytes.NewReader(nil)), log: defaultLogger()}
|
||||
if err := l.notify(context.Background(), "notifications/initialized", map[string]any{}); err != nil {
|
||||
t.Fatalf("notify: %v", err)
|
||||
}
|
||||
body := buf.String()
|
||||
if !strings.Contains(body, "notifications/initialized") {
|
||||
t.Fatalf("body = %q, want notifications/initialized", body)
|
||||
}
|
||||
if strings.Contains(body, `"id"`) {
|
||||
t.Fatalf("body = %q, must not contain id (notification)", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineRPC_ContextCanceled(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
l := newLineRPC(`{"jsonrpc":"2.0","id":"1","result":{}}` + "\n")
|
||||
err := l.call(ctx, "initialize", nil, nil)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioTransport_Start_NoCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newStdioTransport(MCPConfig{Name: "s"}, defaultLogger())
|
||||
err := s.Start(context.Background())
|
||||
if !errors.Is(err, ErrNoTransport) {
|
||||
t.Fatalf("Start error = %v, want ErrNoTransport", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioTransport_Start_InvalidCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newStdioTransport(MCPConfig{Name: "s", Command: "cyrene-no-such-binary-xyz"}, defaultLogger())
|
||||
if err := s.Start(context.Background()); err == nil {
|
||||
t.Fatal("Start with bogus command succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioTransport_Call_NotStarted(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newStdioTransport(MCPConfig{Name: "s", Command: "x"}, defaultLogger())
|
||||
err := s.Call(context.Background(), "tools/list", nil, nil)
|
||||
if !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("Call error = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioTransport_Close_NoCmd(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newStdioTransport(MCPConfig{Name: "s"}, defaultLogger())
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// failWriter returns an error on every write.
|
||||
type failWriter struct{}
|
||||
|
||||
// Write always fails.
|
||||
func (failWriter) Write(_ []byte) (int, error) {
|
||||
return 0, errors.New("write boom")
|
||||
}
|
||||
+104
-24
@@ -2,47 +2,91 @@ package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// mcpTool adapts an MCP tool to the pkg/plugins/sdk Tool interface so it can be
|
||||
// registered and invoked like any built-in tool. It is immutable and safe for
|
||||
// concurrent use.
|
||||
// ToolInfo 是 MCP 服务器暴露的一把工具。
|
||||
type ToolInfo struct {
|
||||
// Name 是调用该工具用的标识。
|
||||
Name string
|
||||
// Description 说明工具做什么。
|
||||
Description string
|
||||
// InputSchema 是工具的 JSON Schema,通常是
|
||||
// {"type":"object","properties":{...}}。
|
||||
InputSchema map[string]any
|
||||
}
|
||||
|
||||
// ContentBlock 是工具调用结果里的一个内容块(MCP content block)。
|
||||
type ContentBlock struct {
|
||||
// Type 是块类型,最常见的是 "text";非文本块只保留类型标记。
|
||||
Type string `json:"type"`
|
||||
// Text 是文本载荷。
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// ToolResult 是一次 tools/call 的结构化结果。
|
||||
type ToolResult struct {
|
||||
// Content 是服务器返回的内容块。
|
||||
Content []ContentBlock
|
||||
// Structured 是服务器返回的结构化结果(structuredContent),可能为 nil。
|
||||
Structured any
|
||||
// IsError 表示服务器把这次调用标成了错误。
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// toolInfoFrom 把 SDK 的工具描述转成本包的 ToolInfo。
|
||||
func toolInfoFrom(t *mcpsdk.Tool) ToolInfo {
|
||||
return ToolInfo{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: schemaMap(t.InputSchema),
|
||||
}
|
||||
}
|
||||
|
||||
// mcpTool 把一把 MCP 工具适配成 pkg/plugins/sdk 的 Tool 接口,
|
||||
// 使其能被注册进 registry、被 agent 当作普通工具调用。字段不可变,可并发使用。
|
||||
type mcpTool struct {
|
||||
client *Client
|
||||
info ToolInfo
|
||||
}
|
||||
|
||||
// Name returns the MCP tool identifier.
|
||||
func (t *mcpTool) Name() string {
|
||||
return t.info.Name
|
||||
}
|
||||
// Name 返回 MCP 工具标识。
|
||||
func (t *mcpTool) Name() string { return t.info.Name }
|
||||
|
||||
// Description returns the MCP tool description.
|
||||
func (t *mcpTool) Description() string {
|
||||
return t.info.Description
|
||||
}
|
||||
// Description 返回 MCP 工具描述。
|
||||
func (t *mcpTool) Description() string { return t.info.Description }
|
||||
|
||||
// Parameters returns the MCP tool input schema, defaulting to an empty object
|
||||
// schema when the server omits one.
|
||||
// Parameters 返回 MCP input schema;服务器未给 schema 时返回无约束空对象 schema。
|
||||
func (t *mcpTool) Parameters() (map[string]any, error) {
|
||||
if t.info.InputSchema == nil {
|
||||
return map[string]any{"type": "object", "properties": map[string]any{}}, nil
|
||||
return emptyObjectSchema(), nil
|
||||
}
|
||||
return t.info.InputSchema, nil
|
||||
}
|
||||
|
||||
// Execute invokes the MCP tool and returns its concatenated text content.
|
||||
// Execute 调用 MCP 工具并返回其结果文本。
|
||||
// 参数先经 input schema 缓存校验 + 补默认值(design/09 §1.1),
|
||||
// 文本块全为空时退回结构化结果的 JSON 文本,保证模型总能看见东西。
|
||||
func (t *mcpTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
res, err := t.client.CallTool(ctx, t.info.Name, args)
|
||||
params, err := normalizeArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return contentText(res.Content), nil
|
||||
res, err := t.client.callTool(ctx, t.info.Name, params, t.info.InputSchema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if text := contentText(res.Content); text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return structuredText(res.Structured)
|
||||
}
|
||||
|
||||
// contentText concatenates the text blocks of a tool result into a single
|
||||
// string, ignoring non-text blocks.
|
||||
// contentText 把工具结果里的文本块拼成一个字符串,忽略非文本块。
|
||||
func contentText(content []ContentBlock) string {
|
||||
var b strings.Builder
|
||||
for _, blk := range content {
|
||||
@@ -53,11 +97,47 @@ func contentText(content []ContentBlock) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// toContentBlocks converts wire content blocks to the public content block type.
|
||||
func toContentBlocks(wire []contentBlock) []ContentBlock {
|
||||
out := make([]ContentBlock, 0, len(wire))
|
||||
for _, c := range wire {
|
||||
out = append(out, ContentBlock{Type: c.Type, Text: c.Text})
|
||||
// structuredText 把结构化结果序列化成 JSON 文本;无结构化结果时返回空串。
|
||||
func structuredText(structured any) (string, error) {
|
||||
if structured == nil {
|
||||
return "", nil
|
||||
}
|
||||
data, err := json.Marshal(structured)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal structured tool result: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// toContentBlocks 把 SDK 内容块转成本包结构。非文本块只保留类型标记,
|
||||
// 不把 base64 大块塞进模型上下文。
|
||||
func toContentBlocks(content []mcpsdk.Content) []ContentBlock {
|
||||
out := make([]ContentBlock, 0, len(content))
|
||||
for _, blk := range content {
|
||||
if text, ok := blk.(*mcpsdk.TextContent); ok {
|
||||
out = append(out, ContentBlock{Type: "text", Text: text.Text})
|
||||
continue
|
||||
}
|
||||
out = append(out, ContentBlock{Type: contentTypeOf(blk)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// contentTypeOf 返回内容块的线上类型名(如 image / audio / resource);
|
||||
// 未知类型统一记作 non-text。
|
||||
func contentTypeOf(blk mcpsdk.Content) string {
|
||||
switch blk.(type) {
|
||||
case *mcpsdk.ImageContent:
|
||||
return "image"
|
||||
case *mcpsdk.AudioContent:
|
||||
return "audio"
|
||||
case *mcpsdk.ResourceLink, *mcpsdk.EmbeddedResource:
|
||||
return "resource"
|
||||
case *mcpsdk.ToolUseContent:
|
||||
return "tool_use"
|
||||
case *mcpsdk.ToolResultContent:
|
||||
return "tool_result"
|
||||
default:
|
||||
return "non-text"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// newTransport 按 cfg 选传输。显式 TransportType 优先(stdio / sse / http,http 与
|
||||
// streamable 等价,对齐 design/09 §2.1 的枚举);为空则沿用历史推断:
|
||||
// 有 Command 走 stdio,有 URL 走 SSE(既有 URL 配置多为 /sse 端点)。
|
||||
// APIKey 通过自定义 RoundTripper 以 Authorization: Bearer 发送。
|
||||
func newTransport(cfg MCPConfig) (mcpsdk.Transport, error) {
|
||||
switch cfg.TransportType {
|
||||
case "http", "streamable":
|
||||
return httpURLTransport(cfg, true)
|
||||
case "sse":
|
||||
return httpURLTransport(cfg, false)
|
||||
case "stdio":
|
||||
return commandTransport(cfg)
|
||||
}
|
||||
switch {
|
||||
case cfg.Command != "":
|
||||
return commandTransport(cfg)
|
||||
case cfg.URL != "":
|
||||
return httpURLTransport(cfg, false)
|
||||
default:
|
||||
return nil, fmt.Errorf("mcp: %w", ErrNoTransport)
|
||||
}
|
||||
}
|
||||
|
||||
// httpURLTransport 构建 URL 型传输:streamable=true 走 Streamable HTTP(2025-11-25
|
||||
// 规范首选),否则走旧式 SSE。
|
||||
func httpURLTransport(cfg MCPConfig, streamable bool) (mcpsdk.Transport, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, fmt.Errorf("mcp transport %s: %w", cfg.TransportType, ErrNoTransport)
|
||||
}
|
||||
client := httpClientFor(cfg)
|
||||
if streamable {
|
||||
return &mcpsdk.StreamableClientTransport{Endpoint: cfg.URL, HTTPClient: client}, nil
|
||||
}
|
||||
return &mcpsdk.SSEClientTransport{Endpoint: cfg.URL, HTTPClient: client}, nil
|
||||
}
|
||||
|
||||
// commandTransport 拉起 stdio 子进程,按换行分隔的 JSON-RPC 通信。
|
||||
func commandTransport(cfg MCPConfig) (mcpsdk.Transport, error) {
|
||||
if cfg.Command == "" {
|
||||
return nil, fmt.Errorf("mcp transport stdio: %w", ErrNoTransport)
|
||||
}
|
||||
cmd := exec.Command(cfg.Command, cfg.Args...)
|
||||
cmd.Env = envWith(os.Environ(), cfg.Env)
|
||||
return &mcpsdk.CommandTransport{Command: cmd}, nil
|
||||
}
|
||||
|
||||
// httpClientFor 返回带鉴权头的 HTTP 客户端;没有 APIKey 时返回 nil(用 SDK 默认)。
|
||||
func httpClientFor(cfg MCPConfig) *http.Client {
|
||||
if cfg.APIKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &http.Client{Transport: &bearerTransport{base: http.DefaultTransport, token: cfg.APIKey}}
|
||||
}
|
||||
|
||||
// bearerTransport 给每个请求加 Authorization: Bearer。凭据不写日志、不进错误消息。
|
||||
type bearerTransport struct {
|
||||
base http.RoundTripper
|
||||
token string
|
||||
}
|
||||
|
||||
// RoundTrip 实现 http.RoundTripper。
|
||||
func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
clone := req.Clone(req.Context())
|
||||
clone.Header.Set("Authorization", "Bearer "+t.token)
|
||||
return t.base.RoundTrip(clone)
|
||||
}
|
||||
|
||||
// envWith 返回 base 叠加 extra 后的环境变量(同名以 extra 为准);extra 为空时原样返回。
|
||||
func envWith(base []string, extra map[string]string) []string {
|
||||
if len(extra) == 0 {
|
||||
return base
|
||||
}
|
||||
out := make([]string, 0, len(base)+len(extra))
|
||||
for _, kv := range base {
|
||||
if k, _, ok := strings.Cut(kv, "="); ok {
|
||||
if _, dup := extra[k]; dup {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
for k, v := range extra {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// TestNewTransport_SelectsByType 覆盖显式传输类型与历史推断。
|
||||
func TestNewTransport_SelectsByType(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg MCPConfig
|
||||
want any
|
||||
}{
|
||||
{name: "http", cfg: MCPConfig{URL: "http://x", TransportType: "http"}, want: &mcpsdk.StreamableClientTransport{}},
|
||||
{name: "streamable 别名", cfg: MCPConfig{URL: "http://x", TransportType: "streamable"}, want: &mcpsdk.StreamableClientTransport{}},
|
||||
{name: "sse", cfg: MCPConfig{URL: "http://x", TransportType: "sse"}, want: &mcpsdk.SSEClientTransport{}},
|
||||
{name: "stdio", cfg: MCPConfig{Command: "x", TransportType: "stdio"}, want: &mcpsdk.CommandTransport{}},
|
||||
{name: "推断 stdio", cfg: MCPConfig{Command: "x"}, want: &mcpsdk.CommandTransport{}},
|
||||
{name: "推断 sse", cfg: MCPConfig{URL: "http://x"}, want: &mcpsdk.SSEClientTransport{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr, err := newTransport(tt.cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("newTransport: %v", err)
|
||||
}
|
||||
if got, want := typeName(tr), typeName(tt.want); got != want {
|
||||
t.Fatalf("传输类型 = %s, 期望 %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTransport_NoTransport 两个字段都空时必须报 ErrNoTransport。
|
||||
func TestNewTransport_NoTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := newTransport(MCPConfig{Name: "s"}); err == nil {
|
||||
t.Fatal("没有 Command/URL 却选出了传输")
|
||||
}
|
||||
// 显式类型但缺必要字段同样要报错(不静默构造坏传输)。
|
||||
if _, err := newTransport(MCPConfig{TransportType: "http"}); err == nil {
|
||||
t.Fatal("TransportType=http 但 URL 为空却没有报错")
|
||||
}
|
||||
if _, err := newTransport(MCPConfig{TransportType: "stdio"}); err == nil {
|
||||
t.Fatal("TransportType=stdio 但 Command 为空却没有报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerTransport_SetsAuthorization 凭据以 Authorization: Bearer 下发,
|
||||
// 并且不污染原始请求。
|
||||
func TestBearerTransport_SetsAuthorization(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := make(chan string, 1)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := httpClientFor(MCPConfig{APIKey: "sk-1234"})
|
||||
if client == nil {
|
||||
t.Fatal("配了 APIKey 却没有返回自定义 http client")
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if auth := <-got; auth != "Bearer sk-1234" {
|
||||
t.Fatalf("Authorization = %q, 期望 Bearer sk-1234", auth)
|
||||
}
|
||||
if req.Header.Get("Authorization") != "" {
|
||||
t.Fatal("原始请求被就地改写了")
|
||||
}
|
||||
if httpClientFor(MCPConfig{}) != nil {
|
||||
t.Fatal("无 APIKey 时应返回 nil(用 SDK 默认客户端)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvWith_OverridesAndPreserves stdio 子进程环境变量叠加语义。
|
||||
func TestEnvWith_OverridesAndPreserves(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := []string{"PATH=/usr/bin", "HOME=/root"}
|
||||
got := envWith(base, map[string]string{"HOME": "/tmp", "TOKEN": "abc"})
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, 期望 3(%v)", len(got), got)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, kv := range got {
|
||||
seen[kv] = true
|
||||
}
|
||||
if !seen["PATH=/usr/bin"] || !seen["HOME=/tmp"] || !seen["TOKEN=abc"] {
|
||||
t.Fatalf("合并后的环境 = %v", got)
|
||||
}
|
||||
if len(envWith(base, nil)) != len(base) {
|
||||
t.Fatal("空 extra 改变了基础环境")
|
||||
}
|
||||
}
|
||||
|
||||
// typeName 返回传输的类别名,避免在断言里依赖具体指针比较。
|
||||
func typeName(v any) string {
|
||||
switch v.(type) {
|
||||
case *mcpsdk.StreamableClientTransport:
|
||||
return "streamable"
|
||||
case *mcpsdk.SSEClientTransport:
|
||||
return "sse"
|
||||
case *mcpsdk.CommandTransport:
|
||||
return "stdio"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user