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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue