Skip to content

Commit ff237aa

Browse files
Add --features CLI flag for feature flag support
Add CLI flag and config support for feature flags in the local server: - Add --features flag to main.go (StringSlice, comma-separated) - Add EnabledFeatures field to StdioServerConfig and MCPServerConfig - Create createFeatureChecker() that builds a set from enabled features - Wire WithFeatureChecker() into the toolset group filter chain This enables tools/resources/prompts that have FeatureFlagEnable set to a flag name that is passed via --features. The checker uses a simple set membership test for O(1) lookup. Usage: github-mcp-server stdio --features=my_feature,another_feature GITHUB_FEATURES=my_feature github-mcp-server stdio
1 parent 26e5bf0 commit ff237aa

35 files changed

+2290
-1071
lines changed

README.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ You can also configure specific tools using the `--tools` flag. Tools can be use
384384
- Tools, toolsets, and dynamic toolsets can all be used together
385385
- Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools`
386386
- Tool names must match exactly (e.g., `get_file_contents`, not `getFileContents`). Invalid tool names will cause the server to fail at startup with an error message
387+
- When tools are renamed, old names are preserved as aliases for backward compatibility. See [Deprecated Tool Aliases](docs/deprecated-tool-aliases.md) for details.
387388

388389
### Using Toolsets With Docker
389390

@@ -459,7 +460,6 @@ The following sets of tools are available:
459460
| `code_security` | Code security related tools, such as GitHub Code Scanning |
460461
| `dependabot` | Dependabot tools |
461462
| `discussions` | GitHub Discussions related tools |
462-
| `experiments` | Experimental features that are not considered stable yet |
463463
| `gists` | GitHub Gist related tools |
464464
| `git` | GitHub Git API related tools for low-level Git operations |
465465
| `issues` | GitHub Issues related tools |
@@ -718,11 +718,6 @@ The following sets of tools are available:
718718
- `owner`: Repository owner (string, required)
719719
- `repo`: Repository name (string, required)
720720

721-
- **get_label** - Get a specific label from a repository.
722-
- `name`: Label name. (string, required)
723-
- `owner`: Repository owner (username or organization name) (string, required)
724-
- `repo`: Repository name (string, required)
725-
726721
- **issue_read** - Get issue details
727722
- `issue_number`: The number of the issue (number, required)
728723
- `method`: The read operation to perform on a single issue.

cmd/github-mcp-server/generate_docs.go

Lines changed: 89 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,12 @@ import (
1010
"strings"
1111

1212
"github.com/github/github-mcp-server/pkg/github"
13-
"github.com/github/github-mcp-server/pkg/lockdown"
1413
"github.com/github/github-mcp-server/pkg/raw"
1514
"github.com/github/github-mcp-server/pkg/toolsets"
1615
"github.com/github/github-mcp-server/pkg/translations"
1716
gogithub "github.com/google/go-github/v79/github"
1817
"github.com/google/jsonschema-go/jsonschema"
1918
"github.com/modelcontextprotocol/go-sdk/mcp"
20-
"github.com/shurcooL/githubv4"
2119
"github.com/spf13/cobra"
2220
)
2321

@@ -39,11 +37,6 @@ func mockGetClient(_ context.Context) (*gogithub.Client, error) {
3937
return gogithub.NewClient(nil), nil
4038
}
4139

42-
// mockGetGQLClient returns a mock GraphQL client for documentation generation
43-
func mockGetGQLClient(_ context.Context) (*githubv4.Client, error) {
44-
return githubv4.NewClient(nil), nil
45-
}
46-
4740
// mockGetRawClient returns a mock raw client for documentation generation
4841
func mockGetRawClient(_ context.Context) (*raw.Client, error) {
4942
return nil, nil
@@ -58,16 +51,19 @@ func generateAllDocs() error {
5851
return fmt.Errorf("failed to generate remote-server docs: %w", err)
5952
}
6053

54+
if err := generateDeprecatedAliasesDocs("docs/deprecated-tool-aliases.md"); err != nil {
55+
return fmt.Errorf("failed to generate deprecated aliases docs: %w", err)
56+
}
57+
6158
return nil
6259
}
6360

6461
func generateReadmeDocs(readmePath string) error {
6562
// Create translation helper
6663
t, _ := translations.TranslationHelper()
6764

68-
// Create toolset group with mock clients
69-
repoAccessCache := lockdown.GetInstance(nil)
70-
tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{}, repoAccessCache)
65+
// Create toolset group with mock clients (no deps needed for doc generation)
66+
tsg := github.NewToolsetGroup(t, mockGetClient, mockGetRawClient)
7167

7268
// Generate toolsets documentation
7369
toolsetsDoc := generateToolsetsDoc(tsg)
@@ -133,20 +129,16 @@ func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string {
133129
// Add the context toolset row (handled separately in README)
134130
lines = append(lines, "| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |")
135131

136-
// Get all toolsets except context (which is handled separately above)
137-
var toolsetNames []string
138-
for name := range tsg.Toolsets {
139-
if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately
140-
toolsetNames = append(toolsetNames, name)
141-
}
142-
}
143-
144-
// Sort toolset names for consistent output
145-
sort.Strings(toolsetNames)
132+
// Get toolset IDs and descriptions
133+
toolsetIDs := tsg.ToolsetIDs()
134+
descriptions := tsg.ToolsetDescriptions()
146135

147-
for _, name := range toolsetNames {
148-
toolset := tsg.Toolsets[name]
149-
lines = append(lines, fmt.Sprintf("| `%s` | %s |", name, toolset.Description))
136+
// Filter out context and dynamic toolsets (handled separately)
137+
for _, id := range toolsetIDs {
138+
if id != "context" && id != "dynamic" {
139+
description := descriptions[id]
140+
lines = append(lines, fmt.Sprintf("| `%s` | %s |", id, description))
141+
}
150142
}
151143

152144
return strings.Join(lines, "\n")
@@ -155,30 +147,22 @@ func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string {
155147
func generateToolsDoc(tsg *toolsets.ToolsetGroup) string {
156148
var sections []string
157149

158-
// Get all toolset names and sort them alphabetically for deterministic order
159-
var toolsetNames []string
160-
for name := range tsg.Toolsets {
161-
if name != "dynamic" { // Skip dynamic toolset as it's handled separately
162-
toolsetNames = append(toolsetNames, name)
163-
}
164-
}
165-
sort.Strings(toolsetNames)
150+
// Get toolset IDs (already sorted deterministically)
151+
toolsetIDs := tsg.ToolsetIDs()
166152

167-
for _, toolsetName := range toolsetNames {
168-
toolset := tsg.Toolsets[toolsetName]
153+
for _, toolsetID := range toolsetIDs {
154+
if toolsetID == "dynamic" { // Skip dynamic toolset as it's handled separately
155+
continue
156+
}
169157

170-
tools := toolset.GetAvailableTools()
158+
// Get tools for this toolset (already sorted deterministically)
159+
tools := tsg.ToolsForToolset(toolsetID)
171160
if len(tools) == 0 {
172161
continue
173162
}
174163

175-
// Sort tools by name for deterministic order
176-
sort.Slice(tools, func(i, j int) bool {
177-
return tools[i].Tool.Name < tools[j].Tool.Name
178-
})
179-
180164
// Generate section header - capitalize first letter and replace underscores
181-
sectionName := formatToolsetName(toolsetName)
165+
sectionName := formatToolsetName(string(toolsetID))
182166

183167
var toolDocs []string
184168
for _, serverTool := range tools {
@@ -322,33 +306,30 @@ func generateRemoteToolsetsDoc() string {
322306
t, _ := translations.TranslationHelper()
323307

324308
// Create toolset group with mock clients
325-
repoAccessCache := lockdown.GetInstance(nil)
326-
tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{}, repoAccessCache)
309+
tsg := github.NewToolsetGroup(t, mockGetClient, mockGetRawClient)
327310

328311
// Generate table header
329312
buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")
330313
buf.WriteString("|----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n")
331314

332-
// Get all toolsets
333-
toolsetNames := make([]string, 0, len(tsg.Toolsets))
334-
for name := range tsg.Toolsets {
335-
if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately
336-
toolsetNames = append(toolsetNames, name)
337-
}
338-
}
339-
sort.Strings(toolsetNames)
315+
// Get toolset IDs and descriptions
316+
toolsetIDs := tsg.ToolsetIDs()
317+
descriptions := tsg.ToolsetDescriptions()
340318

341319
// Add "all" toolset first (special case)
342320
buf.WriteString("| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |\n")
343321

344322
// Add individual toolsets
345-
for _, name := range toolsetNames {
346-
toolset := tsg.Toolsets[name]
323+
for _, id := range toolsetIDs {
324+
idStr := string(id)
325+
if idStr == "context" || idStr == "dynamic" { // Skip context and dynamic toolsets as they're handled separately
326+
continue
327+
}
347328

348-
formattedName := formatToolsetName(name)
349-
description := toolset.Description
350-
apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", name)
351-
readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", name)
329+
description := descriptions[id]
330+
formattedName := formatToolsetName(idStr)
331+
apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr)
332+
readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr)
352333

353334
// Create install config JSON (URL encoded)
354335
installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL))
@@ -358,8 +339,8 @@ func generateRemoteToolsetsDoc() string {
358339
installConfig = strings.ReplaceAll(installConfig, "+", "%20")
359340
readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20")
360341

361-
installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, installConfig)
362-
readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, readonlyConfig)
342+
installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig)
343+
readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig)
363344

364345
buf.WriteString(fmt.Sprintf("| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n",
365346
formattedName,
@@ -373,3 +354,53 @@ func generateRemoteToolsetsDoc() string {
373354

374355
return buf.String()
375356
}
357+
358+
func generateDeprecatedAliasesDocs(docsPath string) error {
359+
// Read the current file
360+
content, err := os.ReadFile(docsPath) //#nosec G304
361+
if err != nil {
362+
return fmt.Errorf("failed to read docs file: %w", err)
363+
}
364+
365+
// Generate the table
366+
aliasesDoc := generateDeprecatedAliasesTable()
367+
368+
// Replace content between markers
369+
updatedContent := replaceSection(string(content), "START AUTOMATED ALIASES", "END AUTOMATED ALIASES", aliasesDoc)
370+
371+
// Write back to file
372+
err = os.WriteFile(docsPath, []byte(updatedContent), 0600)
373+
if err != nil {
374+
return fmt.Errorf("failed to write deprecated aliases docs: %w", err)
375+
}
376+
377+
fmt.Println("Successfully updated docs/deprecated-tool-aliases.md with automated documentation")
378+
return nil
379+
}
380+
381+
func generateDeprecatedAliasesTable() string {
382+
var lines []string
383+
384+
// Add table header
385+
lines = append(lines, "| Old Name | New Name |")
386+
lines = append(lines, "|----------|----------|")
387+
388+
aliases := github.DeprecatedToolAliases
389+
if len(aliases) == 0 {
390+
lines = append(lines, "| *(none currently)* | |")
391+
} else {
392+
// Sort keys for deterministic output
393+
var oldNames []string
394+
for oldName := range aliases {
395+
oldNames = append(oldNames, oldName)
396+
}
397+
sort.Strings(oldNames)
398+
399+
for _, oldName := range oldNames {
400+
newName := aliases[oldName]
401+
lines = append(lines, fmt.Sprintf("| `%s` | `%s` |", oldName, newName))
402+
}
403+
}
404+
405+
return strings.Join(lines, "\n")
406+
}

cmd/github-mcp-server/main.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ var (
5252
return fmt.Errorf("failed to unmarshal tools: %w", err)
5353
}
5454

55-
// If neither toolset config nor tools config is passed we enable the default toolset
56-
if len(enabledToolsets) == 0 && len(enabledTools) == 0 {
57-
enabledToolsets = []string{github.ToolsetMetadataDefault.ID}
55+
// Parse enabled features (similar to toolsets)
56+
var enabledFeatures []string
57+
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
58+
return fmt.Errorf("failed to unmarshal features: %w", err)
5859
}
5960

6061
ttl := viper.GetDuration("repo-access-cache-ttl")
@@ -64,6 +65,7 @@ var (
6465
Token: token,
6566
EnabledToolsets: enabledToolsets,
6667
EnabledTools: enabledTools,
68+
EnabledFeatures: enabledFeatures,
6769
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
6870
ReadOnly: viper.GetBool("read-only"),
6971
ExportTranslations: viper.GetBool("export-translations"),
@@ -87,6 +89,7 @@ func init() {
8789
// Add global flags that will be shared by all commands
8890
rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp())
8991
rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable")
92+
rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable")
9093
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
9194
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
9295
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
@@ -100,6 +103,7 @@ func init() {
100103
// Bind flag to viper
101104
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
102105
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
106+
_ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features"))
103107
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
104108
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
105109
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))

docs/deprecated-tool-aliases.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Deprecated Tool Aliases
2+
3+
This document tracks tool renames in the GitHub MCP Server. When tools are renamed, the old names are preserved as aliases for backward compatibility. Using a deprecated alias will still work, but clients should migrate to the new canonical name.
4+
5+
## Current Deprecations
6+
7+
<!-- START AUTOMATED ALIASES -->
8+
| Old Name | New Name |
9+
|----------|----------|
10+
| *(none currently)* | |
11+
<!-- END AUTOMATED ALIASES -->
12+
13+
## How It Works
14+
15+
When a tool is renamed:
16+
17+
1. The old name is added to `DeprecatedToolAliases` in [pkg/github/deprecated_tool_aliases.go](../pkg/github/deprecated_tool_aliases.go)
18+
2. Clients using the old name will receive the new tool
19+
3. A deprecation notice is logged when the alias is used
20+
21+
## For Developers
22+
23+
To deprecate a tool name when renaming:
24+
25+
```go
26+
var DeprecatedToolAliases = map[string]string{
27+
"old_tool_name": "new_tool_name",
28+
}
29+
```
30+
31+
The alias resolution happens at server startup, ensuring backward compatibility for existing client configurations.

0 commit comments

Comments
 (0)