Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
2fca1b4
feat: implement sessionless protocol support via per-request _meta va…
guglielmo-san May 19, 2026
98b2a44
fix: correctly report MethodNotFound error codes in new-protocol requ…
guglielmo-san May 19, 2026
222d145
refactor: remove legacy stateless session handling logic and associat…
guglielmo-san May 19, 2026
4beb079
feat: enforce SEP-2575 protocol version header validation and restric…
guglielmo-san May 20, 2026
23b804b
test: add required Mcp-Method and Mcp-Name headers to streamable inte…
guglielmo-san May 20, 2026
113cc9f
fix: reject initialize, ping, and notifications/initialized methods i…
guglielmo-san May 20, 2026
85a36ed
docs: simplify protocol version requirement comment in streamable.go
guglielmo-san May 20, 2026
aecfae4
docs: update validateRequestMeta comment grammar to present tense
guglielmo-san May 20, 2026
69a549f
refactor: update protocol version retrieval to use context instead of…
guglielmo-san May 20, 2026
584f8bf
feat: implement SEP-2575 server/discover protocol for stateless clien…
guglielmo-san May 21, 2026
67233a6
refactor: add isNil interface method to all param structs and update …
guglielmo-san May 21, 2026
52828cd
refactor: remove redundant meta field from validatedMeta and simplify…
guglielmo-san May 21, 2026
ded4e45
test: update streamable handler tests to inject required client metad…
guglielmo-san May 21, 2026
aba9529
style: align whitespace in isParams method declarations for consisten…
guglielmo-san May 21, 2026
e931735
test: update MCP tests to handle server/discover fallback to legacy i…
guglielmo-san May 21, 2026
f809bb2
Merge branch 'guglielmoc/SEP-2567_2575_Stateless_MCP' into guglielmoc…
guglielmo-san May 21, 2026
6cc84d2
feat: add isNil helper method to DiscoverParams struct
guglielmo-san May 21, 2026
fc5865b
refactor: simplify orZero helper implementation and remove deprecated…
guglielmo-san May 21, 2026
b1a06ba
feat: extract and persist initialize params from new protocol request…
guglielmo-san May 21, 2026
2e2a116
refactor: rename usesNewProtocol variable to validatedMeta for clarit…
guglielmo-san May 21, 2026
8b572a6
refactor: update ServerSessionState using thread-safe updateState hel…
guglielmo-san May 21, 2026
6f1eba0
fix: prevent redundant initialization of server session state when al…
guglielmo-san May 22, 2026
ddec24b
feat: implement SEP-2575 handshake support with version-aware transpo…
guglielmo-san May 25, 2026
22c0c7d
refactor: update protocol versioning, remove client keepalive initial…
guglielmo-san May 25, 2026
31b343c
fix: propagate discovery errors and add Bad Request to legacy fallbac…
guglielmo-san May 25, 2026
38d8b59
refactor: update MCP discovery logic to handle protocol version fallb…
guglielmo-san May 25, 2026
9e3da0c
refactor: replace protocolVersionSetter interface with context-based …
guglielmo-san May 25, 2026
372c6ee
test: add discoverInterceptor to test suite and clean up server-side …
guglielmo-san May 25, 2026
b145443
Merge branch 'guglielmoc/SEP-2567_2575_Stateless_MCP' into guglielmoc…
guglielmo-san May 25, 2026
a0e6114
feat: add discover method stub to server and register in methodInfos
guglielmo-san May 25, 2026
d642211
feat: implement server-side support for protocol discovery and versio…
guglielmo-san May 26, 2026
425a4aa
Merge branch 'main' into guglielmoc/SEP-2567_2575_Stateless_MCP_part3
guglielmo-san Jun 2, 2026
d1b64ed
feat: implement server/discover protocol support and remove test-only…
guglielmo-san Jun 2, 2026
1cd089d
fix: ensure MethodNotFound error codes are correctly propagated by us…
guglielmo-san Jun 2, 2026
362f449
feat: implement SEP-2575 protocol version negotiation via server disc…
guglielmo-san Jun 2, 2026
64da66f
wip
guglielmo-san Jun 2, 2026
f03e810
test: inject protocolVersion20260630 into supportedProtocolVersions f…
guglielmo-san Jun 2, 2026
1f05980
revert changes
guglielmo-san Jun 2, 2026
d0af619
feat: implement SEP-2575 protocol-level HTTP status overrides for JSO…
guglielmo-san Jun 4, 2026
ba8b4dc
feat: implement clientConnection interface for streamableClientConn
guglielmo-san Jun 4, 2026
2afc267
refactor: rename newProtocolErrorStatus to extractErrorStatus and upd…
guglielmo-san Jun 4, 2026
29a0883
chore: update protocol version to DRAFT-2026-v1, improve streamable e…
guglielmo-san Jun 4, 2026
6937ff7
feat: add missing capability error code
guglielmo-san Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/mcpgodebug.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the
restoring the previous behavior. The default behavior was changed so that
stateless servers ignore session IDs entirely and reject `DELETE` with 405.

- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not
include the MethodNotFound Error (`-32601`) in the error response when the
requested method in STDIO transport is not found. The default behavior was
changed to include the MethodNotFound Error in the error response when the
requested method in STDIO transport is not found.

### 1.6.0

Options listed below were added and will be removed in the 1.8.0 version of the SDK.
Expand Down
6 changes: 6 additions & 0 deletions internal/docs/mcpgodebug.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ Options listed below were added and will be removed in the 1.9.0 version of the
restoring the previous behavior. The default behavior was changed so that
stateless servers ignore session IDs entirely and reject `DELETE` with 405.

- `nomethodnotfoundcodeinerror` added. If set to `1`, the jsonrpc2 layer will not
include the MethodNotFound Error (`-32601`) in the error response when the
requested method in STDIO transport is not found. The default behavior was
changed to include the MethodNotFound Error in the error response when the
requested method in STDIO transport is not found.

### 1.6.0

Options listed below were added and will be removed in the 1.8.0 version of the SDK.
Expand Down
21 changes: 18 additions & 3 deletions internal/jsonrpc2/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,25 @@ import (
"time"

"github.com/modelcontextprotocol/go-sdk/internal/json"
"github.com/modelcontextprotocol/go-sdk/internal/mcpgodebug"
)

// nomethodnotfoundcodeinerror is a compatibility parameter that restores the
// pre-fix behavior of [processResult], where wrapped [ErrNotHandled] or
// [ErrMethodNotFound] errors returned by request handlers were not
// recognized as "method not found" signals. The original switch statement
// compared sentinel errors with ==, which never matched errors returned via
// fmt.Errorf("%w: ...", ErrNotHandled, ...) — including the ones produced
// by checkRequest. As a result the wire error response carried code 0
// instead of code -32601. The fix uses errors.Is to recognize wrapped
// sentinels and append the method name to the message.
//
// To restore the previous behavior, set MCPGODEBUG=nomethodnotfoundcodeinerror=1.
// This option will be removed in a future SDK version.
// See the documentation for the mcpgodebug package for instructions on how
// to use it.
var nomethodnotfoundcodeinerror = mcpgodebug.Value("nomethodnotfoundcodeinerror")

// Connection manages the jsonrpc2 protocol, connecting responses back to their
// calls. Connection is bidirectional; it does not have a designated server or
// client end.
Expand Down Expand Up @@ -648,9 +665,7 @@ func (c *Connection) handleAsync() {

// processResult processes the result of a request and, if appropriate, sends a response.
func (c *Connection) processResult(from any, req *incomingRequest, result any, err error) error {
switch err {
case ErrNotHandled, ErrMethodNotFound:
// Add detail describing the unhandled method.
if nomethodnotfoundcodeinerror != "1" && (errors.Is(err, ErrNotHandled) || errors.Is(err, ErrMethodNotFound)) {
err = fmt.Errorf("%w: %q", ErrMethodNotFound, req.Method)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/jsonrpc2/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ var (
// ErrServerClosing is returned for calls that arrive while the server is closing.
ErrServerClosing = NewError(-32006, "server is closing")
// ErrClientClosing is a dummy error returned for calls initiated while the client is closing.
ErrClientClosing = NewError(-32003, "client is closing")
ErrClientClosing = NewError(-32007, "client is closing")

// The following errors have special semantics for MCP transports

Expand Down
255 changes: 255 additions & 0 deletions mcp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"context"
"fmt"
"log/slog"
"slices"
"sync/atomic"
"testing"

Expand Down Expand Up @@ -677,6 +678,17 @@ func TestClientConnectDiscover(t *testing.T) {
wantInitialize: true,
wantVersion: latestProtocolVersion,
},
{
name: "unsupported protocol version falls back to initialize",
discoverHandler: func() (Result, error) {
return nil, &jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: "unsupported protocol version",
}
},
wantInitialize: true,
wantVersion: latestProtocolVersion,
},
{
name: "no overlapping supported version falls back to initialize",
discoverHandler: func() (Result, error) {
Expand Down Expand Up @@ -772,6 +784,10 @@ func TestClientConnectDiscover(t *testing.T) {
// request sent by Client.Connect carries the SEP-2575 per-request _meta triple:
// protocolVersion, clientInfo, and clientCapabilities.
func TestClientConnectDiscover_RequestContents(t *testing.T) {
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })

ctx := context.Background()

type captured struct {
Expand Down Expand Up @@ -841,3 +857,242 @@ func TestClientConnectDiscover_RequestContents(t *testing.T) {
t.Errorf("clientCapabilities.sampling missing (CreateMessageHandler was set); got %v", caps)
}
}

// TestInMemory_E2E_DiscoverSuccess is a full end-to-end smoke test for
// SEP-2575 over a non-HTTP transport.
func TestInMemory_E2E_DiscoverSuccess(t *testing.T) {
ctx := context.Background()

orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })

server := NewServer(&Implementation{Name: "stdio-like-server", Version: "v1"}, nil)
ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()

client := NewClient(&Implementation{Name: "stdio-like-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()

ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil; discover should have populated it")
}
if ir.ProtocolVersion != protocolVersion20260630 {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (negotiated via discover, no initialize)",
ir.ProtocolVersion, protocolVersion20260630)
}
if ir.ServerInfo == nil || ir.ServerInfo.Name != "stdio-like-server" {
t.Errorf("InitializeResult.ServerInfo = %+v, want name=stdio-like-server", ir.ServerInfo)
}

// Prove the session is usable.
if _, err := cs.ListTools(ctx, nil); err != nil {
t.Errorf("ListTools after discover: %v", err)
}
}

// TestInMemory_E2E_DiscoverFallback_NoOverlap verifies the fallback path
// over an InMemory (STDIO-equivalent) transport: the client probes with
// _meta.protocolVersion = 2026-06-30, but the server's supported list does
// NOT include that version (the default for an SDK server that hasn't
// shimmed supportedProtocolVersions).
func TestInMemory_E2E_DiscoverFallback_NoOverlap(t *testing.T) {
ctx := context.Background()
orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })

server := NewServer(&Implementation{Name: "vpre-like-server", Version: "v1"}, nil)
// Intercept discover and reply as if we were a server that only
// supports legacy versions.
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
return &DiscoverResult{
SupportedVersions: []string{protocolVersion20251125},
Capabilities: &ServerCapabilities{},
ServerInfo: &Implementation{Name: "vpre-like-server", Version: "v1"},
}, nil
}
return next(ctx, method, req)
}
})

ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()

client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()

ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after fallback initialize")
}
if ir.ProtocolVersion != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after no-overlap discover)",
ir.ProtocolVersion, latestProtocolVersion)
}

// Prove the session is usable after fallback.
if _, err := cs.ListTools(ctx, nil); err != nil {
t.Errorf("ListTools after fallback initialize: %v", err)
}
}

// TestInMemory_E2E_DiscoverFallback_MethodNotFound verifies the fallback
// path over InMemory when the server doesn't know about server/discover at
// all (simulating a true pre-SEP-2575 server).
func TestInMemory_E2E_DiscoverFallback_MethodNotFound(t *testing.T) {
ctx := context.Background()

orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })

server := NewServer(&Implementation{Name: "vpre-server", Version: "v1"}, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
return nil, jsonrpc2.ErrMethodNotFound
}
return next(ctx, method, req)
}
})

ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()

client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()

ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after fallback initialize")
}
if ir.ProtocolVersion != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after MethodNotFound)",
ir.ProtocolVersion, latestProtocolVersion)
}

