mcp: add Extensions field to capabilities per SEP-2133 (#794)
This implements [SEP-2133](https://modelcontextprotocol.io/community/seps/2133-extensions) and resolves #777. ## Summary of changes - Add `Extensions` field to `ServerCapabilities` and `ClientCapabilities` structs per [SEP-2133](https://modelcontextprotocol.io/community/seps/2133-extensions) - Add `AddExtension` helper methods on both types that normalize nil settings to empty maps. - [The spec requires an object, not null](https://modelcontextprotocol.io/community/seps/2133-extensions#negotiation:~:text=an%20empty%20object%20indicates%20no%20settings.) - Include shallow-copy support via `maps.Clone` in the existing `clone()` methods - also include shallow-copy support for `Experimental` field. - Add comprehensive tests: unit tests for nil-safety, capability preservation, and over-the-wire round-trip ## What's tested - [x] `go test ./mcp/...` passes - [x] Server conformance tests pass (30 scenarios, 40 assertions, 0 failures) - [x] Client conformance tests match existing baseline (auth scenarios are known failures)
This commit is contained in:
parent
1580ac9494
commit
3dd262606e
3 changed files with 124 additions and 2 deletions
|
|
@ -402,6 +402,26 @@ func TestClientCapabilities(t *testing.T) {
|
|||
RootsV2: &RootCapabilities{ListChanged: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extensions preserved",
|
||||
configureClient: func(s *Client) {},
|
||||
clientOpts: func() ClientOptions {
|
||||
caps := &ClientCapabilities{
|
||||
RootsV2: &RootCapabilities{ListChanged: true},
|
||||
}
|
||||
caps.AddExtension("io.example/ext1", map[string]any{"key": "value"})
|
||||
caps.AddExtension("io.example/ext2", nil)
|
||||
return ClientOptions{Capabilities: caps}
|
||||
}(),
|
||||
wantCapabilities: &ClientCapabilities{
|
||||
Extensions: map[string]any{
|
||||
"io.example/ext1": map[string]any{"key": "value"},
|
||||
"io.example/ext2": map[string]any{},
|
||||
},
|
||||
Roots: RootCapabilities{ListChanged: true},
|
||||
RootsV2: &RootCapabilities{ListChanged: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
|
@ -469,6 +489,23 @@ func TestClientCapabilitiesOverWire(t *testing.T) {
|
|||
},
|
||||
wantCapabilities: &ClientCapabilities{},
|
||||
},
|
||||
{
|
||||
name: "Extensions over wire",
|
||||
clientOpts: func() *ClientOptions {
|
||||
caps := &ClientCapabilities{
|
||||
RootsV2: &RootCapabilities{ListChanged: true},
|
||||
}
|
||||
caps.AddExtension("io.example/ext", map[string]any{"key": "value"})
|
||||
return &ClientOptions{Capabilities: caps}
|
||||
}(),
|
||||
wantCapabilities: &ClientCapabilities{
|
||||
Extensions: map[string]any{
|
||||
"io.example/ext": map[string]any{"key": "value"},
|
||||
},
|
||||
Roots: RootCapabilities{ListChanged: true},
|
||||
RootsV2: &RootCapabilities{ListChanged: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ package mcp
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
)
|
||||
|
||||
// Optional annotations for the client. The client can use annotations to inform
|
||||
|
|
@ -192,7 +193,14 @@ type ClientCapabilities struct {
|
|||
// [ClientCapabilities.clone].
|
||||
|
||||
// Experimental reports non-standard capabilities that the client supports.
|
||||
// The caller should not modify the map after assigning it.
|
||||
Experimental map[string]any `json:"experimental,omitempty"`
|
||||
// Extensions reports extensions that the client supports.
|
||||
// Keys are extension identifiers in "{vendor-prefix}/{extension-name}" format.
|
||||
// Values are per-extension settings objects; use [ClientCapabilities.AddExtension]
|
||||
// to ensure nil settings are normalized to empty objects.
|
||||
// The caller should not modify the map or its values after assigning it.
|
||||
Extensions map[string]any `json:"extensions,omitempty"`
|
||||
// Roots describes the client's support for roots.
|
||||
//
|
||||
// Deprecated: use RootsV2. As described in #607, Roots should have been a
|
||||
|
|
@ -211,9 +219,26 @@ type ClientCapabilities struct {
|
|||
Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"`
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the ClientCapabilities.
|
||||
// AddExtension adds an extension with the given name and settings.
|
||||
// If settings is nil, an empty map is used to ensure valid JSON serialization
|
||||
// (the spec requires an object, not null).
|
||||
// The settings map should not be modified after the call.
|
||||
func (c *ClientCapabilities) AddExtension(name string, settings map[string]any) {
|
||||
if c.Extensions == nil {
|
||||
c.Extensions = make(map[string]any)
|
||||
}
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
c.Extensions[name] = settings
|
||||
}
|
||||
|
||||
// clone returns a copy of the ClientCapabilities.
|
||||
// Values in the Extensions and Experimental maps are shallow-copied.
|
||||
func (c *ClientCapabilities) clone() *ClientCapabilities {
|
||||
cp := *c
|
||||
cp.Experimental = maps.Clone(c.Experimental)
|
||||
cp.Extensions = maps.Clone(c.Extensions)
|
||||
cp.RootsV2 = shallowClone(c.RootsV2)
|
||||
if c.Sampling != nil {
|
||||
x := *c.Sampling
|
||||
|
|
@ -1516,7 +1541,14 @@ type ServerCapabilities struct {
|
|||
// [ServerCapabilities.clone].
|
||||
|
||||
// Experimental reports non-standard capabilities that the server supports.
|
||||
// The caller should not modify the map after assigning it.
|
||||
Experimental map[string]any `json:"experimental,omitempty"`
|
||||
// Extensions reports extensions that the server supports.
|
||||
// Keys are extension identifiers in "{vendor-prefix}/{extension-name}" format.
|
||||
// Values are per-extension settings objects; use [ServerCapabilities.AddExtension]
|
||||
// to ensure nil settings are normalized to empty objects.
|
||||
// The caller should not modify the map or its values after assigning it.
|
||||
Extensions map[string]any `json:"extensions,omitempty"`
|
||||
// Completions is present if the server supports argument autocompletion
|
||||
// suggestions.
|
||||
Completions *CompletionCapabilities `json:"completions,omitempty"`
|
||||
|
|
@ -1530,9 +1562,26 @@ type ServerCapabilities struct {
|
|||
Tools *ToolCapabilities `json:"tools,omitempty"`
|
||||
}
|
||||
|
||||
// clone returns a deep copy of the ServerCapabilities.
|
||||
// AddExtension adds an extension with the given name and settings.
|
||||
// If settings is nil, an empty map is used to ensure valid JSON serialization
|
||||
// (the spec requires an object, not null).
|
||||
// The settings map should not be modified after the call.
|
||||
func (c *ServerCapabilities) AddExtension(name string, settings map[string]any) {
|
||||
if c.Extensions == nil {
|
||||
c.Extensions = make(map[string]any)
|
||||
}
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
}
|
||||
c.Extensions[name] = settings
|
||||
}
|
||||
|
||||
// clone returns a copy of the ServerCapabilities.
|
||||
// Values in the Extensions and Experimental maps are shallow-copied.
|
||||
func (c *ServerCapabilities) clone() *ServerCapabilities {
|
||||
cp := *c
|
||||
cp.Experimental = maps.Clone(c.Experimental)
|
||||
cp.Extensions = maps.Clone(c.Extensions)
|
||||
cp.Completions = shallowClone(c.Completions)
|
||||
cp.Logging = shallowClone(c.Logging)
|
||||
cp.Prompts = shallowClone(c.Prompts)
|
||||
|
|
|
|||
|
|
@ -441,6 +441,25 @@ func TestServerCapabilities(t *testing.T) {
|
|||
Logging: &LoggingCapabilities{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extensions preserved",
|
||||
configureServer: func(s *Server) {},
|
||||
serverOpts: func() ServerOptions {
|
||||
caps := &ServerCapabilities{
|
||||
Logging: &LoggingCapabilities{},
|
||||
}
|
||||
caps.AddExtension("io.example/ext1", map[string]any{"key": "value"})
|
||||
caps.AddExtension("io.example/ext2", nil)
|
||||
return ServerOptions{Capabilities: caps}
|
||||
}(),
|
||||
wantCapabilities: &ServerCapabilities{
|
||||
Extensions: map[string]any{
|
||||
"io.example/ext1": map[string]any{"key": "value"},
|
||||
"io.example/ext2": map[string]any{},
|
||||
},
|
||||
Logging: &LoggingCapabilities{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
|
@ -927,6 +946,23 @@ func TestServerCapabilitiesOverWire(t *testing.T) {
|
|||
Tools: &ToolCapabilities{ListChanged: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Extensions over wire",
|
||||
serverOpts: func() *ServerOptions {
|
||||
caps := &ServerCapabilities{
|
||||
Logging: &LoggingCapabilities{},
|
||||
}
|
||||
caps.AddExtension("io.example/ext", map[string]any{"key": "value"})
|
||||
return &ServerOptions{Capabilities: caps}
|
||||
}(),
|
||||
configureServer: func(s *Server) {},
|
||||
wantCapabilities: &ServerCapabilities{
|
||||
Extensions: map[string]any{
|
||||
"io.example/ext": map[string]any{"key": "value"},
|
||||
},
|
||||
Logging: &LoggingCapabilities{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
|
|
|
|||
Loading…
Reference in a new issue