Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ You can also configure specific tools using the `--tools` flag. Tools can be use
- Tools, toolsets, and dynamic toolsets can all be used together
- Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools`
- 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
- When tools are renamed, old names are preserved as aliases for backward compatibility. See [Deprecated Tool Aliases](docs/deprecated-tool-aliases.md) for details.

### Using Toolsets With Docker

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

- **get_label** - Get a specific label from a repository.
- `name`: Label name. (string, required)
- `owner`: Repository owner (username or organization name) (string, required)
- `repo`: Repository name (string, required)

- **issue_read** - Get issue details
- `issue_number`: The number of the issue (number, required)
- `method`: The read operation to perform on a single issue.
Expand Down
162 changes: 90 additions & 72 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"context"
"fmt"
"net/url"
"os"
Expand All @@ -10,14 +9,10 @@ import (
"strings"

"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/toolsets"
"github.com/github/github-mcp-server/pkg/translations"
gogithub "github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
"github.com/spf13/cobra"
)

Expand All @@ -34,21 +29,6 @@ func init() {
rootCmd.AddCommand(generateDocsCmd)
}

// mockGetClient returns a mock GitHub client for documentation generation
func mockGetClient(_ context.Context) (*gogithub.Client, error) {
return gogithub.NewClient(nil), nil
}

// mockGetGQLClient returns a mock GraphQL client for documentation generation
func mockGetGQLClient(_ context.Context) (*githubv4.Client, error) {
return githubv4.NewClient(nil), nil
}

// mockGetRawClient returns a mock raw client for documentation generation
func mockGetRawClient(_ context.Context) (*raw.Client, error) {
return nil, nil
}

func generateAllDocs() error {
if err := generateReadmeDocs("README.md"); err != nil {
return fmt.Errorf("failed to generate README docs: %w", err)
Expand All @@ -58,16 +38,19 @@ func generateAllDocs() error {
return fmt.Errorf("failed to generate remote-server docs: %w", err)
}

if err := generateDeprecatedAliasesDocs("docs/deprecated-tool-aliases.md"); err != nil {
return fmt.Errorf("failed to generate deprecated aliases docs: %w", err)
}

return nil
}

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

// Create toolset group with mock clients
repoAccessCache := lockdown.GetInstance(nil)
tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{}, repoAccessCache)
// Create toolset group - stateless, no dependencies needed for doc generation
tsg := github.NewToolsetGroup(t)

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

// Get all toolsets except context (which is handled separately above)
var toolsetNames []string
for name := range tsg.Toolsets {
if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately
toolsetNames = append(toolsetNames, name)
}
}

// Sort toolset names for consistent output
sort.Strings(toolsetNames)
// Get toolset IDs and descriptions
toolsetIDs := tsg.ToolsetIDs()
descriptions := tsg.ToolsetDescriptions()

for _, name := range toolsetNames {
toolset := tsg.Toolsets[name]
lines = append(lines, fmt.Sprintf("| `%s` | %s |", name, toolset.Description))
// Filter out context and dynamic toolsets (handled separately)
for _, id := range toolsetIDs {
if id != "context" && id != "dynamic" {
description := descriptions[id]
lines = append(lines, fmt.Sprintf("| `%s` | %s |", id, description))
}
}

return strings.Join(lines, "\n")
Expand All @@ -155,30 +134,22 @@ func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string {
func generateToolsDoc(tsg *toolsets.ToolsetGroup) string {
var sections []string

// Get all toolset names and sort them alphabetically for deterministic order
var toolsetNames []string
for name := range tsg.Toolsets {
if name != "dynamic" { // Skip dynamic toolset as it's handled separately
toolsetNames = append(toolsetNames, name)
}
}
sort.Strings(toolsetNames)
// Get toolset IDs (already sorted deterministically)
toolsetIDs := tsg.ToolsetIDs()

for _, toolsetName := range toolsetNames {
toolset := tsg.Toolsets[toolsetName]
for _, toolsetID := range toolsetIDs {
if toolsetID == "dynamic" { // Skip dynamic toolset as it's handled separately
continue
}

tools := toolset.GetAvailableTools()
// Get tools for this toolset (already sorted deterministically)
tools := tsg.ToolsForToolset(toolsetID)
if len(tools) == 0 {
continue
}

// Sort tools by name for deterministic order
sort.Slice(tools, func(i, j int) bool {
return tools[i].Tool.Name < tools[j].Tool.Name
})

// Generate section header - capitalize first letter and replace underscores
sectionName := formatToolsetName(toolsetName)
sectionName := formatToolsetName(string(toolsetID))

var toolDocs []string
for _, serverTool := range tools {
Expand Down Expand Up @@ -321,34 +292,31 @@ func generateRemoteToolsetsDoc() string {
// Create translation helper
t, _ := translations.TranslationHelper()

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

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

// Get all toolsets
toolsetNames := make([]string, 0, len(tsg.Toolsets))
for name := range tsg.Toolsets {
if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately
toolsetNames = append(toolsetNames, name)
}
}
sort.Strings(toolsetNames)
// Get toolset IDs and descriptions
toolsetIDs := tsg.ToolsetIDs()
descriptions := tsg.ToolsetDescriptions()

// Add "all" toolset first (special case)
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")

// Add individual toolsets
for _, name := range toolsetNames {
toolset := tsg.Toolsets[name]
for _, id := range toolsetIDs {
idStr := string(id)
if idStr == "context" || idStr == "dynamic" { // Skip context and dynamic toolsets as they're handled separately
continue
}

formattedName := formatToolsetName(name)
description := toolset.Description
apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", name)
readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", name)
description := descriptions[id]
formattedName := formatToolsetName(idStr)
apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr)
readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr)

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

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

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

return buf.String()
}

func generateDeprecatedAliasesDocs(docsPath string) error {
// Read the current file
content, err := os.ReadFile(docsPath) //#nosec G304
if err != nil {
return fmt.Errorf("failed to read docs file: %w", err)
}

// Generate the table
aliasesDoc := generateDeprecatedAliasesTable()

// Replace content between markers
updatedContent := replaceSection(string(content), "START AUTOMATED ALIASES", "END AUTOMATED ALIASES", aliasesDoc)

// Write back to file
err = os.WriteFile(docsPath, []byte(updatedContent), 0600)
if err != nil {
return fmt.Errorf("failed to write deprecated aliases docs: %w", err)
}

fmt.Println("Successfully updated docs/deprecated-tool-aliases.md with automated documentation")
return nil
}

func generateDeprecatedAliasesTable() string {
var lines []string

// Add table header
lines = append(lines, "| Old Name | New Name |")
lines = append(lines, "|----------|----------|")

aliases := github.DeprecatedToolAliases
if len(aliases) == 0 {
lines = append(lines, "| *(none currently)* | |")
} else {
// Sort keys for deterministic output
var oldNames []string
for oldName := range aliases {
oldNames = append(oldNames, oldName)
}
sort.Strings(oldNames)

for _, oldName := range oldNames {
newName := aliases[oldName]
lines = append(lines, fmt.Sprintf("| `%s` | `%s` |", oldName, newName))
}
}

return strings.Join(lines, "\n")
}
20 changes: 15 additions & 5 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,27 @@ var (
// it's because viper doesn't handle comma-separated values correctly for env
// vars when using GetStringSlice.
// https://github.com/spf13/viper/issues/380
//
// Additionally, viper.UnmarshalKey returns an empty slice even when the flag
// is not set, but we need nil to indicate "use defaults". So we check IsSet first.
var enabledToolsets []string
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
if viper.IsSet("toolsets") {
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}
}
// else: enabledToolsets stays nil, meaning "use defaults"

// Parse tools (similar to toolsets)
var enabledTools []string
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
return fmt.Errorf("failed to unmarshal tools: %w", err)
}

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

ttl := viper.GetDuration("repo-access-cache-ttl")
Expand All @@ -64,6 +71,7 @@ var (
Token: token,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
ReadOnly: viper.GetBool("read-only"),
ExportTranslations: viper.GetBool("export-translations"),
Expand All @@ -87,6 +95,7 @@ func init() {
// Add global flags that will be shared by all commands
rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp())
rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable")
rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable")
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
Expand All @@ -100,6 +109,7 @@ func init() {
// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
_ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features"))
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
Expand Down
31 changes: 31 additions & 0 deletions docs/deprecated-tool-aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Deprecated Tool Aliases

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.

## Current Deprecations

<!-- START AUTOMATED ALIASES -->
| Old Name | New Name |
|----------|----------|
| *(none currently)* | |
<!-- END AUTOMATED ALIASES -->

## How It Works

When a tool is renamed:

1. The old name is added to `DeprecatedToolAliases` in [pkg/github/deprecated_tool_aliases.go](../pkg/github/deprecated_tool_aliases.go)
2. Clients using the old name will receive the new tool
3. A deprecation notice is logged when the alias is used

## For Developers

To deprecate a tool name when renaming:

```go
var DeprecatedToolAliases = map[string]string{
"old_tool_name": "new_tool_name",
}
```

The alias resolution happens at server startup, ensuring backward compatibility for existing client configurations.
Loading