if _, err := cs.ListTools(ctx, nil); err != nil {
t.Errorf("ListTools after fallback initialize: %v", err)
}
}

// TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion verifies the
// fallback path when the server explicitly rejects the discover probe with
// CodeUnsupportedProtocolVersion (the structured SEP-2575 signal). This
// exercises Path A of the fallback logic in client.go.
func TestInMemory_E2E_DiscoverFallback_UnsupportedProtocolVersion(t *testing.T) {
ctx := context.Background()

orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })

server := NewServer(&Implementation{Name: "strict-server", Version: "v1"}, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
return nil, &jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: "unsupported protocol version",
}
}
return next(ctx, method, req)
}
})

ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()

client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()

ir := cs.InitializeResult()
if ir == nil {
t.Fatal("InitializeResult is nil after fallback initialize")
}
if ir.ProtocolVersion != latestProtocolVersion {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q (legacy fallback after UnsupportedProtocolVersion)",
ir.ProtocolVersion, latestProtocolVersion)
}
}

// TestInMemory_E2E_DiscoverPropagatesOtherErrors verifies that an unrelated
// error from the discover handler aborts Connect and does NOT silently
// fall back.
func TestInMemory_E2E_DiscoverPropagatesOtherErrors(t *testing.T) {
ctx := context.Background()

orig := supportedProtocolVersions
supportedProtocolVersions = append([]string{protocolVersion20260630}, slices.Clone(orig)...)
t.Cleanup(func() { supportedProtocolVersions = orig })

var sawInitialize atomic.Bool
server := NewServer(&Implementation{Name: "broken-server", Version: "v1"}, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
switch method {
case methodDiscover:
return nil, &jsonrpc.Error{
Code: jsonrpc.CodeInternalError,
Message: "boom",
}
case methodInitialize:
sawInitialize.Store(true)
}
return next(ctx, method, req)
}
})

ct, st := NewInMemoryTransports()
ss, err := server.Connect(ctx, st, nil)
if err != nil {
t.Fatalf("server.Connect: %v", err)
}
defer ss.Close()

client := NewClient(&Implementation{Name: "new-client", Version: "v1"}, nil)
cs, err := client.Connect(ctx, ct, &ClientSessionOptions{protocolVersion: protocolVersion20260630})
if err == nil {
_ = cs.Close()
t.Fatal("Connect succeeded; want propagated discover error")
}
if sawInitialize.Load() {
t.Error("server received initialize; Connect should have aborted on the discover error")
}
}
21 changes: 0 additions & 21 deletions mcp/mrtr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,27 +526,6 @@ func TestMultiRoundTrip_ReadResource_ManualRetry(t *testing.T) {
func mustConnect(t *testing.T, s *Server, clientOpts *ClientOptions) *ClientSession {
t.Helper()

// The mrtr tests require negotiating the 2026-06-30 protocol version.
// Server.discover is currently a stub that returns ErrMethodNotFound, which
// would cause Client.Connect to fall back to the legacy initialize handshake
// and downgrade the negotiated version. Install a receiving middleware that
// answers server/discover with a DiscoverResult advertising 2026-06-30 so
// the client can negotiate the new protocol via the discover path.
//
// TODO: Remove this once the server has a proper discover implementation.
s.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodDiscover {
return &DiscoverResult{
SupportedVersions: []string{protocolVersion20260630},
Capabilities: &ServerCapabilities{},
ServerInfo: testImpl,
}, nil
}
return next(ctx, method, req)
}
})

st, ct := NewInMemoryTransports()
ss, err := s.Connect(t.Context(), st, nil)
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions mcp/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -2103,3 +2103,14 @@ const (
// MetaKeyClientCapabilities carries the client's [ClientCapabilities].
MetaKeyClientCapabilities = "io.modelcontextprotocol/clientCapabilities"
)

// UnsupportedProtocolVersionData is the SEP-2575 payload carried in the
// `data` field of a JSON-RPC error response with code
// [CodeUnsupportedProtocolVersion]. The server uses it to advertise which
// versions it supports so the client can pick a mutually supported one.
type UnsupportedProtocolVersionData struct {
// Supported is the list of protocol versions the server supports.
Supported []string `json:"supported"`
// Requested is the protocol version the client asked for.
Requested string `json:"requested"`
}
Loading
Loading