diff --git a/mcp/client.go b/mcp/client.go index 3b24b2e9..0c37ff92 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -727,18 +727,23 @@ func (c *Client) shouldSendListChangedNotification(notification string) bool { if caps == nil { return true } - // Check RootsV2 first (preferred), then fall back to Roots. - if caps.RootsV2 != nil { - return caps.RootsV2.ListChanged - } - return caps.Roots.ListChanged + // Capabilities.Roots is ignored (#607); only RootsV2 is advertised. + return caps.RootsV2 != nil && caps.RootsV2.ListChanged default: // Unknown notification, allow by default. return true } } +// rootsEnabled reports whether the client advertises the roots capability. +func (c *Client) rootsEnabled() bool { + return c.opts.Capabilities == nil || c.opts.Capabilities.RootsV2 != nil +} + func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRootsResult, error) { + if !c.rootsEnabled() { + return nil, &jsonrpc.Error{Code: codeUnsupportedMethod, Message: "client does not support roots"} + } c.mu.Lock() defer c.mu.Unlock() roots := slices.Collect(c.roots.all()) diff --git a/mcp/client_test.go b/mcp/client_test.go index 062cef79..bc6dc86e 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -1341,3 +1341,58 @@ func TestClientAllowMissingParamsCompat(t *testing.T) { t.Errorf("handler called with nil params %d times, want 1", got.Load()) } } + +func TestListRootsCapabilityDisabled(t *testing.T) { + ctx := context.Background() + roots := []*Root{{URI: "file:///secret", Name: "secret"}} + + tests := []struct { + name string + caps *ClientCapabilities + wantErr bool + }{ + {"default", nil, false}, + {"RootsV2 set", &ClientCapabilities{RootsV2: &RootCapabilities{}}, false}, + {"disabled", &ClientCapabilities{}, true}, + {"only deprecated Roots set", &ClientCapabilities{Roots: struct { + ListChanged bool `json:"listChanged,omitempty"` + }{ListChanged: true}}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ct, st := NewInMemoryTransports() + s := NewServer(testImpl, nil) + ss, err := s.Connect(ctx, st, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ss.Close() }) + + c := NewClient(testImpl, &ClientOptions{Capabilities: tc.caps}) + c.AddRoots(roots...) + cs, err := c.Connect(ctx, ct, &ClientSessionOptions{ProtocolVersion: protocolVersion20251125}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cs.Close() }) + + res, err := ss.ListRoots(ctx, nil) + if tc.wantErr { + var jerr *jsonrpc.Error + if !errors.As(err, &jerr) || jerr.Code != codeUnsupportedMethod { + t.Fatalf("ListRoots error = %v, want code %d", err, codeUnsupportedMethod) + } + if res != nil { + t.Errorf("ListRoots returned roots %v despite disabled capability", res.Roots) + } + return + } + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(roots, res.Roots); diff != "" { + t.Errorf("roots mismatch (-want +got):\n%s", diff) + } + }) + } +}