Skip to content

Commit a67e626

Browse files
feat: add MCP variants extension support (SEP-2053)
Add --use-variants flag that exposes each toolset as a separate MCP server variant instead of registering all tools on a single server. This allows clients to dynamically select which toolset to use per-request via _meta, replacing the need for upfront toolset configuration. Each toolset (repos, issues, pull_requests, actions, etc.) becomes its own variant with appropriate priority (default toolsets rank higher). Clients that don't support variants get the first-ranked default variant automatically. Uses github.com/modelcontextprotocol/experimental-ext-variants/go/sdk. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bf64678 commit a67e626

5 files changed

Lines changed: 318 additions & 3 deletions

File tree

cmd/github-mcp-server/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ var (
9494
LockdownMode: viper.GetBool("lockdown-mode"),
9595
InsidersMode: viper.GetBool("insiders"),
9696
ExcludeTools: excludeTools,
97+
UseVariants: viper.GetBool("use_variants"),
9798
RepoAccessCacheTTL: &ttl,
9899
}
99100
return ghmcp.RunStdioServer(stdioServerConfig)
@@ -146,6 +147,7 @@ func init() {
146147
rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size")
147148
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
148149
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
150+
rootCmd.PersistentFlags().Bool("use-variants", false, "Enable MCP variants extension (SEP-2053) - each toolset becomes a separate variant")
149151
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
150152

151153
// HTTP-specific flags
@@ -168,6 +170,7 @@ func init() {
168170
_ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
169171
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
170172
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
173+
_ = viper.BindPFlag("use_variants", rootCmd.PersistentFlags().Lookup("use-variants"))
171174
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
172175
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
173176
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/josephburnett/jd/v2 v2.4.0
1111
github.com/lithammer/fuzzysearch v1.1.8
1212
github.com/microcosm-cc/bluemonday v1.0.27
13+
github.com/modelcontextprotocol/experimental-ext-variants/go/sdk v0.0.0-20260301071243-e1c17e047f85
1314
github.com/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798
1415
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021
1516
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
4444
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
4545
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
4646
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
47+
github.com/modelcontextprotocol/experimental-ext-variants/go/sdk v0.0.0-20260301071243-e1c17e047f85 h1:w6qg4OjcgF2KNup6zDH+Lw/S+JwDLpVKXugTB/YtRHY=
48+
github.com/modelcontextprotocol/experimental-ext-variants/go/sdk v0.0.0-20260301071243-e1c17e047f85/go.mod h1:lQBmGKt6TDbjhlO5r4EXlaoo2D7sIQnqbTVtBLHu/cc=
4749
github.com/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798 h1:ogb5ErmcnxZgfaTeVZnKEMrwdHDpJ3yln5EhCIPcTlY=
4850
github.com/modelcontextprotocol/go-sdk v1.3.1-0.20260220105450-b17143f71798/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
4951
github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g=

internal/ghmcp/server.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ type StdioServerConfig struct {
220220
// explicitly listed in EnabledTools.
221221
ExcludeTools []string
222222

223+
// UseVariants enables the MCP variants extension (SEP-2053), exposing each
224+
// toolset as a separate variant instead of registering all tools on one server.
225+
UseVariants bool
226+
223227
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
224228
RepoAccessCacheTTL *time.Duration
225229
}
@@ -246,7 +250,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
246250
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
247251
}
248252
logger := slog.New(slogHandler)
249-
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
253+
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode, "useVariants", cfg.UseVariants)
250254

251255
// Fetch token scopes for scope-based tool filtering (PAT tokens only)
252256
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
@@ -264,7 +268,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
264268
logger.Debug("skipping scope filtering for non-PAT token")
265269
}
266270

267-
ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{
271+
mcpCfg := github.MCPServerConfig{
268272
Version: cfg.Version,
269273
Host: cfg.Host,
270274
Token: cfg.Token,
@@ -281,7 +285,14 @@ func RunStdioServer(cfg StdioServerConfig) error {
281285
Logger: logger,
282286
RepoAccessTTL: cfg.RepoAccessCacheTTL,
283287
TokenScopes: tokenScopes,
284-
})
288+
}
289+
290+
// In variants mode, each toolset becomes a separate variant
291+
if cfg.UseVariants {
292+
return runVariantsStdioServer(ctx, mcpCfg, cfg, logger, t, dumpTranslations)
293+
}
294+
295+
ghServer, err := NewStdioMCPServer(ctx, mcpCfg)
285296
if err != nil {
286297
return fmt.Errorf("failed to create MCP server: %w", err)
287298
}

internal/ghmcp/variants.go

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package ghmcp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"log/slog"
8+
"os"
9+
"slices"
10+
"strings"
11+
12+
gherrors "github.com/github/github-mcp-server/pkg/errors"
13+
"github.com/github/github-mcp-server/pkg/github"
14+
"github.com/github/github-mcp-server/pkg/inventory"
15+
mcplog "github.com/github/github-mcp-server/pkg/log"
16+
"github.com/github/github-mcp-server/pkg/octicons"
17+
"github.com/github/github-mcp-server/pkg/translations"
18+
"github.com/github/github-mcp-server/pkg/utils"
19+
"github.com/modelcontextprotocol/experimental-ext-variants/go/sdk/variants"
20+
"github.com/modelcontextprotocol/go-sdk/mcp"
21+
)
22+
23+
// NewVariantsStdioMCPServer creates a variants.Server where each toolset is exposed
24+
// as a separate variant. This replaces toolset configuration with the MCP variants
25+
// extension (SEP-2053), allowing clients to dynamically select which toolset to use
26+
// per-request via _meta.
27+
func NewVariantsStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*variants.Server, error) {
28+
apiHost, err := utils.NewAPIHost(cfg.Host)
29+
if err != nil {
30+
return nil, fmt.Errorf("failed to parse API host: %w", err)
31+
}
32+
33+
clients, err := createGitHubClients(cfg, apiHost)
34+
if err != nil {
35+
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
36+
}
37+
38+
featureChecker := createFeatureChecker(cfg.EnabledFeatures)
39+
40+
deps := github.NewBaseDeps(
41+
clients.rest,
42+
clients.gql,
43+
clients.raw,
44+
clients.repoAccess,
45+
cfg.Translator,
46+
github.FeatureFlags{
47+
LockdownMode: cfg.LockdownMode,
48+
InsidersMode: cfg.InsidersMode,
49+
},
50+
cfg.ContentWindowSize,
51+
featureChecker,
52+
)
53+
54+
// Build a full inventory with ALL toolsets enabled (no filtering).
55+
// Each toolset will become its own variant.
56+
inv, err := github.NewInventory(cfg.Translator).
57+
WithDeprecatedAliases(github.DeprecatedToolAliases).
58+
WithReadOnly(cfg.ReadOnly).
59+
WithToolsets([]string{"all"}).
60+
WithServerInstructions().
61+
WithFeatureChecker(featureChecker).
62+
WithInsidersMode(cfg.InsidersMode).
63+
Build()
64+
if err != nil {
65+
return nil, fmt.Errorf("failed to build inventory: %w", err)
66+
}
67+
68+
// Group tools, resources, and prompts by toolset
69+
toolsByToolset := groupToolsByToolset(inv.AvailableTools(ctx))
70+
resourcesByToolset := groupResourcesByToolset(inv.AvailableResourceTemplates(ctx))
71+
promptsByToolset := groupPromptsByToolset(inv.AvailablePrompts(ctx))
72+
73+
// Collect all unique toolset IDs from tools, resources, and prompts
74+
allToolsetIDs := collectToolsetIDs(toolsByToolset, resourcesByToolset, promptsByToolset)
75+
76+
impl := &mcp.Implementation{
77+
Name: "github-mcp-server",
78+
Title: "GitHub MCP Server",
79+
Version: cfg.Version,
80+
Icons: octicons.Icons("mark-github"),
81+
}
82+
83+
vs := variants.NewServer(impl)
84+
85+
// Default toolsets get lower priority values (higher priority)
86+
defaultPriority := 0
87+
nonDefaultPriority := 100
88+
89+
for _, toolsetID := range allToolsetIDs {
90+
tools := toolsByToolset[toolsetID]
91+
resources := resourcesByToolset[toolsetID]
92+
prompts := promptsByToolset[toolsetID]
93+
94+
if len(tools) == 0 && len(resources) == 0 && len(prompts) == 0 {
95+
continue
96+
}
97+
98+
// Determine priority and status from the first tool's toolset metadata
99+
var meta inventory.ToolsetMetadata
100+
switch {
101+
case len(tools) > 0:
102+
meta = tools[0].Toolset
103+
case len(resources) > 0:
104+
meta = resources[0].Toolset
105+
case len(prompts) > 0:
106+
meta = prompts[0].Toolset
107+
}
108+
109+
priority := nonDefaultPriority
110+
if meta.Default {
111+
priority = defaultPriority
112+
defaultPriority++
113+
} else {
114+
nonDefaultPriority++
115+
}
116+
117+
// Create per-toolset server
118+
serverOpts := &mcp.ServerOptions{
119+
Logger: cfg.Logger,
120+
}
121+
toolsetServer := mcp.NewServer(impl, serverOpts)
122+
123+
// Add middleware for deps injection and error context
124+
toolsetServer.AddReceivingMiddleware(github.InjectDepsMiddleware(deps))
125+
toolsetServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
126+
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
127+
ctx = gherrors.ContextWithGitHubErrors(ctx)
128+
return next(ctx, method, req)
129+
}
130+
})
131+
132+
// Register tools
133+
for i := range tools {
134+
tools[i].RegisterFunc(toolsetServer, deps)
135+
}
136+
137+
// Register resources
138+
for i := range resources {
139+
templateCopy := resources[i].Template
140+
if len(templateCopy.Icons) == 0 {
141+
templateCopy.Icons = resources[i].Toolset.Icons()
142+
}
143+
toolsetServer.AddResourceTemplate(&templateCopy, resources[i].Handler(deps))
144+
}
145+
146+
// Register prompts
147+
for i := range prompts {
148+
promptCopy := prompts[i].Prompt
149+
if len(promptCopy.Icons) == 0 {
150+
promptCopy.Icons = prompts[i].Toolset.Icons()
151+
}
152+
toolsetServer.AddPrompt(&promptCopy, prompts[i].Handler)
153+
}
154+
155+
// Build hints from toolset metadata
156+
hints := map[string]string{
157+
"toolset": string(meta.ID),
158+
}
159+
if meta.Default {
160+
hints["default"] = "true"
161+
}
162+
163+
vs = vs.WithVariant(variants.ServerVariant{
164+
ID: string(meta.ID),
165+
Description: meta.Description,
166+
Status: variants.Stable,
167+
Hints: hints,
168+
}, toolsetServer, priority)
169+
}
170+
171+
// Custom ranking: boost variants whose toolset ID matches the client's hint
172+
vs = vs.WithRanking(func(_ context.Context, hints variants.VariantHints, vs []variants.ServerVariant) []variants.ServerVariant {
173+
requested, _ := variants.HintValue[string](hints, "toolset")
174+
slices.SortStableFunc(vs, func(a, b variants.ServerVariant) int {
175+
aMatch := strings.EqualFold(a.Hints["toolset"], requested)
176+
bMatch := strings.EqualFold(b.Hints["toolset"], requested)
177+
if aMatch != bMatch {
178+
if aMatch {
179+
return -1
180+
}
181+
return 1
182+
}
183+
return a.Priority() - b.Priority()
184+
})
185+
return vs
186+
})
187+
188+
return vs, nil
189+
}
190+
191+
// groupToolsByToolset groups tools by their toolset ID.
192+
func groupToolsByToolset(tools []inventory.ServerTool) map[inventory.ToolsetID][]inventory.ServerTool {
193+
result := make(map[inventory.ToolsetID][]inventory.ServerTool)
194+
for _, tool := range tools {
195+
result[tool.Toolset.ID] = append(result[tool.Toolset.ID], tool)
196+
}
197+
return result
198+
}
199+
200+
// groupResourcesByToolset groups resource templates by their toolset ID.
201+
func groupResourcesByToolset(resources []inventory.ServerResourceTemplate) map[inventory.ToolsetID][]inventory.ServerResourceTemplate {
202+
result := make(map[inventory.ToolsetID][]inventory.ServerResourceTemplate)
203+
for _, res := range resources {
204+
result[res.Toolset.ID] = append(result[res.Toolset.ID], res)
205+
}
206+
return result
207+
}
208+
209+
// groupPromptsByToolset groups prompts by their toolset ID.
210+
func groupPromptsByToolset(prompts []inventory.ServerPrompt) map[inventory.ToolsetID][]inventory.ServerPrompt {
211+
result := make(map[inventory.ToolsetID][]inventory.ServerPrompt)
212+
for _, prompt := range prompts {
213+
result[prompt.Toolset.ID] = append(result[prompt.Toolset.ID], prompt)
214+
}
215+
return result
216+
}
217+
218+
// collectToolsetIDs returns sorted unique toolset IDs from all item maps.
219+
func collectToolsetIDs(
220+
tools map[inventory.ToolsetID][]inventory.ServerTool,
221+
resources map[inventory.ToolsetID][]inventory.ServerResourceTemplate,
222+
prompts map[inventory.ToolsetID][]inventory.ServerPrompt,
223+
) []inventory.ToolsetID {
224+
idSet := make(map[inventory.ToolsetID]bool)
225+
for id := range tools {
226+
idSet[id] = true
227+
}
228+
for id := range resources {
229+
idSet[id] = true
230+
}
231+
for id := range prompts {
232+
idSet[id] = true
233+
}
234+
235+
ids := make([]inventory.ToolsetID, 0, len(idSet))
236+
for id := range idSet {
237+
ids = append(ids, id)
238+
}
239+
slices.Sort(ids)
240+
return ids
241+
}
242+
243+
// runVariantsStdioServer creates and runs a variants-based MCP server over stdio.
244+
func runVariantsStdioServer(
245+
ctx context.Context,
246+
mcpCfg github.MCPServerConfig,
247+
cfg StdioServerConfig,
248+
logger *slog.Logger,
249+
t translations.TranslationHelperFunc,
250+
dumpTranslations func(),
251+
) error {
252+
vs, err := NewVariantsStdioMCPServer(ctx, mcpCfg)
253+
if err != nil {
254+
return fmt.Errorf("failed to create variants MCP server: %w", err)
255+
}
256+
defer vs.Close()
257+
258+
if cfg.ExportTranslations {
259+
dumpTranslations()
260+
}
261+
262+
logger.Info("using variants mode - each toolset is a separate variant",
263+
"variantCount", len(vs.Variants()))
264+
for _, v := range vs.Variants() {
265+
logger.Info("registered variant", "id", v.ID, "description", v.Description, "priority", v.Priority())
266+
}
267+
268+
errC := make(chan error, 1)
269+
go func() {
270+
var in io.ReadCloser
271+
var out io.WriteCloser
272+
273+
in = os.Stdin
274+
out = os.Stdout
275+
276+
if cfg.EnableCommandLogging {
277+
loggedIO := mcplog.NewIOLogger(in, out, logger)
278+
in, out = loggedIO, loggedIO
279+
}
280+
281+
_ = t // translations already applied via mcpCfg.Translator
282+
errC <- vs.Run(ctx, &mcp.IOTransport{Reader: in, Writer: out})
283+
}()
284+
285+
_, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on stdio (variants mode)\n")
286+
287+
select {
288+
case <-ctx.Done():
289+
logger.Info("shutting down server", "signal", "context done")
290+
case err := <-errC:
291+
if err != nil {
292+
logger.Error("error running server", "error", err)
293+
return fmt.Errorf("error running server: %w", err)
294+
}
295+
}
296+
297+
return nil
298+
}

0 commit comments

Comments
 (0)