fix: merge Extensions in unionCapabilities (#9)
Some checks failed
Go CI / Linting (push) Has been cancelled
Go CI / Unit Tests (push) Has been cancelled
Go CI / Unit Tests-1 (push) Has been cancelled
Go CI / Race Detection (push) Has been cancelled

## Summary

`unionCapabilities` merges `Experimental` map entries from inner variant
servers but skips the `Extensions` field entirely. This means any
extension registered via `ServerCapabilities.AddExtension()` on an inner
server (e.g. `io.modelcontextprotocol/skills`) is silently dropped from
the front proxy's `InitializeResult`.

- Add the same first-wins merge logic for `Extensions` that already
exists for `Experimental`
- Bump `go-sdk` to v1.4.1 which introduced the `Extensions` field on
`ServerCapabilities`
- Add unit test for `unionCapabilities` extensions merging
- Add integration test verifying extensions survive the variant path
end-to-end

## Test plan

- [x] `TestUnionCapabilities_MergesExtensions` — verifies first-wins
merge semantics for extensions
- [x] `TestIntegration_ExtensionsAdvertised` — verifies extensions
registered on inner servers appear in the client's `InitializeResult`
- [x] All existing tests pass (27/27)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sambhav Kothari 2026-05-29 09:17:04 +01:00 • committed by GitHub
commit cfc05d6f5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 103 additions and 18 deletions

View file

@ -24,7 +24,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
with:
go-version: "^1.25"
go-version: "^1.24"
- name: Check formatting
run: |
cd go/sdk
@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.23", "1.24", "1.25"]
go: ["1.24", "1.25"]
steps:
- name: Check out code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1

View file

@ -1,19 +1,22 @@
module github.com/modelcontextprotocol/experimental-ext-variants/go/sdk
go 1.23.0
go 1.24.0
toolchain go1.24.3
require (
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/stretchr/testify v1.11.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View file

@ -1,23 +1,29 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -378,8 +378,8 @@ func (s *Server) enrichInitResult(ctx context.Context, result mcp.Result, req mc
// one inner server supports it.
// - Completions, Logging: marker capabilities (empty structs). Advertised
// if any variant advertises them; the first non-nil value is kept.
// - Experimental: keys are merged into a single map. The first variant to
// register a given key wins; later duplicates are ignored.
// - Experimental, Extensions: keys are merged into a single map. The first
// variant to register a given key wins; later duplicates are ignored.
func unionCapabilities(allCaps []*mcp.ServerCapabilities) *mcp.ServerCapabilities {
union := &mcp.ServerCapabilities{}
@ -427,6 +427,17 @@ func unionCapabilities(allCaps []*mcp.ServerCapabilities) *mcp.ServerCapabilitie
}
}
}
if caps.Extensions != nil {
if union.Extensions == nil {
union.Extensions = make(map[string]any)
}
for k, v := range caps.Extensions {
if _, exists := union.Extensions[k]; !exists {
union.Extensions[k] = v
}
}
}
}
return union

View file

@ -387,6 +387,71 @@ func TestIntegration_HTTP_Stateless(t *testing.T) {
assert.NotEmpty(t, result.Content)
}
// TestUnionCapabilities_MergesExtensions verifies that unionCapabilities
// merges Extensions from multiple inner servers using first-wins semantics.
func TestUnionCapabilities_MergesExtensions(t *testing.T) {
caps := []*mcp.ServerCapabilities{
{
Extensions: map[string]any{
"io.modelcontextprotocol/skills": map[string]any{},
},
},
{
Extensions: map[string]any{
"io.modelcontextprotocol/skills": map[string]any{"overridden": true},
"io.modelcontextprotocol/other": map[string]any{"key": "val"},
},
},
{},
}
union := unionCapabilities(caps)
require.NotNil(t, union.Extensions)
assert.Contains(t, union.Extensions, "io.modelcontextprotocol/skills")
assert.Contains(t, union.Extensions, "io.modelcontextprotocol/other")
// First-wins: the first variant's value should be kept.
skillsExt, ok := union.Extensions["io.modelcontextprotocol/skills"].(map[string]any)
require.True(t, ok)
assert.Empty(t, skillsExt, "first variant's empty map should win over second variant's")
}
// TestIntegration_ExtensionsAdvertised verifies that extensions registered on
// inner variant servers are advertised to clients in the InitializeResult.
func TestIntegration_ExtensionsAdvertised(t *testing.T) {
codingServer := mcp.NewServer(
&mcp.Implementation{Name: "coding", Version: "v1"},
&mcp.ServerOptions{
Capabilities: func() *mcp.ServerCapabilities {
caps := &mcp.ServerCapabilities{}
caps.AddExtension("io.modelcontextprotocol/skills", nil)
return caps
}(),
},
)
mcp.AddTool(codingServer, &mcp.Tool{Name: "analyze_code", Description: "Static analysis"}, analyzeCode)
compactServer := mcp.NewServer(
&mcp.Implementation{Name: "compact", Version: "v1"},
nil,
)
mcp.AddTool(compactServer, &mcp.Tool{Name: "summarize", Description: "Summarize text"}, summarize)
vs := NewServer(&mcp.Implementation{Name: "test", Version: "1.0.0"}).
WithVariant(ServerVariant{ID: "coding", Description: "Coding", Status: Stable}, codingServer, 0).
WithVariant(ServerVariant{ID: "compact", Description: "Compact", Status: Stable}, compactServer, 1)
session := connectTestClient(t, vs, nil)
initResult := session.InitializeResult()
require.NotNil(t, initResult)
require.NotNil(t, initResult.Capabilities)
require.NotNil(t, initResult.Capabilities.Extensions,
"extensions from inner servers should be advertised in InitializeResult")
assert.Contains(t, initResult.Capabilities.Extensions, "io.modelcontextprotocol/skills")
}
// ---------------------------------------------------------------------------
// Test tool handlers
// ---------------------------------------------------------------------------