|
| 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