mcp: Implement support for SEP-2575 on client side (#975)
Implements the client half of the SEP-2575 / `2026-06-30` protocol. This CL is client-only; server-side `server/discover` is still a `MethodNotFound` stub. ### Changes - **`server/discover` probe in `Client.Connect()`** (`client.go`). When the caller opts into `protocolVersion >= 2026-06-30`, the client probes the server with `server/discover` before falling back to the legacy `initialize` handshake. - **Per-request `_meta` injection** (`shared.go`, `injectRequestMeta`). For`>= 2026-06-30` sessions every outgoing request gets the SEP-2575 triple (`protocolVersion`, `clientInfo`, `clientCapabilities`). - **Server-side fix**: `ServerSession.handle` no longer pre-populates `state.InitializeParams` from a `server/discover` request's `_meta`, so a subsequent fallback `initialize` is not rejected as a duplicate. ### Fallback rules Client falls back to the legacy `initialize` handshake when discover returns: - `MethodNotFound` (-32601), - `UnsupportedProtocolVersionError` (-32004), - an HTTP `Bad Request` (substring-matched on the error message, to interop with pre-2026-06-30 servers). Fixes #966
This commit is contained in:
parent
d54751a8ee
commit
c60a318d22
1 changed files with 1010 additions and 96 deletions
|
|
@ -2049,68 +2049,6 @@ func TestStreamableMcpHeaderValidation(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TODO: Remove this once client operations will automatically inject metadata in the requests
|
||||
func injectMetaToRequest(req *http.Request) error {
|
||||
if req.Body == nil {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Body.Close()
|
||||
|
||||
var val any
|
||||
if err := json.Unmarshal(body, &val); err == nil {
|
||||
var method string
|
||||
if m, ok := val.(map[string]any); ok {
|
||||
method, _ = m["method"].(string)
|
||||
} else if list, ok := val.([]any); ok && len(list) > 0 {
|
||||
if m, ok := list[0].(map[string]any); ok {
|
||||
method, _ = m["method"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
if method == "initialize" || method == "notifications/initialized" || strings.HasPrefix(method, "notifications/") {
|
||||
req.Header.Set(protocolVersionHeader, "2025-11-25")
|
||||
} else {
|
||||
req.Header.Set(protocolVersionHeader, minVersionForStandardHeaders)
|
||||
|
||||
var msgs []map[string]any
|
||||
if m, ok := val.(map[string]any); ok {
|
||||
msgs = []map[string]any{m}
|
||||
} else if list, ok := val.([]any); ok {
|
||||
for _, item := range list {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range msgs {
|
||||
params, _ := m["params"].(map[string]any)
|
||||
if params == nil {
|
||||
params = make(map[string]any)
|
||||
m["params"] = params
|
||||
}
|
||||
meta, _ := params["_meta"].(map[string]any)
|
||||
if meta == nil {
|
||||
meta = make(map[string]any)
|
||||
params["_meta"] = meta
|
||||
}
|
||||
meta[MetaKeyProtocolVersion] = minVersionForStandardHeaders
|
||||
meta[MetaKeyClientInfo] = map[string]any{"name": "testClient", "version": "v1.0.0"}
|
||||
meta[MetaKeyClientCapabilities] = map[string]any{}
|
||||
}
|
||||
body, _ = json.Marshal(val)
|
||||
}
|
||||
}
|
||||
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestStreamableMcpHeaderValidationErrorFormat verifies that header
|
||||
// validation errors return a JSON-RPC error with code -32001 and
|
||||
// Content-Type application/json, per SEP-2243.
|
||||
|
|
@ -2131,7 +2069,15 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) {
|
|||
})
|
||||
defer handler.closeAll()
|
||||
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, handler))
|
||||
// TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly
|
||||
// once Server.discover returns a real DiscoverResult instead of
|
||||
// MethodNotFound. See comment on discoverInterceptor for details.
|
||||
wrapped := discoverInterceptor(t, handler,
|
||||
[]string{minVersionForStandardHeaders},
|
||||
&ServerCapabilities{Tools: &ToolCapabilities{}},
|
||||
&Implementation{Name: "testServer", Version: "v1.0.0"},
|
||||
)
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, wrapped))
|
||||
defer httpServer.Close()
|
||||
|
||||
// Use the MCP client with a custom RoundTripper to inject a bad header.
|
||||
|
|
@ -2140,9 +2086,6 @@ func TestStreamableMcpHeaderValidationErrorFormat(t *testing.T) {
|
|||
|
||||
customClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := injectMetaToRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var originalMethodHeader string
|
||||
if req.Header.Get(methodHeader) == "tools/call" {
|
||||
originalMethodHeader = req.Header.Get(methodHeader)
|
||||
|
|
@ -2296,15 +2239,20 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) {
|
|||
Stateless: true,
|
||||
})
|
||||
defer handler.closeAll()
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, handler))
|
||||
// TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly
|
||||
// once Server.discover returns a real DiscoverResult instead of
|
||||
// MethodNotFound. See comment on discoverInterceptor for details.
|
||||
wrapped := discoverInterceptor(t, handler,
|
||||
[]string{minVersionForStandardHeaders},
|
||||
&ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}},
|
||||
&Implementation{Name: "testServer", Version: "v1.0.0"},
|
||||
)
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, wrapped))
|
||||
defer httpServer.Close()
|
||||
|
||||
var capturedHeaders http.Header
|
||||
customClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := injectMetaToRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Header.Get(methodHeader) == "tools/call" {
|
||||
capturedHeaders = req.Header.Clone()
|
||||
}
|
||||
|
|
@ -2326,7 +2274,9 @@ func TestStreamableParamHeadersClientSetsHeaders(t *testing.T) {
|
|||
defer session.Close()
|
||||
|
||||
// ListTools to populate the tool cache (needed for param headers).
|
||||
if _, err := session.ListTools(ctx, nil); err != nil {
|
||||
// Pass a non-nil params so the SEP-2575 per-request _meta triple is
|
||||
// injected; injectMeta is a no-op when params is nil.
|
||||
if _, err := session.ListTools(ctx, &ListToolsParams{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -2411,23 +2361,21 @@ func TestStreamableFilterValidToolsIntegration(t *testing.T) {
|
|||
Stateless: true,
|
||||
})
|
||||
defer handler.closeAll()
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, handler))
|
||||
// TODO(SEP-2575): drop discoverInterceptor and hit `handler` directly
|
||||
// once Server.discover returns a real DiscoverResult instead of
|
||||
// MethodNotFound. See comment on discoverInterceptor for details.
|
||||
wrapped := discoverInterceptor(t, handler,
|
||||
[]string{minVersionForStandardHeaders},
|
||||
&ServerCapabilities{Tools: &ToolCapabilities{ListChanged: true}},
|
||||
&Implementation{Name: "testServer", Version: "v1.0.0"},
|
||||
)
|
||||
httpServer := httptest.NewServer(mustNotPanic(t, wrapped))
|
||||
defer httpServer.Close()
|
||||
|
||||
customClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if err := injectMetaToRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}),
|
||||
}
|
||||
|
||||
client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil)
|
||||
ctx := context.Background()
|
||||
session, err := client.Connect(ctx, &StreamableClientTransport{
|
||||
Endpoint: httpServer.URL,
|
||||
HTTPClient: customClient,
|
||||
Endpoint: httpServer.URL,
|
||||
}, &ClientSessionOptions{protocolVersion: minVersionForStandardHeaders})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -2742,6 +2690,51 @@ func TestStreamableSessionTimeout(t *testing.T) {
|
|||
handler.mu.Unlock()
|
||||
}
|
||||
|
||||
// discoverInterceptor wraps an HTTP handler so that POST requests carrying a
|
||||
// server/discover JSON-RPC request are answered with a canned DiscoverResult
|
||||
// advertising the given supportedVersions. All other requests are forwarded
|
||||
// to next unchanged.
|
||||
//
|
||||
// TODO(SEP-2575): this is a workaround for tests that need an end-to-end
|
||||
// SEP-2575 session (e.g. to exercise the Mcp-Method / Mcp-Param-* request
|
||||
// headers gated on protocol >= 2026-06-30) while the server-side
|
||||
// Server.discover implementation still returns MethodNotFound. Once
|
||||
// server-side discover is implemented, this helper can be removed and the
|
||||
// tests can hit the real handler directly.
|
||||
func discoverInterceptor(t *testing.T, next http.Handler, supportedVersions []string, capabilities *ServerCapabilities, serverInfo *Implementation) http.Handler {
|
||||
t.Helper()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
msg, err := jsonrpc.DecodeMessage(body)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
r, ok := msg.(*jsonrpc.Request)
|
||||
if !ok || r.Method != methodDiscover {
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
result := &DiscoverResult{
|
||||
SupportedVersions: supportedVersions,
|
||||
Capabilities: capabilities,
|
||||
ServerInfo: serverInfo,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)})))
|
||||
})
|
||||
}
|
||||
|
||||
// mustNotPanic is a helper to enforce that test handlers do not panic (see
|
||||
// issue #556).
|
||||
func mustNotPanic(t *testing.T, h http.Handler) http.Handler {
|
||||
|
|
@ -3592,3 +3585,48 @@ func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) {
|
|||
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamableClientUnsupportedVersionFallback exercises the full
|
||||
// SEP-2575 fallback. The client requests protocolVersion20260630, which the server does
|
||||
// not advertise in supportedProtocolVersions. The server therefore rejects
|
||||
// the server/discover POST at the transport-level header validation with a
|
||||
// plain HTTP 400 ("Bad Request: Unsupported protocol version ..."). The
|
||||
// streamable client must recognize this body, keep the connection alive, and
|
||||
// successfully complete the legacy initialize handshake.
|
||||
//
|
||||
// TODO: once 20260630 is part of supportedProtocolVersion on server side, modify the list in the test to keep it consistent.
|
||||
func TestStreamableClientUnsupportedVersionFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
server := NewServer(testImpl, nil)
|
||||
handler := NewStreamableHTTPHandler(
|
||||
func(*http.Request) *Server { return server },
|
||||
nil,
|
||||
)
|
||||
httpServer := httptest.NewServer(handler)
|
||||
defer httpServer.Close()
|
||||
|
||||
client := NewClient(testImpl, nil)
|
||||
transport := &StreamableClientTransport{Endpoint: httpServer.URL}
|
||||
|
||||
session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
|
||||
if err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
ir := session.InitializeResult()
|
||||
if ir == nil {
|
||||
t.Fatal("InitializeResult is nil; expected legacy initialize to populate it")
|
||||
}
|
||||
if ir.ProtocolVersion != latestProtocolVersion {
|
||||
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback)",
|
||||
ir.ProtocolVersion, latestProtocolVersion)
|
||||
}
|
||||
|
||||
// Verify the session is fully usable after the fallback by issuing a
|
||||
// real call against the server.
|
||||
if err := session.Ping(ctx, nil); err != nil {
|
||||
t.Errorf("Ping after fallback initialize: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue