mcp: add Capabilities fields to ServerOptions and ClientOptions (#713)
This allows users to configure default capabilities, disable defaults, or suppress listChanged notifications by setting ListChanged: false. Key changes: - Add ServerOptions.Capabilities and ClientOptions.Capabilities fields. - Merge user-provided capabilities with dynamically-added ones (tools, prompts, resources, sampling, elicitation). - Check ListChanged before sending list-changed notifications. - Deprecate HasPrompts, HasResources, HasTools on ServerOptions. - Remove unreleased ElicitationModes field from ClientOptions. - Add clone methods to capability structs using shallowClone helper. - Add synctest-based tests for notification behavior (go1.25). - Add TestClientCapabilitiesOverWire and TestServerCapabilitiesOverWire - Document the new Capabilities API in docs/ Updates #706
This commit is contained in:
parent
bae0929820
commit
3430e2242e
14 changed files with 1071 additions and 57 deletions
|
|
@ -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,
|
||||
})
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
180
mcp/capabilities_go125_test.go
Normal file
180
mcp/capabilities_go125_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
140
mcp/client.go
140
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,
|
||||
}
|
||||
if c.opts.CreateMessageHandler != nil {
|
||||
caps.Sampling = &SamplingCapabilities{}
|
||||
}
|
||||
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{}
|
||||
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,
|
||||
},
|
||||
}
|
||||
if slices.Contains(modes, "url") {
|
||||
caps.Elicitation.URL = &URLElicitationCapabilities{}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if caps.Sampling == nil {
|
||||
caps.Sampling = &SamplingCapabilities{}
|
||||
}
|
||||
}
|
||||
|
||||
// Augment with elicitation capability if handler is set.
|
||||
if c.opts.ElicitationHandler != nil {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
105
mcp/server.go
105
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue