From ba3dea7a0d6211a8cf886bdb103ab5b2c09e8f01 Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Mon, 8 Dec 2025 19:25:17 -0500 Subject: [PATCH 1/3] mcp: fix broken client root capabilities To address #607, add ClientCapabilities.RootsV2, and populate it when constructing sending and receiving InitializeParams. Also generally improve documentation for protocol types related to capabilities. Fixes #607 --- docs/rough_edges.md | 6 ++ internal/docs/rough_edges.src.md | 6 ++ mcp/client.go | 4 + mcp/client_test.go | 18 ++-- mcp/protocol.go | 140 +++++++++++++++++++++++-------- mcp/server.go | 21 ++++- mcp/server_test.go | 123 +++++++++++++++++++++++++++ mcp/shared.go | 8 ++ 8 files changed, 279 insertions(+), 47 deletions(-) diff --git a/docs/rough_edges.md b/docs/rough_edges.md index 5c732bdf..d6dc826c 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -30,3 +30,9 @@ v2. - `AudioContent.MarshalJSON` should have had a pointer receiver, to be consistent with other content types. + +- `ClientCapabilities.Roots` should have been a distinguished struct pointer + ([see #607](https://github.com/modelcontextprotocol/go-sdk/issues/607)). + + **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the + semantics of other capability fields. diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index ff95b263..4566032a 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -29,3 +29,9 @@ v2. - `AudioContent.MarshalJSON` should have had a pointer receiver, to be consistent with other content types. + +- `ClientCapabilities.Roots` should have been a distinguished struct pointer + ([see #607](https://github.com/modelcontextprotocol/go-sdk/issues/607)). + + **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the + semantics of other capability fields. diff --git a/mcp/client.go b/mcp/client.go index 7349ba9c..1994adb4 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -128,7 +128,11 @@ type ClientSessionOptions struct { func (c *Client) capabilities() *ClientCapabilities { caps := &ClientCapabilities{} + // Due to an oversight (#607), roots require special handling. caps.Roots.ListChanged = true + caps.RootsV2 = &RootsCapabilities{ + ListChanged: true, + } if c.opts.CreateMessageHandler != nil { caps.Sampling = &SamplingCapabilities{} } diff --git a/mcp/client_test.go b/mcp/client_test.go index a7fb68dc..c626ef6d 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -192,6 +192,7 @@ func TestClientPaginateVariousPageSizes(t *testing.T) { } func TestClientCapabilities(t *testing.T) { + canListRoots := RootsCapabilities{ListChanged: true} testCases := []struct { name string configureClient func(s *Client) @@ -202,9 +203,8 @@ func TestClientCapabilities(t *testing.T) { name: "With initial capabilities", configureClient: func(s *Client) {}, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, + Roots: canListRoots, + RootsV2: &canListRoots, }, }, { @@ -216,9 +216,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, + Roots: canListRoots, + RootsV2: &canListRoots, Sampling: &SamplingCapabilities{}, }, }, @@ -232,9 +231,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, + Roots: canListRoots, + RootsV2: &canListRoots, Elicitation: &ElicitationCapabilities{ Form: &FormElicitationCapabilities{}, }, @@ -253,6 +251,7 @@ func TestClientCapabilities(t *testing.T) { Roots: struct { ListChanged bool "json:\"listChanged,omitempty\"" }{ListChanged: true}, + RootsV2: &RootsCapabilities{ListChanged: true}, Elicitation: &ElicitationCapabilities{ URL: &URLElicitationCapabilities{}, }, @@ -271,6 +270,7 @@ func TestClientCapabilities(t *testing.T) { Roots: struct { ListChanged bool "json:\"listChanged,omitempty\"" }{ListChanged: true}, + RootsV2: &RootsCapabilities{ListChanged: true}, Elicitation: &ElicitationCapabilities{ Form: &FormElicitationCapabilities{}, URL: &URLElicitationCapabilities{}, diff --git a/mcp/protocol.go b/mcp/protocol.go index 8a88f8e2..a52b740e 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -177,23 +177,58 @@ func (x *CancelledParams) isParams() {} func (x *CancelledParams) GetProgressToken() any { return getProgressToken(x) } func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) } +// RootsCapabilities describes a client's support for roots. +type RootsCapabilities struct { + // ListChanged reports whether the client supports notifications for + // changes to the roots list. + ListChanged bool `json:"listChanged,omitempty"` +} + // Capabilities a client may support. Known capabilities are defined here, in // this schema, but this is not a closed set: any client can define its own, // additional capabilities. type ClientCapabilities struct { - // Experimental, non-standard capabilities that the client supports. + // Experimental reports non-standard capabilities that the client supports. Experimental map[string]any `json:"experimental,omitempty"` - // Present if the client supports listing roots. + // Roots describes the client's support for roots. + // + // Deprecated: use RootsV2. As described in #607, Roots should have been a + // pointer to a RootsCapabilities value. Roots will be continue to be + // populated, but any new fields will only be added in the RootsV2 field. Roots struct { - // Whether the client supports notifications for changes to the roots list. + // ListChanged reports whether the client supports notifications for + // changes to the roots list. ListChanged bool `json:"listChanged,omitempty"` } `json:"roots,omitempty"` - // Present if the client supports sampling from an LLM. + // RootsV2 is present if the client supports roots. + RootsV2 *RootsCapabilities `json:"-"` + // Sampling is present if the client supports sampling from an LLM. Sampling *SamplingCapabilities `json:"sampling,omitempty"` - // Present if the client supports elicitation from the server. + // Elicitation is present if the client supports elicitation from the server. Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"` } +func (c *ClientCapabilities) toV2() *clientCapabilitiesV2 { + return &clientCapabilitiesV2{ + ClientCapabilities: *c, + Roots: c.RootsV2, + } +} + +// clientCapabilitiesV2 is a version of ClientCapabilities that fixes the bug +// described in #607: Roots should have been a pointer to value type +// RootsCapabilities. +type clientCapabilitiesV2 struct { + ClientCapabilities + Roots *RootsCapabilities `json:"roots,omitempty"` +} + +func (c *clientCapabilitiesV2) toV1() *ClientCapabilities { + caps := c.ClientCapabilities + caps.RootsV2 = c.Roots + return &caps +} + type CompleteParamsArgument struct { // The name of the argument Name string `json:"name"` @@ -373,27 +408,53 @@ type GetPromptResult struct { func (*GetPromptResult) isResult() {} +// InitializeParams is sent by the client to initialize the session. type InitializeParams struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` + Meta `json:"_meta,omitempty"` + // Capabilities describes the client's capabilities. Capabilities *ClientCapabilities `json:"capabilities"` - ClientInfo *Implementation `json:"clientInfo"` - // The latest version of the Model Context Protocol that the client supports. - // The client may decide to support older versions as well. + // ClientInfo provides information about the client. + ClientInfo *Implementation `json:"clientInfo"` + // ProtocolVersion is the latest version of the Model Context Protocol that + // the client supports. ProtocolVersion string `json:"protocolVersion"` } +func (p *InitializeParams) toV2() *initializeParamsV2 { + return &initializeParamsV2{ + InitializeParams: *p, + Capabilities: p.Capabilities.toV2(), + } +} + +// initializeParamsV2 works around the mistake in #607: Capabilities.Roots +// should have been a pointer. +type initializeParamsV2 struct { + InitializeParams + Capabilities *clientCapabilitiesV2 `json:"capabilities"` +} + +func (p *initializeParamsV2) toV1() *InitializeParams { + p1 := p.InitializeParams + if p.Capabilities != nil { + p1.Capabilities = p.Capabilities.toV1() + } + return &p1 +} + func (x *InitializeParams) isParams() {} func (x *InitializeParams) GetProgressToken() any { return getProgressToken(x) } func (x *InitializeParams) SetProgressToken(t any) { setProgressToken(x, t) } -// After receiving an initialize request from the client, the server sends this -// response. +// InitializeResult is sent by the server in response to an initialize request +// from the client. type InitializeResult struct { // This property is reserved by the protocol to allow clients and servers to // attach additional metadata to their responses. - Meta `json:"_meta,omitempty"` + Meta `json:"_meta,omitempty"` + // Capabilities describes the server's capabilities. Capabilities *ServerCapabilities `json:"capabilities"` // Instructions describing how to use the server and its features. // @@ -411,8 +472,8 @@ type InitializeResult struct { func (*InitializeResult) isResult() {} type InitializedParams struct { - // This property is reserved by the protocol to allow clients and servers to - // attach additional metadata to their responses. + // Meta is reserved by the protocol to allow clients and servers to attach + // additional metadata to their responses. Meta `json:"_meta,omitempty"` } @@ -875,7 +936,10 @@ func (x *RootsListChangedParams) isParams() {} func (x *RootsListChangedParams) GetProgressToken() any { return getProgressToken(x) } func (x *RootsListChangedParams) SetProgressToken(t any) { setProgressToken(x, t) } -// SamplingCapabilities describes the capabilities for sampling. +// TODO: to be consistent with ServerCapabilities, move the capability types +// below directly above ClientCapabilities. + +// SamplingCapabilities describes the client's support for sampling. type SamplingCapabilities struct{} // ElicitationCapabilities describes the capabilities for elicitation. @@ -1160,50 +1224,52 @@ type Implementation struct { Icons []Icon `json:"icons,omitempty"` } -// Present if the server supports argument autocompletion suggestions. +// CompletionCapabilities describes the server's support for argument autocompletion. type CompletionCapabilities struct{} -// Present if the server supports sending log messages to the client. +// LoggingCapabilities describes the server's support for sending log messages to the client. type LoggingCapabilities struct{} -// Present if the server offers any prompt templates. +// PromptCapabilities describes the server's support for prompts. type PromptCapabilities struct { // Whether this server supports notifications for changes to the prompt list. ListChanged bool `json:"listChanged,omitempty"` } -// Present if the server offers any resources to read. +// ResourceCapabilities describes the server's support for resources. type ResourceCapabilities struct { - // Whether this server supports notifications for changes to the resource list. + // ListChanged reports whether the client supports notifications for + // changes to the resource list. ListChanged bool `json:"listChanged,omitempty"` - // Whether this server supports subscribing to resource updates. + // Subscribe reports whether this server supports subscribing to resource + // updates. Subscribe bool `json:"subscribe,omitempty"` } -// Capabilities that a server may support. Known capabilities are defined here, -// in this schema, but this is not a closed set: any server can define its own, -// additional capabilities. +// ToolCapabilities describes the server's support for tools. +type ToolCapabilities struct { + // ListChanged reports whether the client supports notifications for + // changes to the tool list. + ListChanged bool `json:"listChanged,omitempty"` +} + +// ServerCapabilities describes capabilities that a server supports. type ServerCapabilities struct { - // Present if the server supports argument autocompletion suggestions. - Completions *CompletionCapabilities `json:"completions,omitempty"` - // Experimental, non-standard capabilities that the server supports. + // Experimental reports non-standard capabilities that the server supports. Experimental map[string]any `json:"experimental,omitempty"` - // Present if the server supports sending log messages to the client. + // Completions is present if the server supports argument autocompletion + // suggestions. + Completions *CompletionCapabilities `json:"completions,omitempty"` + // Logging is present if the server supports log messages. Logging *LoggingCapabilities `json:"logging,omitempty"` - // Present if the server offers any prompt templates. + // Prompts is present if the server supports prompts. Prompts *PromptCapabilities `json:"prompts,omitempty"` - // Present if the server offers any resources to read. + // Resources is present if the server supports resourcs. Resources *ResourceCapabilities `json:"resources,omitempty"` - // Present if the server offers any tools to call. + // Tools is present if the supports tools. Tools *ToolCapabilities `json:"tools,omitempty"` } -// Present if the server offers any tools to call. -type ToolCapabilities struct { - // Whether this server supports notifications for changes to the tool list. - ListChanged bool `json:"listChanged,omitempty"` -} - const ( methodCallTool = "tools/call" notificationCancelled = "notifications/cancelled" diff --git a/mcp/server.go b/mcp/server.go index d4317222..35550e00 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -1138,7 +1138,7 @@ func (s *Server) AddReceivingMiddleware(middleware ...Middleware) { // curating these method flags. var serverMethodInfos = map[string]methodInfo{ methodComplete: newServerMethodInfo(serverMethod((*Server).complete), 0), - methodInitialize: newServerMethodInfo(serverSessionMethod((*ServerSession).initialize), 0), + methodInitialize: initializeMethodInfo(), methodPing: newServerMethodInfo(serverSessionMethod((*ServerSession).ping), missingParamsOK), methodListPrompts: newServerMethodInfo(serverMethod((*Server).listPrompts), missingParamsOK), methodGetPrompt: newServerMethodInfo(serverMethod((*Server).getPrompt), 0), @@ -1156,6 +1156,25 @@ var serverMethodInfos = map[string]methodInfo{ notificationProgress: newServerMethodInfo(serverSessionMethod((*ServerSession).callProgressNotificationHandler), notification), } +// initializeMethodInfo handles the workaround for #607: we must set +// params.Capabilities.RootsV2. +func initializeMethodInfo() methodInfo { + info := newServerMethodInfo(serverSessionMethod((*ServerSession).initialize), 0) + info.unmarshalParams = func(m json.RawMessage) (Params, error) { + var params *initializeParamsV2 + if m != nil { + if err := json.Unmarshal(m, ¶ms); err != nil { + return nil, fmt.Errorf("unmarshaling %q into a %T: %w", m, params, err) + } + } + if params == nil { + return nil, fmt.Errorf(`missing required "params"`) + } + return params.toV1(), nil + } + return info +} + func (ss *ServerSession) sendingMethodInfos() map[string]methodInfo { return clientMethodInfos } func (ss *ServerSession) receivingMethodInfos() map[string]methodInfo { return serverMethodInfos } diff --git a/mcp/server_test.go b/mcp/server_test.go index d8c0df65..55eadad7 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -17,6 +17,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/internal/jsonrpc2" ) type testItem struct { @@ -600,6 +601,128 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out } } +// TestClientRootsCapabilities verifies that the server correctly observes +// RootsV2 for various client capability configurations. This tests the fix +// for #607. +func TestClientRootsCapabilities(t *testing.T) { + testCases := []struct { + name string + capabilities *string // JSON for the capabilities field; nil means omit the field + wantRootsV2 *RootsCapabilities + }{ + { + name: "capabilities field omitted", + capabilities: nil, + wantRootsV2: nil, + }, + { + name: "empty capabilities", + capabilities: ptr(`{}`), + wantRootsV2: nil, + }, + { + name: "capabilities with no roots", + capabilities: ptr(`{"sampling": {}}`), + wantRootsV2: nil, + }, + { + name: "capabilities with empty roots", + capabilities: ptr(`{"roots": {}}`), + wantRootsV2: &RootsCapabilities{ListChanged: false}, + }, + { + name: "capabilities with roots without listChanged", + capabilities: ptr(`{"roots": {"listChanged": false}}`), + wantRootsV2: &RootsCapabilities{ListChanged: false}, + }, + { + name: "capabilities with roots with listChanged", + capabilities: ptr(`{"roots": {"listChanged": true}}`), + wantRootsV2: &RootsCapabilities{ListChanged: true}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + // Create a minimal server. + impl := &Implementation{Name: "testServer", Version: "v1.0.0"} + s := NewServer(impl, nil) + + // Connect the server. + cTransport, sTransport := NewInMemoryTransports() + ss, err := s.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + + // Connect the client JSON-RPC connection (raw, no client). + cConn, err := cTransport.Connect(ctx) + if err != nil { + t.Fatal(err) + } + + // Build initialize params, optionally including capabilities. + var initParams json.RawMessage + if tc.capabilities != nil { + initParams = json.RawMessage(`{ + "protocolVersion": "2025-06-18", + "capabilities": ` + *tc.capabilities + `, + "clientInfo": {"name": "TestClient", "version": "1.0.0"} + }`) + } else { + initParams = json.RawMessage(`{ + "protocolVersion": "2025-06-18", + "clientInfo": {"name": "TestClient", "version": "1.0.0"} + }`) + } + + initReq, err := jsonrpc2.NewCall(jsonrpc2.Int64ID(1), "initialize", initParams) + if err != nil { + t.Fatal(err) + } + + if err := cConn.Write(ctx, initReq); err != nil { + t.Fatalf("Write failed: %v", err) + } + + // Read the initialize response. + msg, err := cConn.Read(ctx) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + resp, ok := msg.(*jsonrpc2.Response) + if !ok { + t.Fatalf("expected Response, got %T", msg) + } + if resp.Error != nil { + t.Fatalf("initialize failed: %v", resp.Error) + } + + // Verify that the server session has the correct RootsV2 value. + params := ss.InitializeParams() + if params == nil { + t.Fatal("InitializeParams is nil") + } + + var gotRootsV2 *RootsCapabilities + if params.Capabilities != nil { + gotRootsV2 = params.Capabilities.RootsV2 + } + if diff := cmp.Diff(tc.wantRootsV2, gotRootsV2); diff != "" { + t.Errorf("RootsV2 mismatch (-want +got):\n%s", diff) + } + + // Close the client connection. + if err := cConn.Close(); err != nil { + t.Fatalf("Stream.Close failed: %v", err) + } + ss.Wait() + }) + } +} + // TODO: move this to tool_test.go func TestToolForSchemas(t *testing.T) { // Validate that toolForErr handles schemas properly. diff --git a/mcp/shared.go b/mcp/shared.go index 43a0d6f1..be660202 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -94,6 +94,14 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request // This can be called from user code, with an arbitrary value for method. return nil, jsonrpc2.ErrNotHandled } + params := req.GetParams() + if initParams, ok := params.(*InitializeParams); ok { + // Fix the marshaling of initialize params, to work around #607. + // + // The initialize params we produce should never be nil, nor have nil + // capabilities, so any panic here is a bug. + params = initParams.toV2() + } // Notifications don't have results. if strings.HasPrefix(method, "notifications/") { return nil, req.GetSession().getConn().Notify(ctx, method, req.GetParams()) From 23b1b68a609edf8c465bea796cc15ee0c0b7fbae Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 10 Dec 2025 14:03:36 -0500 Subject: [PATCH 2/3] address review comments --- mcp/client.go | 2 +- mcp/client_test.go | 25 ++++++++++--------------- mcp/protocol.go | 12 ++++++------ mcp/server_test.go | 14 +++++++------- 4 files changed, 24 insertions(+), 29 deletions(-) diff --git a/mcp/client.go b/mcp/client.go index 1994adb4..d975a8e4 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -130,7 +130,7 @@ func (c *Client) capabilities() *ClientCapabilities { caps := &ClientCapabilities{} // Due to an oversight (#607), roots require special handling. caps.Roots.ListChanged = true - caps.RootsV2 = &RootsCapabilities{ + caps.RootsV2 = &RootCapabilities{ ListChanged: true, } if c.opts.CreateMessageHandler != nil { diff --git a/mcp/client_test.go b/mcp/client_test.go index c626ef6d..0522c063 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -192,7 +192,6 @@ func TestClientPaginateVariousPageSizes(t *testing.T) { } func TestClientCapabilities(t *testing.T) { - canListRoots := RootsCapabilities{ListChanged: true} testCases := []struct { name string configureClient func(s *Client) @@ -203,8 +202,8 @@ func TestClientCapabilities(t *testing.T) { name: "With initial capabilities", configureClient: func(s *Client) {}, wantCapabilities: &ClientCapabilities{ - Roots: canListRoots, - RootsV2: &canListRoots, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, }, }, { @@ -216,8 +215,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: canListRoots, - RootsV2: &canListRoots, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, Sampling: &SamplingCapabilities{}, }, }, @@ -231,8 +230,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: canListRoots, - RootsV2: &canListRoots, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, Elicitation: &ElicitationCapabilities{ Form: &FormElicitationCapabilities{}, }, @@ -248,10 +247,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, - RootsV2: &RootsCapabilities{ListChanged: true}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, Elicitation: &ElicitationCapabilities{ URL: &URLElicitationCapabilities{}, }, @@ -267,10 +264,8 @@ func TestClientCapabilities(t *testing.T) { }, }, wantCapabilities: &ClientCapabilities{ - Roots: struct { - ListChanged bool "json:\"listChanged,omitempty\"" - }{ListChanged: true}, - RootsV2: &RootsCapabilities{ListChanged: true}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, Elicitation: &ElicitationCapabilities{ Form: &FormElicitationCapabilities{}, URL: &URLElicitationCapabilities{}, diff --git a/mcp/protocol.go b/mcp/protocol.go index a52b740e..c3c469a8 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -177,8 +177,8 @@ func (x *CancelledParams) isParams() {} func (x *CancelledParams) GetProgressToken() any { return getProgressToken(x) } func (x *CancelledParams) SetProgressToken(t any) { setProgressToken(x, t) } -// RootsCapabilities describes a client's support for roots. -type RootsCapabilities struct { +// RootCapabilities describes a client's support for roots. +type RootCapabilities struct { // ListChanged reports whether the client supports notifications for // changes to the roots list. ListChanged bool `json:"listChanged,omitempty"` @@ -193,7 +193,7 @@ type ClientCapabilities struct { // Roots describes the client's support for roots. // // Deprecated: use RootsV2. As described in #607, Roots should have been a - // pointer to a RootsCapabilities value. Roots will be continue to be + // pointer to a RootCapabilities value. Roots will be continue to be // populated, but any new fields will only be added in the RootsV2 field. Roots struct { // ListChanged reports whether the client supports notifications for @@ -201,7 +201,7 @@ type ClientCapabilities struct { ListChanged bool `json:"listChanged,omitempty"` } `json:"roots,omitempty"` // RootsV2 is present if the client supports roots. - RootsV2 *RootsCapabilities `json:"-"` + RootsV2 *RootCapabilities `json:"-"` // Sampling is present if the client supports sampling from an LLM. Sampling *SamplingCapabilities `json:"sampling,omitempty"` // Elicitation is present if the client supports elicitation from the server. @@ -217,10 +217,10 @@ func (c *ClientCapabilities) toV2() *clientCapabilitiesV2 { // clientCapabilitiesV2 is a version of ClientCapabilities that fixes the bug // described in #607: Roots should have been a pointer to value type -// RootsCapabilities. +// RootCapabilities. type clientCapabilitiesV2 struct { ClientCapabilities - Roots *RootsCapabilities `json:"roots,omitempty"` + Roots *RootCapabilities `json:"roots,omitempty"` } func (c *clientCapabilitiesV2) toV1() *ClientCapabilities { diff --git a/mcp/server_test.go b/mcp/server_test.go index 55eadad7..b578cbf2 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -601,14 +601,14 @@ func testToolForSchema[In, Out any](t *testing.T, tool *Tool, in string, out Out } } -// TestClientRootsCapabilities verifies that the server correctly observes +// TestClientRootCapabilities verifies that the server correctly observes // RootsV2 for various client capability configurations. This tests the fix // for #607. -func TestClientRootsCapabilities(t *testing.T) { +func TestClientRootCapabilities(t *testing.T) { testCases := []struct { name string capabilities *string // JSON for the capabilities field; nil means omit the field - wantRootsV2 *RootsCapabilities + wantRootsV2 *RootCapabilities }{ { name: "capabilities field omitted", @@ -628,17 +628,17 @@ func TestClientRootsCapabilities(t *testing.T) { { name: "capabilities with empty roots", capabilities: ptr(`{"roots": {}}`), - wantRootsV2: &RootsCapabilities{ListChanged: false}, + wantRootsV2: &RootCapabilities{ListChanged: false}, }, { name: "capabilities with roots without listChanged", capabilities: ptr(`{"roots": {"listChanged": false}}`), - wantRootsV2: &RootsCapabilities{ListChanged: false}, + wantRootsV2: &RootCapabilities{ListChanged: false}, }, { name: "capabilities with roots with listChanged", capabilities: ptr(`{"roots": {"listChanged": true}}`), - wantRootsV2: &RootsCapabilities{ListChanged: true}, + wantRootsV2: &RootCapabilities{ListChanged: true}, }, } @@ -706,7 +706,7 @@ func TestClientRootsCapabilities(t *testing.T) { t.Fatal("InitializeParams is nil") } - var gotRootsV2 *RootsCapabilities + var gotRootsV2 *RootCapabilities if params.Capabilities != nil { gotRootsV2 = params.Capabilities.RootsV2 } From 5995c241b6f66e1d95fe5972e399291df5820b4e Mon Sep 17 00:00:00 2001 From: Rob Findley Date: Wed, 10 Dec 2025 22:32:44 -0500 Subject: [PATCH 3/3] fix bugs in capabilities over wire --- docs/troubleshooting.md | 2 +- mcp/protocol.go | 4 ++++ mcp/shared.go | 4 ++-- mcp/transport_example_test.go | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c22a279d..b8cc7769 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -51,7 +51,7 @@ func ExampleLoggingTransport() { // Output: // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}} // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} } ``` diff --git a/mcp/protocol.go b/mcp/protocol.go index c3c469a8..563b3799 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -226,6 +226,10 @@ type clientCapabilitiesV2 struct { func (c *clientCapabilitiesV2) toV1() *ClientCapabilities { caps := c.ClientCapabilities caps.RootsV2 = c.Roots + // Sync Roots from RootsV2 for backward compatibility (#607). + if caps.RootsV2 != nil { + caps.Roots = *caps.RootsV2 + } return &caps } diff --git a/mcp/shared.go b/mcp/shared.go index be660202..b4516df1 100644 --- a/mcp/shared.go +++ b/mcp/shared.go @@ -104,12 +104,12 @@ func defaultSendingMethodHandler(ctx context.Context, method string, req Request } // Notifications don't have results. if strings.HasPrefix(method, "notifications/") { - return nil, req.GetSession().getConn().Notify(ctx, method, req.GetParams()) + return nil, req.GetSession().getConn().Notify(ctx, method, params) } // Create the result to unmarshal into. // The concrete type of the result is the return type of the receiving function. res := info.newResult() - if err := call(ctx, req.GetSession().getConn(), method, req.GetParams(), res); err != nil { + if err := call(ctx, req.GetSession().getConn(), method, params, res); err != nil { return nil, err } return res, nil diff --git a/mcp/transport_example_test.go b/mcp/transport_example_test.go index 7390ea4e..bb032f11 100644 --- a/mcp/transport_example_test.go +++ b/mcp/transport_example_test.go @@ -46,7 +46,7 @@ func ExampleLoggingTransport() { // Output: // read: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"logging":{}},"protocolVersion":"2025-06-18","serverInfo":{"name":"server","version":"v0.0.1"}}} - // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"roots":{"listChanged":true}},"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18"}} + // write: {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"client","version":"v0.0.1"},"protocolVersion":"2025-06-18","capabilities":{"roots":{"listChanged":true}}}} // write: {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} }