Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
53 changes: 53 additions & 0 deletions mcp/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"log/slog"
"sync/atomic"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
Expand Down Expand Up @@ -1092,3 +1093,55 @@ func TestClientConnectDiscover_UnsupportedVersionNegotiation(t *testing.T) {
t.Errorf("InitializeResult.ProtocolVersion = %q, want %q", got, want)
}
}

// TestClientConnect_SubscriptionsListenMethodNotFound verifies that when
// change handlers are registered in ClientOptions, Client.Connect does not
// fail if the server rejects the optional subscriptions/listen request with
// MethodNotFound (CodeMethodNotFound = -32601) or ErrNotHandled.
func TestClientConnect_SubscriptionsListenMethodNotFound(t *testing.T) {
ctx := t.Context()

listenReceived := make(chan struct{})
server := NewServer(&Implementation{Name: "stateless-server", Version: "v1"}, nil)
server.AddReceivingMiddleware(func(next MethodHandler) MethodHandler {
return func(ctx context.Context, method string, req Request) (Result, error) {
if method == methodSubscriptionsListen {
select {
case <-listenReceived:
default:
close(listenReceived)
}
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: "client", Version: "v1"}, &ClientOptions{
ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) {},
})

cs, err := client.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("client.Connect: %v", err)
}
defer cs.Close()

select {
case <-listenReceived:
case <-time.After(2 * time.Second):
t.Error("timed out waiting for server to receive subscriptions/listen request")
}

// Verify the session is fully functional despite subscriptions/listen not being supported.
if _, err := cs.ListTools(ctx, nil); err != nil {
t.Errorf("ListTools after connect: %v", err)
}
}
18 changes: 10 additions & 8 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -2347,13 +2347,14 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
}

if err := c.checkResponse(ctx, requestSummary, resp); err != nil {
if requestMethod == methodDiscover && !errors.Is(err, jsonrpc2.ErrRejected) {
// Wrap the discover failure with ErrRejected so the jsonrpc2 layer
// doesn't set writeErr, which would prevent the legacy initialize
// fallback from succeeding on the same connection. This covers the
// case where a legacy server rejects server/discover with a
// non-JSON-RPC body (e.g. plain text 400), which checkResponse
// cannot classify as a per-call rejection on its own.
if (requestMethod == methodDiscover || requestMethod == methodSubscriptionsListen) && !errors.Is(err, jsonrpc2.ErrRejected) {
// Wrap the discover or subscriptions/listen failure with ErrRejected so
// the jsonrpc2 layer doesn't set writeErr, which would break the connection
// (preventing the legacy initialize fallback from succeeding, or breaking
// subsequent RPCs if subscriptions/listen fails). This covers the case
// where a server rejects these requests with a non-JSON-RPC body (e.g.
// plain text 404 or 400), which checkResponse cannot classify as a
// per-call rejection on its own.
err = fmt.Errorf("%w: %w", err, jsonrpc2.ErrRejected)
} else if !errors.Is(err, jsonrpc2.ErrRejected) {
// Only fail the connection for non-transient errors.
Expand Down Expand Up @@ -2592,7 +2593,8 @@ func (c *streamableClientConn) checkResponse(ctx context.Context, requestSummary
// §2.5.3: "The server MAY terminate the session at any time, after
// which it MUST respond to requests containing that session ID with HTTP
// 404 Not Found."
if resp.StatusCode == http.StatusNotFound {
// Only a stateful connection with a known session ID can experience ErrSessionMissing.
if resp.StatusCode == http.StatusNotFound && c.sessionID != "" {
// Return an ErrSessionMissing to avoid sending a redundant DELETE when the
// session is already gone.
return fmt.Errorf("%s: failed to connect (session ID: %v): %w", requestSummary, c.sessionID, ErrSessionMissing)
Expand Down
61 changes: 61 additions & 0 deletions mcp/streamable_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1965,3 +1965,64 @@ func TestStreamableClientHandlerErrorPropagation(t *testing.T) {
})
}
}

// TestStreamableClient_StatelessSubscriptionsListen404 verifies that in stateless mode
// (protocol 2026-07-28), when a server rejects subscriptions/listen with 404 (e.g. Copilot MCP server),
// the client connection survives and subsequent RPCs (such as tools/list) succeed.
func TestStreamableClient_StatelessSubscriptionsListen404(t *testing.T) {
ctx := t.Context()

var listenServed atomic.Bool
fake := &fakeStreamableServer{
t: t,
responses: fakeResponses{
{"POST", "", methodDiscover, ""}: {
header: header{
"Content-Type": "application/json",
},
wantProtocolVersion: protocolVersion20260728,
responseFunc: func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, resp(r.ID.Raw().(int64), discoverResult, nil)), http.StatusOK
},
},
{"POST", "", methodSubscriptionsListen, ""}: {
header: header{"Content-Type": "text/plain"},
responseFunc: func(r *jsonrpc.Request) (string, int) {
listenServed.Store(true)
return "404 Not Found", http.StatusNotFound
},
optional: true,
},
{"POST", "", methodListTools, ""}: {
header: header{"Content-Type": "application/json"},
responseFunc: func(r *jsonrpc.Request) (string, int) {
return jsonBody(t, resp(r.ID.Raw().(int64), &ListToolsResult{Tools: []*Tool{}}, nil)), http.StatusOK
},
optional: true,
},
},
}

httpServer := httptest.NewServer(fake)
defer httpServer.Close()

transport := &StreamableClientTransport{Endpoint: httpServer.URL}
client := NewClient(testImpl, &ClientOptions{
ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) {},
})

session, err := client.Connect(ctx, transport, &ClientSessionOptions{ProtocolVersion: protocolVersion20260728})
if err != nil {
t.Fatalf("Connect failed: %v", err)
}
defer session.Close()

// tools/list must succeed even though subscriptions/listen returned 404.
res, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatalf("ListTools failed: %v", err)
}
if res == nil {
t.Fatal("ListTools result is nil")
}
}
Loading