diff --git a/docs/client.md b/docs/client.md index 6ce20c90..565fae96 100644 --- a/docs/client.md +++ b/docs/client.md @@ -4,6 +4,9 @@ 1. [Roots](#roots) 1. [Sampling](#sampling) 1. [Elicitation](#elicitation) +1. [Capabilities](#capabilities) + 1. [Capability inference](#capability-inference) + 1. [Explicit capabilities](#explicit-capabilities) ## Roots @@ -130,7 +133,9 @@ allows servers to request user inputs. It is implemented in the SDK as follows: **Client-side**: To add the `elicitation` capability to a client, set [`ClientOptions.ElicitationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationHandler). The elicitation handler must return a result that matches the requested schema; -otherwise, elicitation returns an error. +otherwise, elicitation returns an error. If your handler supports [URL mode +elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-mode-elicitation-requests), +you must declare that capability explicitly (see [Capabilities](#capabilities)) **Server-side**: To use elicitation from the server, call [`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). @@ -171,3 +176,60 @@ func Example_elicitation() { // Output: value } ``` + +## Capabilities + +Client capabilities are advertised to servers during the initialization +handshake. [By default](rough_edges.md), the SDK advertises the `logging` +capability. Additional capabilities are automatically added when server +features are added (e.g. via `AddTool`), or when handlers are set in the +`ServerOptions` struct (e.g., setting `CompletionHandler` adds the +`completions` capability), or may be configured explicitly. + +### Capability inference + +When handlers are set on `ClientOptions` (e.g., `CreateMessageHandler` or +`ElicitationHandler`), the corresponding capability is automatically added if +not already present, with a default configuration. + +For elicitation, if the handler is set but no `Capabilities.Elicitation` is +specified, the client defaults to form elicitation. To enable URL elicitation +or both modes, [configure `Capabilities.Elicitation` +explicitly](#explicit-capabilities). + +See the [`ClientCapabilities` +documentation](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientCapabilities) +for further details on inference. + +### Explicit capabilities + +To explicitly declare capabilities, or to override the [default inferred +capability](#capability-inference), set +[`ClientOptions.Capabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.Capabilities). +This sets the initial client capabilities, before any capabilities are added +based on configured handlers. If a capability is already present in +`Capabilities`, adding a handler will not change its configuration. + +This allows you to: + +- **Disable default capabilities**: Pass an empty `&ClientCapabilities{}` to + disable all defaults, including roots. +- **Disable listChanged notifications**: Set `ListChanged: false` on a + capability to prevent the client from sending list-changed notifications + when roots are added or removed. +- **Configure elicitation modes**: Specify which elicitation modes (form, URL) + the client supports. + +```go +// Configure elicitation modes and disable roots. +client := mcp.NewClient(impl, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{ + Form: &mcp.FormElicitationCapabilities{}, + URL: &mcp.URLElicitationCapabilities{}, + }, + }, + ElicitationHandler: handler, +}) +``` + diff --git a/docs/rough_edges.md b/docs/rough_edges.md index d6dc826c..98758cd6 100644 --- a/docs/rough_edges.md +++ b/docs/rough_edges.md @@ -36,3 +36,12 @@ v2. **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the semantics of other capability fields. + +- Default capabilities should have been empty. Instead, servers default to + advertising `logging`, and clients default to advertising `roots` with + `listChanged: true`. This is confusing because a nil `Capabilities` field + does not mean "no capabilities". + + **Workaround**: to advertise no capabilities, set + `ServerOptions.Capabilities` or `ClientOptions.Capabilities` to an empty + `&ServerCapabilities{}` or `&ClientCapabilities{}` respectively. diff --git a/docs/server.md b/docs/server.md index f986c68f..4c3c80b8 100644 --- a/docs/server.md +++ b/docs/server.md @@ -7,6 +7,9 @@ 1. [Utilities](#utilities) 1. [Completion](#completion) 1. [Logging](#logging) +1. [Capabilities](#capabilities) + 1. [Capability inference](#capability-inference) + 1. [Explicit capabilities](#explicit-capabilities) 1. [Pagination](#pagination) ## Prompts @@ -535,6 +538,53 @@ func Example_logging() { } ``` +## Capabilities + +Server capabilities are advertised to clients during the initialization +handshake. By default, the SDK advertises only the `logging` capability. +Additional capabilities are automatically added when features are registered +(e.g., adding a tool adds the `tools` capability). + +### Capability inference + +When features such as tools, prompts, or resources are added to the server +(e.g., via `Server.AddTool`), their capability is automatically inferred, with +default value `{listChanged:true}`. Similarly, if the +`ServerOptions.SubscribeHandler` or `ServerOptions.CompletionHandler` are set, +the corresponding capability is added. + +### Explicit capabilities + +To explicitly declare capabilities, or to override the [default inferred +capability](#capability-inference), set +[`ServerOptions.Capabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.Capabilities). +This sets the default server capabilities, before any capabilities are added +based on configured handlers. If a capability is already present as a field in +`Capabilities`, adding a feature or handler will not change its configuration. + +This allows you to: + +- **Disable default capabilities**: Pass an empty `&ServerCapabilities{}` to + disable all defaults, including logging. +- **Disable listChanged notifications**: Set `ListChanged: false` on a + capability to prevent the server from sending list-changed notifications + when features are added or removed. +- **Pre-declare capabilities**: Declare capabilities before features are + registered, useful for servers that load features dynamically. + +```go +// Disable listChanged notifications for tools +server := mcp.NewServer(impl, &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{ + Logging: &mcp.LoggingCapabilities{}, + Tools: &mcp.ToolCapabilities{ListChanged: false}, + }, +}) +``` + +**Deprecated**: The `HasPrompts`, `HasResources`, and `HasTools` fields on +`ServerOptions` are deprecated. Use `Capabilities` instead. + ### Pagination Server-side feature lists may be diff --git a/internal/docs/client.src.md b/internal/docs/client.src.md index fc37d454..f6ae786f 100644 --- a/internal/docs/client.src.md +++ b/internal/docs/client.src.md @@ -47,9 +47,68 @@ allows servers to request user inputs. It is implemented in the SDK as follows: **Client-side**: To add the `elicitation` capability to a client, set [`ClientOptions.ElicitationHandler`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.ElicitationHandler). The elicitation handler must return a result that matches the requested schema; -otherwise, elicitation returns an error. +otherwise, elicitation returns an error. If your handler supports [URL mode +elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-mode-elicitation-requests), +you must declare that capability explicitly (see [Capabilities](#capabilities)) **Server-side**: To use elicitation from the server, call [`ServerSession.Elicit`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerSession.Elicit). %include ../../mcp/client_example_test.go elicitation - + +## Capabilities + +Client capabilities are advertised to servers during the initialization +handshake. [By default](rough_edges.md), the SDK advertises the `logging` +capability. Additional capabilities are automatically added when server +features are added (e.g. via `AddTool`), or when handlers are set in the +`ServerOptions` struct (e.g., setting `CompletionHandler` adds the +`completions` capability), or may be configured explicitly. + +### Capability inference + +When handlers are set on `ClientOptions` (e.g., `CreateMessageHandler` or +`ElicitationHandler`), the corresponding capability is automatically added if +not already present, with a default configuration. + +For elicitation, if the handler is set but no `Capabilities.Elicitation` is +specified, the client defaults to form elicitation. To enable URL elicitation +or both modes, [configure `Capabilities.Elicitation` +explicitly](#explicit-capabilities). + +See the [`ClientCapabilities` +documentation](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientCapabilities) +for further details on inference. + +### Explicit capabilities + +To explicitly declare capabilities, or to override the [default inferred +capability](#capability-inference), set +[`ClientOptions.Capabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ClientOptions.Capabilities). +This sets the initial client capabilities, before any capabilities are added +based on configured handlers. If a capability is already present in +`Capabilities`, adding a handler will not change its configuration. + +This allows you to: + +- **Disable default capabilities**: Pass an empty `&ClientCapabilities{}` to + disable all defaults, including roots. +- **Disable listChanged notifications**: Set `ListChanged: false` on a + capability to prevent the client from sending list-changed notifications + when roots are added or removed. +- **Configure elicitation modes**: Specify which elicitation modes (form, URL) + the client supports. + +```go +// Configure elicitation modes and disable roots. +client := mcp.NewClient(impl, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{ + Form: &mcp.FormElicitationCapabilities{}, + URL: &mcp.URLElicitationCapabilities{}, + }, + }, + ElicitationHandler: handler, +}) +``` + diff --git a/internal/docs/rough_edges.src.md b/internal/docs/rough_edges.src.md index 4566032a..4dc32199 100644 --- a/internal/docs/rough_edges.src.md +++ b/internal/docs/rough_edges.src.md @@ -35,3 +35,12 @@ v2. **Workaround**: use `ClientCapabilities.RootsV2`, which aligns with the semantics of other capability fields. + +- Default capabilities should have been empty. Instead, servers default to + advertising `logging`, and clients default to advertising `roots` with + `listChanged: true`. This is confusing because a nil `Capabilities` field + does not mean "no capabilities". + + **Workaround**: to advertise no capabilities, set + `ServerOptions.Capabilities` or `ClientOptions.Capabilities` to an empty + `&ServerCapabilities{}` or `&ClientCapabilities{}` respectively. diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index c09ba63e..c6946dbd 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -243,6 +243,53 @@ Call [`ClientSession.SetLevel`](https://pkg.go.dev/github.com/modelcontextprotoc %include ../../mcp/server_example_test.go logging - +## Capabilities + +Server capabilities are advertised to clients during the initialization +handshake. By default, the SDK advertises only the `logging` capability. +Additional capabilities are automatically added when features are registered +(e.g., adding a tool adds the `tools` capability). + +### Capability inference + +When features such as tools, prompts, or resources are added to the server +(e.g., via `Server.AddTool`), their capability is automatically inferred, with +default value `{listChanged:true}`. Similarly, if the +`ServerOptions.SubscribeHandler` or `ServerOptions.CompletionHandler` are set, +the corresponding capability is added. + +### Explicit capabilities + +To explicitly declare capabilities, or to override the [default inferred +capability](#capability-inference), set +[`ServerOptions.Capabilities`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#ServerOptions.Capabilities). +This sets the default server capabilities, before any capabilities are added +based on configured handlers. If a capability is already present as a field in +`Capabilities`, adding a feature or handler will not change its configuration. + +This allows you to: + +- **Disable default capabilities**: Pass an empty `&ServerCapabilities{}` to + disable all defaults, including logging. +- **Disable listChanged notifications**: Set `ListChanged: false` on a + capability to prevent the server from sending list-changed notifications + when features are added or removed. +- **Pre-declare capabilities**: Declare capabilities before features are + registered, useful for servers that load features dynamically. + +```go +// Disable listChanged notifications for tools +server := mcp.NewServer(impl, &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{ + Logging: &mcp.LoggingCapabilities{}, + Tools: &mcp.ToolCapabilities{ListChanged: false}, + }, +}) +``` + +**Deprecated**: The `HasPrompts`, `HasResources`, and `HasTools` fields on +`ServerOptions` are deprecated. Use `Capabilities` instead. + ### Pagination Server-side feature lists may be diff --git a/mcp/capabilities_go125_test.go b/mcp/capabilities_go125_test.go new file mode 100644 index 00000000..4f959df6 --- /dev/null +++ b/mcp/capabilities_go125_test.go @@ -0,0 +1,180 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build go1.25 + +package mcp + +import ( + "context" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/google/jsonschema-go/jsonschema" +) + +// TestServerListChangedNotifications verifies that listChanged notifications +// are correctly sent or suppressed based on capability configuration. +func TestServerListChangedNotifications(t *testing.T) { + tool := &Tool{Name: "test-tool", InputSchema: &jsonschema.Schema{Type: "object"}} + + testCases := []struct { + name string + serverOpts *ServerOptions + wantNotifyCount int64 + }{ + { + name: "Default: notification sent", + serverOpts: nil, + wantNotifyCount: 1, + }, + { + name: "ListChanged false: notification suppressed", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + wantNotifyCount: 0, + }, + { + name: "ListChanged true: notification sent", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + wantNotifyCount: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + // Create server. + impl := &Implementation{Name: "testServer", Version: "v1.0.0"} + server := NewServer(impl, tc.serverOpts) + + // Track notifications. + var notifyCount atomic.Int64 + + // Connect client and server. + cTransport, sTransport := NewInMemoryTransports() + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, &ClientOptions{ + ToolListChangedHandler: func(ctx context.Context, req *ToolListChangedRequest) { + notifyCount.Add(1) + }, + }) + cs, err := client.Connect(ctx, cTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Add a tool, which may or may not trigger notification. + server.AddTool(tool, nil) + + // Sleep an arbitrary time longer than the debounce delay (synctest + // makes this practical). + time.Sleep(1 * time.Second) + + // Wait for all goroutines to be blocked (notification delivered). + synctest.Wait() + + if got, want := notifyCount.Load(), tc.wantNotifyCount; got != want { + t.Errorf("notification count: got %d, want %d", got, want) + } + }) + }) + } +} + +// TestClientListChangedNotifications verifies that roots listChanged notifications +// are correctly sent or suppressed based on client capability configuration. +func TestClientListChangedNotifications(t *testing.T) { + root := &Root{URI: "file:///test"} + + testCases := []struct { + name string + clientOpts *ClientOptions + wantNotifyCount int64 + }{ + { + name: "Default: notification sent", + clientOpts: nil, + wantNotifyCount: 1, + }, + { + name: "ListChanged false: notification suppressed", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + wantNotifyCount: 0, + }, + { + name: "ListChanged true: notification sent", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, + wantNotifyCount: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := context.Background() + + // Track notifications. + var notifyCount atomic.Int64 + + // Create server with roots list changed handler. + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, &ServerOptions{ + RootsListChangedHandler: func(ctx context.Context, req *RootsListChangedRequest) { + notifyCount.Add(1) + }, + }) + + // Connect client and server. + cTransport, sTransport := NewInMemoryTransports() + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, tc.clientOpts) + cs, err := client.Connect(ctx, cTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Add a root, which may or may not trigger notification. + client.AddRoots(root) + + // Wait for all goroutines to be blocked (notification delivered). + synctest.Wait() + + if got, want := notifyCount.Load(), tc.wantNotifyCount; got != want { + t.Errorf("notification count: got %d, want %d", got, want) + } + }) + }) + } +} diff --git a/mcp/client.go b/mcp/client.go index 612b12f4..2dc1a86c 100644 --- a/mcp/client.go +++ b/mcp/client.go @@ -63,17 +63,57 @@ func NewClient(impl *Implementation, opts *ClientOptions) *Client { type ClientOptions struct { // CreateMessageHandler handles incoming requests for sampling/createMessage. // - // Setting CreateMessageHandler to a non-nil value causes the client to - // advertise the sampling capability. + // Setting CreateMessageHandler to a non-nil value automatically causes the + // client to advertise the sampling capability, with default value + // &SamplingCapabilities{}. If [ClientOptions.Capabilities] is set and has a + // non nil value for [ClientCapabilities.Sampling], that value overrides the + // inferred capability. CreateMessageHandler func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) // ElicitationHandler handles incoming requests for elicitation/create. // - // Setting ElicitationHandler to a non-nil value causes the client to - // advertise the elicitation capability. + // Setting ElicitationHandler to a non-nil value automatically causes the + // client to advertise the elicitation capability, with default value + // &ElicitationCapabilities{}. If [ClientOptions.Capabilities] is set and has + // a non nil value for [ClientCapabilities.ELicitattion], that value + // overrides the inferred capability. ElicitationHandler func(context.Context, *ElicitRequest) (*ElicitResult, error) - // ElicitationModes specifies the elicitation modes supported by the client. - // If ElicitationHandler is set and ElicitationModes is empty, it defaults to ["form"]. - ElicitationModes []string + // Capabilities optionally configures the client's default capabilities, + // before any capabilities are inferred from other configuration. + // + // If Capabilities is nil, the default client capabilities are + // {"roots":{"listChanged":true}}, for historical reasons. Setting + // Capabilities to a non-nil value overrides this default. As a special case, + // to work around #607, Capabilities.Roots is ignored: set + // Capabilities.RootsV2 to configure the roots capability. This allows the + // "roots" capability to be disabled entirely. + // + // For example: + // - To disable the "roots" capability, use &ClientCapabilities{} + // - To configure "roots", but disable "listChanged" notifications, use + // &ClientCapabilities{RootsV2:&RootCapabilities{}}. + // + // # Interaction with capability inference + // + // Sampling and elicitation capabilities are automatically added when their + // corresponding handlers are set, with the default value described at + // [ClientOptions.CreateMessageHandler] and + // [ClientOptions.ElicitationHandler]. If the Sampling or Elicitation fields + // are set in the Capabilities field, their values override the inferred + // value. + // + // For example, to to configure elicitation modes: + // + // Capabilities: &ClientCapabilities{ + // Elicitation: &ElicitationCapabilities{ + // Form: &FormElicitationCapabilities{}, + // URL: &URLElicitationCapabilities{}, + // }, + // } + // + // Conversely, if Capabilities does not set a field (for example, if the + // Elicitation field is nil), the inferred elicitation capability will be + // used. + Capabilities *ClientCapabilities // ElicitationCompleteHandler handles incoming notifications for notifications/elicitation/complete. ElicitationCompleteHandler func(context.Context, *ElicitationCompleteNotificationRequest) // Handlers for notifications from the server. @@ -129,27 +169,43 @@ type ClientSessionOptions struct { protocolVersion string } -func (c *Client) capabilities() *ClientCapabilities { - caps := &ClientCapabilities{} - // Due to an oversight (#607), roots require special handling. - caps.Roots.ListChanged = true - caps.RootsV2 = &RootCapabilities{ - ListChanged: true, +func (c *Client) capabilities(protocolVersion string) *ClientCapabilities { + // Start with user-provided capabilities as defaults, or use SDK defaults. + var caps *ClientCapabilities + if c.opts.Capabilities != nil { + // Deep copy the user-provided capabilities to avoid mutation. + caps = c.opts.Capabilities.clone() + } else { + // SDK defaults: roots with listChanged. + // (this was the default behavior at v1.0.0, and so cannot be changed) + caps = &ClientCapabilities{ + RootsV2: &RootCapabilities{ + ListChanged: true, + }, + } + } + + // Sync Roots from RootsV2 for backward compatibility (#607). + if caps.RootsV2 != nil { + caps.Roots = *caps.RootsV2 } + + // Augment with sampling capability if handler is set. if c.opts.CreateMessageHandler != nil { - caps.Sampling = &SamplingCapabilities{} + if caps.Sampling == nil { + caps.Sampling = &SamplingCapabilities{} + } } + + // Augment with elicitation capability if handler is set. if c.opts.ElicitationHandler != nil { - caps.Elicitation = &ElicitationCapabilities{} - modes := c.opts.ElicitationModes - if len(modes) == 0 || slices.Contains(modes, "form") { - // Technically, the empty ElicitationCapabilities value is equivalent to - // {"form":{}} for backward compatibility, but we explicitly set the form - // capability. - caps.Elicitation.Form = &FormElicitationCapabilities{} - } - if slices.Contains(modes, "url") { - caps.Elicitation.URL = &URLElicitationCapabilities{} + if caps.Elicitation == nil { + caps.Elicitation = &ElicitationCapabilities{} + // Form elicitation was added in 2025-11-25; for older versions, + // {} is treated the same as {"form":{}}. + if protocolVersion >= protocolVersion20251125 { + caps.Elicitation.Form = &FormElicitationCapabilities{} + } } } return caps @@ -175,7 +231,7 @@ func (c *Client) Connect(ctx context.Context, t Transport, opts *ClientSessionOp params := &InitializeParams{ ProtocolVersion: protocolVersion, ClientInfo: c.impl, - Capabilities: c.capabilities(), + Capabilities: c.capabilities(protocolVersion), } req := &InitializeRequest{Session: cs, Params: params} res, err := handleSend[*InitializeResult](ctx, methodInitialize, req) @@ -345,12 +401,38 @@ func changeAndNotify[P Params](c *Client, notification string, params P, change // Lock for the change, but not for the notification. c.mu.Lock() if change() { - sessions = slices.Clone(c.sessions) + // Check if listChanged is enabled for this notification type. + if c.shouldSendListChangedNotification(notification) { + sessions = slices.Clone(c.sessions) + } } c.mu.Unlock() notifySessions(sessions, notification, params, c.logger) } +// shouldSendListChangedNotification checks if the client's capabilities allow +// sending the given list-changed notification. +func (c *Client) shouldSendListChangedNotification(notification string) bool { + // Get effective capabilities (considering user-provided defaults). + caps := c.opts.Capabilities + + switch notification { + case notificationRootsListChanged: + // If user didn't specify capabilities, default behavior sends notifications. + 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 + default: + // Unknown notification, allow by default. + return true + } +} + func (c *Client) listRoots(_ context.Context, req *ListRootsRequest) (*ListRootsResult, error) { c.mu.Lock() defer c.mu.Unlock() diff --git a/mcp/client_test.go b/mcp/client_test.go index 0522c063..ad8c0f12 100644 --- a/mcp/client_test.go +++ b/mcp/client_test.go @@ -196,10 +196,11 @@ func TestClientCapabilities(t *testing.T) { name string configureClient func(s *Client) clientOpts ClientOptions + protocolVersion string // defaults to latestProtocolVersion if empty wantCapabilities *ClientCapabilities }{ { - name: "With initial capabilities", + name: "default", configureClient: func(s *Client) {}, wantCapabilities: &ClientCapabilities{ Roots: RootCapabilities{ListChanged: true}, @@ -207,7 +208,7 @@ func TestClientCapabilities(t *testing.T) { }, }, { - name: "With sampling", + name: "with sampling", configureClient: func(s *Client) {}, clientOpts: ClientOptions{ CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { @@ -221,14 +222,14 @@ func TestClientCapabilities(t *testing.T) { }, }, { - name: "With form elicitation", + name: "with elicitation", configureClient: func(s *Client) {}, clientOpts: ClientOptions{ - ElicitationModes: []string{"form"}, ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { return nil, nil }, }, + protocolVersion: protocolVersion20251125, wantCapabilities: &ClientCapabilities{ Roots: RootCapabilities{ListChanged: true}, RootsV2: &RootCapabilities{ListChanged: true}, @@ -238,10 +239,31 @@ func TestClientCapabilities(t *testing.T) { }, }, { - name: "With URL elicitation", + name: "with elicitation (old protocol)", configureClient: func(s *Client) {}, clientOpts: ClientOptions{ - ElicitationModes: []string{"url"}, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return nil, nil + }, + }, + protocolVersion: protocolVersion20250618, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{}, + }, + }, + { + name: "with URL elicitation", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { return nil, nil }, @@ -255,10 +277,17 @@ func TestClientCapabilities(t *testing.T) { }, }, { - name: "With both form and URL elicitation", + name: "with form and URL elicitation", configureClient: func(s *Client) {}, clientOpts: ClientOptions{ - ElicitationModes: []string{"form", "url"}, + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + Form: &FormElicitationCapabilities{}, + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { return nil, nil }, @@ -272,16 +301,190 @@ func TestClientCapabilities(t *testing.T) { }, }, }, + { + name: "no capabilities", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{}, + }, + wantCapabilities: &ClientCapabilities{}, + }, + { + name: "no roots", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{}, + }, + }, + wantCapabilities: &ClientCapabilities{ + Sampling: &SamplingCapabilities{}, + }, + }, + { + name: "roots-no list", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + wantCapabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + { + name: "custom capabilities with sampling", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: true}, + }, + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Sampling: &SamplingCapabilities{}, + }, + }, + { + name: "elicitation override", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, + ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, + }, + { + name: "custom capabilities with experimental", + configureClient: func(s *Client) {}, + clientOpts: ClientOptions{ + Capabilities: &ClientCapabilities{ + Experimental: map[string]any{"custom": "value"}, + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, + wantCapabilities: &ClientCapabilities{ + Experimental: map[string]any{"custom": "value"}, + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { client := NewClient(testImpl, &tc.clientOpts) tc.configureClient(client) - gotCapabilities := client.capabilities() + protocolVersion := tc.protocolVersion + if protocolVersion == "" { + protocolVersion = latestProtocolVersion + } + gotCapabilities := client.capabilities(protocolVersion) if diff := cmp.Diff(tc.wantCapabilities, gotCapabilities); diff != "" { t.Errorf("capabilities() mismatch (-want +got):\n%s", diff) } }) } } + +func TestClientCapabilitiesOverWire(t *testing.T) { + testCases := []struct { + name string + clientOpts *ClientOptions + wantCapabilities *ClientCapabilities + }{ + { + name: "Default capabilities", + clientOpts: nil, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + }, + }, + { + name: "Custom Capabilities with roots listChanged false", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: false}, + RootsV2: &RootCapabilities{ListChanged: false}, + }, + }, + { + name: "Dynamic sampling capability", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{ + RootsV2: &RootCapabilities{ListChanged: true}, + }, + CreateMessageHandler: func(context.Context, *CreateMessageRequest) (*CreateMessageResult, error) { + return nil, nil + }, + }, + wantCapabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Sampling: &SamplingCapabilities{}, + }, + }, + { + name: "Empty capabilities disables defaults", + clientOpts: &ClientOptions{ + Capabilities: &ClientCapabilities{}, + }, + wantCapabilities: &ClientCapabilities{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + // Create client. + impl := &Implementation{Name: "testClient", Version: "v1.0.0"} + client := NewClient(impl, tc.clientOpts) + + // Connect client and server. + cTransport, sTransport := NewInMemoryTransports() + server := NewServer(&Implementation{Name: "testServer", Version: "v1.0.0"}, nil) + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + cs, err := client.Connect(ctx, cTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Check that the server received the expected capabilities. + initParams := ss.InitializeParams() + if initParams == nil { + t.Fatal("InitializeParams is nil") + } + + if diff := cmp.Diff(tc.wantCapabilities, initParams.Capabilities); diff != "" { + t.Errorf("Capabilities mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/mcp/elicitation_test.go b/mcp/elicitation_test.go index 8da033ed..52ae1d71 100644 --- a/mcp/elicitation_test.go +++ b/mcp/elicitation_test.go @@ -105,7 +105,13 @@ func TestElicitationURLMode(t *testing.T) { defer ss.Close() c := NewClient(testImpl, &ClientOptions{ - ElicitationModes: []string{"url"}, + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: tc.handler, }) cs, err := c.Connect(ctx, ct, nil) @@ -143,7 +149,13 @@ func TestElicitationCompleteNotification(t *testing.T) { var elicitationCompleteCh = make(chan *ElicitationCompleteParams, 1) c := NewClient(testImpl, &ClientOptions{ - ElicitationModes: []string{"url"}, + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: func(context.Context, *ElicitRequest) (*ElicitResult, error) { return &ElicitResult{Action: "accept"}, nil }, diff --git a/mcp/error_test.go b/mcp/error_test.go index 694ef104..dad47aa9 100644 --- a/mcp/error_test.go +++ b/mcp/error_test.go @@ -213,7 +213,13 @@ func TestURLElicitationRequired(t *testing.T) { // Create client with elicitation handler and middleware. client := NewClient(testImpl, &ClientOptions{ - ElicitationModes: []string{"url"}, + Capabilities: &ClientCapabilities{ + Roots: RootCapabilities{ListChanged: true}, + RootsV2: &RootCapabilities{ListChanged: true}, + Elicitation: &ElicitationCapabilities{ + URL: &URLElicitationCapabilities{}, + }, + }, ElicitationHandler: func(ctx context.Context, req *ElicitRequest) (*ElicitResult, error) { elicitCalled = true elicitURL = req.Params.URL diff --git a/mcp/protocol.go b/mcp/protocol.go index 563b3799..bfac1453 100644 --- a/mcp/protocol.go +++ b/mcp/protocol.go @@ -188,6 +188,10 @@ type RootCapabilities struct { // this schema, but this is not a closed set: any client can define its own, // additional capabilities. type ClientCapabilities struct { + + // NOTE: any addition to ClientCapabilities must also be reflected in + // [ClientCapabilities.clone]. + // Experimental reports non-standard capabilities that the client supports. Experimental map[string]any `json:"experimental,omitempty"` // Roots describes the client's support for roots. @@ -200,7 +204,7 @@ type ClientCapabilities struct { // changes to the roots list. ListChanged bool `json:"listChanged,omitempty"` } `json:"roots,omitempty"` - // RootsV2 is present if the client supports roots. + // RootsV2 is present if the client supports roots. When capabilities are explicitly configured via [ClientOptions.Capabilities] RootsV2 *RootCapabilities `json:"-"` // Sampling is present if the client supports sampling from an LLM. Sampling *SamplingCapabilities `json:"sampling,omitempty"` @@ -208,6 +212,29 @@ type ClientCapabilities struct { Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"` } +// clone returns a deep copy of the ClientCapabilities. +func (c *ClientCapabilities) clone() *ClientCapabilities { + cp := *c + cp.RootsV2 = shallowClone(c.RootsV2) + cp.Sampling = shallowClone(c.Sampling) + if c.Elicitation != nil { + x := *c.Elicitation + x.Form = shallowClone(c.Elicitation.Form) + x.URL = shallowClone(c.Elicitation.URL) + cp.Elicitation = &x + } + return &cp +} + +// shallowClone returns a shallow clone of *p, or nil if p is nil. +func shallowClone[T any](p *T) *T { + if p == nil { + return nil + } + x := *p + return &x +} + func (c *ClientCapabilities) toV2() *clientCapabilitiesV2 { return &clientCapabilitiesV2{ ClientCapabilities: *c, @@ -1259,6 +1286,10 @@ type ToolCapabilities struct { // ServerCapabilities describes capabilities that a server supports. type ServerCapabilities struct { + + // NOTE: any addition to ServerCapabilities must also be reflected in + // [ServerCapabilities.clone]. + // Experimental reports non-standard capabilities that the server supports. Experimental map[string]any `json:"experimental,omitempty"` // Completions is present if the server supports argument autocompletion @@ -1274,6 +1305,17 @@ type ServerCapabilities struct { Tools *ToolCapabilities `json:"tools,omitempty"` } +// clone returns a deep copy of the ServerCapabilities. +func (c *ServerCapabilities) clone() *ServerCapabilities { + cp := *c + cp.Completions = shallowClone(c.Completions) + cp.Logging = shallowClone(c.Logging) + cp.Prompts = shallowClone(c.Prompts) + cp.Resources = shallowClone(c.Resources) + cp.Tools = shallowClone(c.Tools) + return &cp +} + const ( methodCallTool = "tools/call" notificationCancelled = "notifications/cancelled" diff --git a/mcp/server.go b/mcp/server.go index 776924ad..1f7edf9c 100644 --- a/mcp/server.go +++ b/mcp/server.go @@ -81,14 +81,51 @@ type ServerOptions struct { SubscribeHandler func(context.Context, *SubscribeRequest) error // Function called when a client session unsubscribes from a resource. UnsubscribeHandler func(context.Context, *UnsubscribeRequest) error + + // Capabilities optionally configures the server's default capabilities, + // before any capabilities are inferred from other configuration or server + // features. + // + // If Capabilities is nil, the default server capabilities are {"logging":{}}, + // for historical reasons. Setting Capabilities to a non-nil value overrides + // this default. For example, setting Capabilities to `&ServerCapabilities{}` + // disables the logging capability. + // + // # Interaction with capability inference + // + // "tools", "prompts", and "resources" capabilities are automatically added when + // tools, prompts, or resources are added to the server (for example, via + // [Server.AddPrompt]), with default value `{"listChanged":true}`. Similarly, + // if the [ClientOptions.SubscribeHandler] or + // [ClientOptions.CompletionHandler] are set, the inferred capabilities are + // adjusted accordingly. + // + // Any non-nil field in Capabilities overrides the inferred value. + // For example: + // + // - To advertise the "tools" capability, even if no tools are added, set + // Capabilities.Tools to &ToolCapabilities{ListChanged:true}. + // - To disable tool list notifications, set Capabilities.Tools to + // &ToolCapabilities{}. + // + // Conversely, if Capabilities does not set a field (for example, if the + // Prompts field is nil), the inferred capability will be used. + Capabilities *ServerCapabilities + // If true, advertises the prompts capability during initialization, // even if no prompts have been registered. + // + // Deprecated: Use Capabilities instead. HasPrompts bool // If true, advertises the resources capability during initialization, // even if no resources have been registered. + // + // Deprecated: Use Capabilities instead. HasResources bool // If true, advertises the tools capability during initialization, // even if no tools have been registered. + // + // Deprecated: Use Capabilities instead. HasTools bool // GetSessionID provides the next session ID to use for an incoming request. @@ -465,24 +502,49 @@ func (s *Server) capabilities() *ServerCapabilities { s.mu.Lock() defer s.mu.Unlock() - caps := &ServerCapabilities{ - Logging: &LoggingCapabilities{}, + // Start with user-provided capabilities as defaults, or use SDK defaults. + var caps *ServerCapabilities + if s.opts.Capabilities != nil { + // Deep copy the user-provided capabilities to avoid mutation. + caps = s.opts.Capabilities.clone() + } else { + // SDK defaults: only logging capability. + caps = &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + } } + + // Augment with tools capability if tools exist or legacy HasTools is set. if s.opts.HasTools || s.tools.len() > 0 { - caps.Tools = &ToolCapabilities{ListChanged: true} + if caps.Tools == nil { + caps.Tools = &ToolCapabilities{ListChanged: true} + } } + + // Augment with prompts capability if prompts exist or legacy HasPrompts is set. if s.opts.HasPrompts || s.prompts.len() > 0 { - caps.Prompts = &PromptCapabilities{ListChanged: true} + if caps.Prompts == nil { + caps.Prompts = &PromptCapabilities{ListChanged: true} + } } + + // Augment with resources capability if resources/templates exist or legacy HasResources is set. if s.opts.HasResources || s.resources.len() > 0 || s.resourceTemplates.len() > 0 { - caps.Resources = &ResourceCapabilities{ListChanged: true} + if caps.Resources == nil { + caps.Resources = &ResourceCapabilities{ListChanged: true} + } if s.opts.SubscribeHandler != nil { caps.Resources.Subscribe = true } } + + // Augment with completions capability if handler is set. if s.opts.CompletionHandler != nil { - caps.Completions = &CompletionCapabilities{} + if caps.Completions == nil { + caps.Completions = &CompletionCapabilities{} + } } + return caps } @@ -512,7 +574,7 @@ const notificationDelay = 10 * time.Millisecond func (s *Server) changeAndNotify(notification string, change func() bool) { s.mu.Lock() defer s.mu.Unlock() - if change() { + if change() && s.shouldSendListChangedNotification(notification) { // Reset the outstanding delayed call, if any. if t := s.pendingNotifications[notification]; t == nil { s.pendingNotifications[notification] = time.AfterFunc(notificationDelay, func() { s.notifySessions(notification) }) @@ -532,6 +594,35 @@ func (s *Server) notifySessions(n string) { notifySessions(sessions, n, changeNotificationParams[n], s.opts.Logger) } +// shouldSendListChangedNotification checks if the server's capabilities allow +// sending the given list-changed notification. +func (s *Server) shouldSendListChangedNotification(notification string) bool { + // Get effective capabilities (considering user-provided defaults). + caps := s.opts.Capabilities + + switch notification { + case notificationToolListChanged: + // If user didn't specify capabilities, default behavior sends notifications. + if caps == nil || caps.Tools == nil { + return true + } + return caps.Tools.ListChanged + case notificationPromptListChanged: + if caps == nil || caps.Prompts == nil { + return true + } + return caps.Prompts.ListChanged + case notificationResourceListChanged: + if caps == nil || caps.Resources == nil { + return true + } + return caps.Resources.ListChanged + default: + // Unknown notification, allow by default. + return true + } +} + // Sessions returns an iterator that yields the current set of server sessions. // // There is no guarantee that the iterator observes sessions that are added or diff --git a/mcp/server_test.go b/mcp/server_test.go index b578cbf2..090ccdc5 100644 --- a/mcp/server_test.go +++ b/mcp/server_test.go @@ -245,14 +245,14 @@ func TestServerCapabilities(t *testing.T) { wantCapabilities *ServerCapabilities }{ { - name: "No capabilities", + name: "no capabilities", configureServer: func(s *Server) {}, wantCapabilities: &ServerCapabilities{ Logging: &LoggingCapabilities{}, }, }, { - name: "With prompts", + name: "with prompts", configureServer: func(s *Server) { s.AddPrompt(&Prompt{Name: "p"}, nil) }, @@ -262,7 +262,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With resources", + name: "with resources", configureServer: func(s *Server) { s.AddResource(&Resource{URI: "file:///r"}, nil) }, @@ -272,7 +272,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With resource templates", + name: "with resource templates", configureServer: func(s *Server) { s.AddResourceTemplate(&ResourceTemplate{URITemplate: "file:///rt"}, nil) }, @@ -282,7 +282,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With resource subscriptions", + name: "with resource subscriptions", configureServer: func(s *Server) { s.AddResourceTemplate(&ResourceTemplate{URITemplate: "file:///rt"}, nil) }, @@ -300,7 +300,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With tools", + name: "with tools", configureServer: func(s *Server) { s.AddTool(tool, nil) }, @@ -310,7 +310,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With completions", + name: "with completions", configureServer: func(s *Server) {}, serverOpts: ServerOptions{ CompletionHandler: func(context.Context, *CompleteRequest) (*CompleteResult, error) { @@ -323,7 +323,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With all capabilities", + name: "all capabilities", configureServer: func(s *Server) { s.AddPrompt(&Prompt{Name: "p"}, nil) s.AddResource(&Resource{URI: "file:///r"}, nil) @@ -350,7 +350,7 @@ func TestServerCapabilities(t *testing.T) { }, }, { - name: "With initial capabilities", + name: "has features", configureServer: func(s *Server) {}, serverOpts: ServerOptions{ HasPrompts: true, @@ -364,6 +364,83 @@ func TestServerCapabilities(t *testing.T) { Tools: &ToolCapabilities{ListChanged: true}, }, }, + { + name: "empty capabilities", + configureServer: func(s *Server) {}, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{}, + }, + wantCapabilities: &ServerCapabilities{}, + }, + { + name: "no logging", + configureServer: func(s *Server) {}, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + { + name: "no list", + configureServer: func(s *Server) {}, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + Prompts: &PromptCapabilities{ListChanged: false}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + Prompts: &PromptCapabilities{ListChanged: false}, + }, + }, + { + name: "adding tools-list", + configureServer: func(s *Server) { + s.AddTool(tool, nil) + }, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + { + name: "adding tools-no list", + configureServer: func(s *Server) { + s.AddTool(tool, nil) + }, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + { + name: "experimental preserved", + configureServer: func(s *Server) {}, + serverOpts: ServerOptions{ + Capabilities: &ServerCapabilities{ + Experimental: map[string]any{"custom": "value"}, + Logging: &LoggingCapabilities{}, + }, + }, + wantCapabilities: &ServerCapabilities{ + Experimental: map[string]any{"custom": "value"}, + Logging: &LoggingCapabilities{}, + }, + }, } for _, tc := range testCases { @@ -776,3 +853,88 @@ func TestToolForSchemas(t *testing.T) { }, "") } + +// TestServerCapabilitiesOverWire verifies that server capabilities are +// correctly sent over the wire during initialization. +func TestServerCapabilitiesOverWire(t *testing.T) { + tool := &Tool{Name: "test-tool", InputSchema: &jsonschema.Schema{Type: "object"}} + + testCases := []struct { + name string + serverOpts *ServerOptions + configureServer func(s *Server) + wantCapabilities *ServerCapabilities + }{ + { + name: "Default capabilities", + serverOpts: nil, + configureServer: func(s *Server) {}, + wantCapabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + }, + }, + { + name: "Custom Capabilities with tools", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + configureServer: func(s *Server) {}, + wantCapabilities: &ServerCapabilities{ + Tools: &ToolCapabilities{ListChanged: false}, + }, + }, + { + name: "Dynamic tool capability", + serverOpts: &ServerOptions{ + Capabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + }, + }, + configureServer: func(s *Server) { + s.AddTool(tool, nil) + }, + wantCapabilities: &ServerCapabilities{ + Logging: &LoggingCapabilities{}, + Tools: &ToolCapabilities{ListChanged: true}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + + // Create server. + impl := &Implementation{Name: "testServer", Version: "v1.0.0"} + server := NewServer(impl, tc.serverOpts) + tc.configureServer(server) + + // Connect client and server. + cTransport, sTransport := NewInMemoryTransports() + ss, err := server.Connect(ctx, sTransport, nil) + if err != nil { + t.Fatal(err) + } + defer ss.Close() + + client := NewClient(&Implementation{Name: "testClient", Version: "v1.0.0"}, nil) + cs, err := client.Connect(ctx, cTransport, nil) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + + // Check that the client received the expected capabilities. + initResult := cs.InitializeResult() + if initResult == nil { + t.Fatal("InitializeResult is nil") + } + + if diff := cmp.Diff(tc.wantCapabilities, initResult.Capabilities); diff != "" { + t.Errorf("Capabilities mismatch (-want +got):\n%s", diff) + } + }) + } +}