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:
Guglielmo Colombo 2026-06-02 14:17:01 +02:00 • committed by GitHub
commit c60a318d22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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)
}
}