From a36e01a9483d72b42709379b3c5df30f07870915 Mon Sep 17 00:00:00 2001 From: Sambhav Kothari Date: Fri, 29 May 2026 09:10:33 +0100 Subject: [PATCH] fix: merge Extensions in unionCapabilities 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, and bump go-sdk to v1.4.1 which introduced the Extensions field on ServerCapabilities. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/go.yml | 4 +-- go/sdk/go.mod | 11 +++--- go/sdk/go.sum | 26 ++++++++------ go/sdk/variants/server.go | 15 ++++++-- go/sdk/variants/server_test.go | 65 ++++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 18 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 87636b9..677768c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -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 diff --git a/go/sdk/go.mod b/go/sdk/go.mod index e4a7abe..6a7718f 100644 --- a/go/sdk/go.mod +++ b/go/sdk/go.mod @@ -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 ) diff --git a/go/sdk/go.sum b/go/sdk/go.sum index 2c0fd3d..b51fb72 100644 --- a/go/sdk/go.sum +++ b/go/sdk/go.sum @@ -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= diff --git a/go/sdk/variants/server.go b/go/sdk/variants/server.go index 7405a97..66a1f46 100644 --- a/go/sdk/variants/server.go +++ b/go/sdk/variants/server.go @@ -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 diff --git a/go/sdk/variants/server_test.go b/go/sdk/variants/server_test.go index 3110bc6..5f23a08 100644 --- a/go/sdk/variants/server_test.go +++ b/go/sdk/variants/server_test.go @@ -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 // ---------------------------------------------------------------------------