feat(mcp): Top5#3 对齐 MCP Go SDK——协议版本协商 + list_changed 差量刷新 + keepalive + Streamable HTTP
- 协议版本协商:initialize 先发最高版本(2025-11-25)并声明能力集合,遇 UnsupportedProtocolVersionError(-32022)/服务器不支持版本 → negotiateMutuallySupportedVersion 求出双方最高共同版本降级重试(最多2次);无共同版本→ErrUnsupportedProtocolVersion。 协商结果记 negotiatedVersion,HTTP 传输回填 Mcp-Protocol-Version 头。 - tools/list_changed 订阅:capabilities 声明 tools.listChanged:true;传输读无 id 帧路由到 handleServerNotification;refreshPending 原子旗标合并突发 + 新 goroutine 调 onToolListChanged (避免 stdio lineRPC 重入自锁);Registry 真差量刷新(重新 DiscoverTools→移除消失/注册新增, 跳过同名冲突保外来工具)。 - keepalive:WithKeepAlive(interval,failures) 起 goroutine 定时 Call(ping),连续失败≥阈值关 session(默认3,可配);服务器不支持 ping(-32601)停 keepalive 不误关;ctx+Close 取消不泄漏。 - Streamable HTTP 传输:newTransport 支持 TransportType=http|streamable(保留 stdio/SSE 推断, 空值走旧逻辑不破坏);Call POST+Accept json/event-stream 响应可单 JSON 或 SSE 流找匹配 id; Mcp-Session-Id 会话 + 独立 GET SSE 流收 server 通知;config 读 MCP_TRANSPORT。 - schema 缓存判定不做:客户端透传 map[string]any 无重复编译,SDK SchemaCache 是服务端 按 Go 类型反射场景,客户端无对应。 go build/vet/test 全绿(count=2 无 flaky) + windows/darwin 交叉编译。
This commit is contained in:
1 parent
464ed2cad3
commit
b0bd93b15c
13 files changed
+1517
-66
No files matched your search
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user