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},
|
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 {
|
for _, tc := range testCases {
|
||||||
|
|
@ -469,6 +489,23 @@ func TestClientCapabilitiesOverWire(t *testing.T) {
|
||||||
},
|
},
|
||||||
wantCapabilities: &ClientCapabilities{},
|
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 {
|
for _, tc := range testCases {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Optional annotations for the client. The client can use annotations to inform
|
// Optional annotations for the client. The client can use annotations to inform
|
||||||
|
|
@ -192,7 +193,14 @@ type ClientCapabilities struct {
|
||||||
// [ClientCapabilities.clone].
|
// [ClientCapabilities.clone].
|
||||||
|
|
||||||
// Experimental reports non-standard capabilities that the client supports.
|
// 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"`
|
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.
|
// Roots describes the client's support for roots.
|
||||||
//
|
//
|
||||||
// Deprecated: use RootsV2. As described in #607, Roots should have been a
|
// Deprecated: use RootsV2. As described in #607, Roots should have been a
|
||||||
|
|
@ -211,9 +219,26 @@ type ClientCapabilities struct {
|
||||||
Elicitation *ElicitationCapabilities `json:"elicitation,omitempty"`
|
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 {
|
func (c *ClientCapabilities) clone() *ClientCapabilities {
|
||||||
cp := *c
|
cp := *c
|
||||||
|
cp.Experimental = maps.Clone(c.Experimental)
|
||||||
|
cp.Extensions = maps.Clone(c.Extensions)
|
||||||
cp.RootsV2 = shallowClone(c.RootsV2)
|
cp.RootsV2 = shallowClone(c.RootsV2)
|
||||||
if c.Sampling != nil {
|
if c.Sampling != nil {
|
||||||
x := *c.Sampling
|
x := *c.Sampling
|
||||||
|
|
@ -1516,7 +1541,14 @@ type ServerCapabilities struct {
|
||||||
// [ServerCapabilities.clone].
|
// [ServerCapabilities.clone].
|
||||||
|
|
||||||
// Experimental reports non-standard capabilities that the server supports.
|
// 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"`
|
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
|
// Completions is present if the server supports argument autocompletion
|
||||||
// suggestions.
|
// suggestions.
|
||||||
Completions *CompletionCapabilities `json:"completions,omitempty"`
|
Completions *CompletionCapabilities `json:"completions,omitempty"`
|
||||||
|
|
@ -1530,9 +1562,26 @@ type ServerCapabilities struct {
|
||||||
Tools *ToolCapabilities `json:"tools,omitempty"`
|
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 {
|
func (c *ServerCapabilities) clone() *ServerCapabilities {
|
||||||
cp := *c
|
cp := *c
|
||||||
|
cp.Experimental = maps.Clone(c.Experimental)
|
||||||
|
cp.Extensions = maps.Clone(c.Extensions)
|
||||||
cp.Completions = shallowClone(c.Completions)
|
cp.Completions = shallowClone(c.Completions)
|
||||||
cp.Logging = shallowClone(c.Logging)
|
cp.Logging = shallowClone(c.Logging)
|
||||||
cp.Prompts = shallowClone(c.Prompts)
|
cp.Prompts = shallowClone(c.Prompts)
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,25 @@ func TestServerCapabilities(t *testing.T) {
|
||||||
Logging: &LoggingCapabilities{},
|
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 {
|
for _, tc := range testCases {
|
||||||
|
|
@ -927,6 +946,23 @@ func TestServerCapabilitiesOverWire(t *testing.T) {
|
||||||
Tools: &ToolCapabilities{ListChanged: true},
|
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 {
|
for _, tc := range testCases {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue