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

@ -1844,6 +1844,13 @@ func (c *streamableClientConn) sessionUpdated(state clientSessionState) {
c.initializedResult = state.InitializeResult
c.mu.Unlock()
// When the protocol version is >= 2026-06-30, the standalone HTTP GET
// SSE stream is removed.
if state.InitializeResult == nil ||
state.InitializeResult.ProtocolVersion >= protocolVersion20260630 {
return
}
// Start the standalone SSE stream as soon as we have the initialized
// result, if continuous listening is enabled.
//
@ -1903,7 +1910,7 @@ func (c *streamableClientConn) connectStandaloneSSE() {
return
}
summary := "standalone SSE stream"
if err := c.checkResponse(summary, resp); err != nil {
if err := c.checkResponse(c.ctx, summary, resp); err != nil {
c.fail(err)
return
}
@ -2046,10 +2053,11 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
}
}
if err := c.checkResponse(requestSummary, resp); err != nil {
if err := c.checkResponse(ctx, requestSummary, resp); err != nil {
// Only fail the connection for non-transient errors.
// Transient errors (wrapped with ErrRejected) should not break the connection.
if !errors.Is(err, jsonrpc2.ErrRejected) {
// ErrMethodNotFound and ErrUnsupportedProtocolVersion should not break the connection as they trigger the initialize fallback.
if !errors.Is(err, jsonrpc2.ErrRejected) && !errors.Is(err, jsonrpc2.ErrMethodNotFound) && !errors.Is(err, jsonrpc2.ErrUnsupportedProtocolVersion) {
c.fail(err)
}
return err
@ -2143,6 +2151,8 @@ func (c *streamableClientConn) setMCPHeaders(req *http.Request) error {
}
if c.initializedResult != nil {
req.Header.Set(protocolVersionHeader, c.initializedResult.ProtocolVersion)
} else if v := protocolVersionFromContext(req.Context()); v != "" {
req.Header.Set(protocolVersionHeader, v)
}
if c.sessionID != "" {
req.Header.Set(sessionIDHeader, c.sessionID)
@ -2228,7 +2238,7 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str
}
resp = newResp
if err := c.checkResponse(requestSummary, resp); err != nil {
if err := c.checkResponse(ctx, requestSummary, resp); err != nil {
c.fail(err)
return
}
@ -2239,7 +2249,7 @@ func (c *streamableClientConn) handleSSE(ctx context.Context, requestSummary str
// translates it into an error if the request was unsuccessful.
//
// The response body is close if a non-nil error is returned.
func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.Response) (err error) {
func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary string, resp *http.Response) (err error) {
defer func() {
if err != nil {
resp.Body.Close()
@ -2259,6 +2269,19 @@ func (c *streamableClientConn) checkResponse(requestSummary string, resp *http.R
return fmt.Errorf("%w: %s: %v", jsonrpc2.ErrRejected, requestSummary, http.StatusText(resp.StatusCode))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// Read the body and if we can detect vPre servers that
// reject "server/discover" as unsupported method with a plain HTTP 400,
// then return jsonrpc2.ErrMethodNotFound or jsonrpc2.ErrUnsupportedProtocolVersion to trigger the fallback.
protocolVersion := protocolVersionFromContext(ctx)
if protocolVersion != "" && protocolVersion >= protocolVersion20260630 {
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), fmt.Sprintf("%s: %q unsupported", jsonrpc2.ErrNotHandled, methodDiscover)) {
return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrMethodNotFound, http.StatusText(resp.StatusCode))
}
if strings.Contains(string(body), "Unsupported protocol version") {
return fmt.Errorf("%s: %w: %v", requestSummary, jsonrpc2.ErrUnsupportedProtocolVersion, http.StatusText(resp.StatusCode))
}
}
return fmt.Errorf("%s: %v", requestSummary, http.StatusText(resp.StatusCode))
}
return nil