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

@ -6,6 +6,7 @@ package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@ -102,6 +103,23 @@ func (s *fakeStreamableServer) ServeHTTP(w http.ResponseWriter, req *http.Reques
resp, ok := s.responses[key]
if !ok {
if key.jsonrpcMethod == "server/discover" {
// Return MethodNotFound to trigger fallback to legacy initialize.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
respErr := &jsonrpc.Error{
Code: jsonrpc.CodeMethodNotFound,
Message: `method not found: "server/discover"`,
}
var id jsonrpc.ID
if jsonrpcReq != nil {
id = jsonrpcReq.ID
}
respMsg, _ := jsonrpc2.NewResponse(id, nil, respErr)
data, _ := jsonrpc2.EncodeMessage(respMsg)
w.Write(data)
return
}
s.t.Errorf("missing response for %v", key)
http.Error(w, "no response", http.StatusInternalServerError)
return
@ -1212,3 +1230,408 @@ func TestStreamableClientOAuth_RetrieveError(t *testing.T) {
t.Fatalf("client.Connect() error = %v, want %v", err, errTestAuthorizeFailed)
}
}
// discoverResult is the canned successful DiscoverResult returned by
// fakeStreamableServer setups in the tests below.
var discoverResult = &DiscoverResult{
SupportedVersions: []string{protocolVersion20260630},
Capabilities: &ServerCapabilities{
Tools: &ToolCapabilities{ListChanged: true},
},
ServerInfo: &Implementation{Name: "discoverServer", Version: "v1.0.0"},
Instructions: "test discover",
}
// TestStreamableClientConnect_DiscoverSuccess verifies that Client.Connect on
// the streamable transport:
// - sends a POST server/discover with Mcp-Protocol-Version: 2026-06-30 and
// the SEP-2575 per-request _meta triple in the body,
// - on a successful DiscoverResult, skips the legacy initialize handshake
// entirely, and
// - seeds ClientSession.InitializeResult() from the discover response,
// picking a mutually-supported protocol version.
func TestStreamableClientConnect_DiscoverSuccess(t *testing.T) {
ctx := context.Background()
// Temporarily enable 2026-06-30 support in the SDK for this test
oldSupported := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, supportedProtocolVersions...)
t.Cleanup(func() {
supportedProtocolVersions = oldSupported
})
var (
gotDiscoverMu sync.Mutex
gotDiscover *jsonrpc.Request
)
fake := &fakeStreamableServer{
t: t,
responses: fakeResponses{
{"POST", "", methodDiscover, ""}: {
header: header{
"Content-Type": "application/json",
sessionIDHeader: "sess-1",
},
wantProtocolVersion: protocolVersion20260630,
responseFunc: func(r *jsonrpc.Request) (string, int) {
gotDiscoverMu.Lock()
gotDiscover = r
gotDiscoverMu.Unlock()
return jsonBody(t, resp(1, discoverResult, nil)), http.StatusOK
},
},
{"DELETE", "sess-1", "", ""}: {optional: true},
},
}
httpServer := httptest.NewServer(fake)
defer httpServer.Close()
transport := &StreamableClientTransport{Endpoint: httpServer.URL}
client := NewClient(testImpl, nil)
session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer session.Close()
if missing := fake.missingRequests(); len(missing) > 0 {
t.Errorf("missing expected requests: %v", missing)
}
gotDiscoverMu.Lock()
defer gotDiscoverMu.Unlock()
if gotDiscover == nil {
t.Fatal("server did not receive server/discover")
}
// Inspect the discover request body for the SEP-2575 _meta triple.
var body struct {
Meta map[string]any `json:"_meta"`
}
if err := json.Unmarshal(gotDiscover.Params, &body); err != nil {
t.Fatalf("decoding discover params: %v", err)
}
if v, _ := body.Meta[MetaKeyProtocolVersion].(string); v != protocolVersion20260630 {
t.Errorf("_meta[%s] = %q, want %q", MetaKeyProtocolVersion, v, protocolVersion20260630)
}
if _, ok := body.Meta[MetaKeyClientInfo]; !ok {
t.Errorf("_meta[%s] missing", MetaKeyClientInfo)
}
if _, ok := body.Meta[MetaKeyClientCapabilities]; !ok {
t.Errorf("_meta[%s] missing", MetaKeyClientCapabilities)
}
ir := session.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after Connect")
}
if got, want := ir.ProtocolVersion, protocolVersion20260630; got != want {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want)
}
if ir.ServerInfo == nil || ir.ServerInfo.Name != "discoverServer" {
t.Errorf("InitializeResult.ServerInfo = %+v, want Name=discoverServer", ir.ServerInfo)
}
if ir.Instructions != "test discover" {
t.Errorf("InitializeResult.Instructions = %q, want %q", ir.Instructions, "test discover")
}
}
// TestStreamableClientConnect_DiscoverMethodNotFound verifies that Client.Connect
// falls back to the legacy initialize handshake when the server responds to
// server/discover with a JSON-RPC "Method not found" error.
func TestStreamableClientConnect_DiscoverMethodNotFound(t *testing.T) {
ctx := context.Background()
// Each request gets a fresh jsonrpc2 ID from the same client connection.
// Use responseFunc to echo the request's ID back so the client matches
// the response to the in-flight call regardless of ordering.
echoErr := func(err error) func(*jsonrpc.Request) (string, int) {
return func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: err.(*jsonrpc.Error)}), http.StatusOK
}
}
echoResult := func(result any) func(*jsonrpc.Request) (string, int) {
return func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK
}
}
fake := &fakeStreamableServer{
t: t,
responses: fakeResponses{
{"POST", "", methodDiscover, ""}: {
header: header{"Content-Type": "application/json"},
wantProtocolVersion: protocolVersion20260630,
responseFunc: echoErr(&jsonrpc.Error{
Code: jsonrpc.CodeMethodNotFound,
Message: "method not found",
}),
},
{"POST", "", methodInitialize, ""}: {
header: header{
"Content-Type": "application/json",
sessionIDHeader: "fallback",
},
responseFunc: echoResult(initResult),
},
{"POST", "fallback", notificationInitialized, ""}: {
status: http.StatusAccepted,
wantProtocolVersion: latestProtocolVersion,
},
{"GET", "fallback", "", ""}: {
header: header{"Content-Type": "text/event-stream"},
wantProtocolVersion: latestProtocolVersion,
optional: true,
},
{"DELETE", "fallback", "", ""}: {optional: true},
},
}
httpServer := httptest.NewServer(fake)
defer httpServer.Close()
transport := &StreamableClientTransport{Endpoint: httpServer.URL}
client := NewClient(testImpl, nil)
session, err := client.Connect(ctx, transport, nil)
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer session.Close()
if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion)
}
}
// TestStreamableClientConnect_DiscoverUnsupportedVersion verifies that
// Client.Connect falls back to the legacy initialize handshake when the
// server responds to server/discover with the SEP-2575
// UnsupportedProtocolVersionError JSON-RPC code.
func TestStreamableClientConnect_DiscoverUnsupportedVersion(t *testing.T) {
ctx := context.Background()
echoErr := func(err error) func(*jsonrpc.Request) (string, int) {
return func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, &jsonrpc.Response{ID: r.ID, Error: err.(*jsonrpc.Error)}), http.StatusOK
}
}
echoResult := func(result any) func(*jsonrpc.Request) (string, int) {
return func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK
}
}
fake := &fakeStreamableServer{
t: t,
responses: fakeResponses{
{"POST", "", methodDiscover, ""}: {
header: header{"Content-Type": "application/json"},
wantProtocolVersion: protocolVersion20260630,
responseFunc: echoErr(&jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: "unsupported protocol version",
}),
},
{"POST", "", methodInitialize, ""}: {
header: header{
"Content-Type": "application/json",
sessionIDHeader: "fallback",
},
responseFunc: echoResult(initResult),
},
{"POST", "fallback", notificationInitialized, ""}: {
status: http.StatusAccepted,
wantProtocolVersion: latestProtocolVersion,
},
{"GET", "fallback", "", ""}: {
header: header{"Content-Type": "text/event-stream"},
wantProtocolVersion: latestProtocolVersion,
optional: true,
},
{"DELETE", "fallback", "", ""}: {optional: true},
},
}
httpServer := httptest.NewServer(fake)
defer httpServer.Close()
transport := &StreamableClientTransport{Endpoint: httpServer.URL}
client := NewClient(testImpl, nil)
session, err := client.Connect(ctx, transport, nil)
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer session.Close()
if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion)
}
}
// TestStreamableClientConnect_DiscoverPropagatesOtherErrors verifies that
// Client.Connect does NOT fall back to initialize when server/discover
// returns an unrelated JSON-RPC error (here, CodeInternalError). The Connect
// call should fail with the propagated error rather than masking it.
func TestStreamableClientConnect_DiscoverPropagatesOtherErrors(t *testing.T) {
ctx := context.Background()
var sawInitialize atomic.Bool
fake := &fakeStreamableServer{
t: t,
responses: fakeResponses{
{"POST", "", methodDiscover, ""}: {
header: header{"Content-Type": "application/json"},
wantProtocolVersion: protocolVersion20260630,
responseFunc: func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, &jsonrpc.Response{
ID: r.ID,
Error: &jsonrpc.Error{
Code: jsonrpc.CodeInternalError,
Message: "boom",
},
}), http.StatusOK
},
},
{"POST", "", methodInitialize, ""}: {
responseFunc: func(r *jsonrpc.Request) (string, int) {
sawInitialize.Store(true)
return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(initResult)}), http.StatusOK
},
header: header{
"Content-Type": "application/json",
sessionIDHeader: "fallback",
},
optional: true,
},
},
}
httpServer := httptest.NewServer(fake)
defer httpServer.Close()
transport := &StreamableClientTransport{Endpoint: httpServer.URL}
client := NewClient(testImpl, nil)
session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err == nil {
_ = session.Close()
t.Fatal("Connect succeeded; want propagated error")
}
if sawInitialize.Load() {
t.Error("server received initialize; Connect should have aborted on the discover error")
}
}
// TestStreamableClientConnect_DiscoverMethodNotFoundVPre verifies that
// Client.Connect falls back to the legacy initialize handshake when a
// pre-SEP-2575 (vPre) server rejects server/discover.
func TestStreamableClientConnect_DiscoverMethodNotFoundVPre(t *testing.T) {
ctx := context.Background()
echoResult := func(result any) func(*jsonrpc.Request) (string, int) {
return func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK
}
}
fake := &fakeStreamableServer{
t: t,
responses: fakeResponses{
{"POST", "", methodDiscover, ""}: {
wantProtocolVersion: protocolVersion20260630,
// Reproduce the exact body a vPre server produces via
// http.Error(w, err.Error(), 400) where err comes from
// checkRequest. http.Error appends a trailing newline.
body: "JSON RPC not handled: \"server/discover\" unsupported\n",
status: http.StatusBadRequest,
header: header{"Content-Type": "text/plain; charset=utf-8"},
},
{"POST", "", methodInitialize, ""}: {
header: header{
"Content-Type": "application/json",
sessionIDHeader: "fallback",
},
responseFunc: echoResult(initResult),
},
{"POST", "fallback", notificationInitialized, ""}: {
status: http.StatusAccepted,
wantProtocolVersion: latestProtocolVersion,
},
{"DELETE", "fallback", "", ""}: {optional: true},
},
}
httpServer := httptest.NewServer(fake)
defer httpServer.Close()
transport := &StreamableClientTransport{
Endpoint: httpServer.URL,
DisableStandaloneSSE: true,
}
client := NewClient(testImpl, nil)
session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer session.Close()
if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion)
}
}
// TestStreamableClientConnect_DiscoverUnsupportedProtocolVersion verifies that
// Client.Connect falls back to the legacy initialize handshake when a
// server rejects server/discover with a plain HTTP 400 containing "Unsupported protocol version".
func TestStreamableClientConnect_DiscoverUnsupportedVersionVPre(t *testing.T) {
ctx := context.Background()
echoResult := func(result any) func(*jsonrpc.Request) (string, int) {
return func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, &jsonrpc.Response{ID: r.ID, Result: mustMarshal(result)}), http.StatusOK
}
}
fake := &fakeStreamableServer{
t: t,
responses: fakeResponses{
{"POST", "", methodDiscover, ""}: {
wantProtocolVersion: protocolVersion20260630,
body: "Bad Request: Unsupported protocol version\n",
status: http.StatusBadRequest,
header: header{"Content-Type": "text/plain; charset=utf-8"},
},
{"POST", "", methodInitialize, ""}: {
header: header{
"Content-Type": "application/json",
sessionIDHeader: "fallback",
},
responseFunc: echoResult(initResult),
},
{"POST", "fallback", notificationInitialized, ""}: {
status: http.StatusAccepted,
wantProtocolVersion: latestProtocolVersion,
},
{"DELETE", "fallback", "", ""}: {optional: true},
},
}
httpServer := httptest.NewServer(fake)
defer httpServer.Close()
transport := &StreamableClientTransport{
Endpoint: httpServer.URL,
DisableStandaloneSSE: true,
}
client := NewClient(testImpl, nil)
session, err := client.Connect(ctx, transport, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer session.Close()
if got := session.InitializeResult().ProtocolVersion; got != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (initialize fallback)", got, latestProtocolVersion)
}
}