chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:57 +08:00
commit e30f8ba47c
533 changed files with 115926 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"context"
"fmt"
"os"
"reflect"
"sort"
"strings"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
)
// generateInsidersFeaturesDocs refreshes the auto-generated section of
// docs/insiders-features.md with the tools and schemas affected by each
// Insiders feature flag.
func generateInsidersFeaturesDocs(docsPath string) error {
body := generateFlaggedToolsDoc(github.InsidersFeatureFlags, "_No Insiders-only tool changes._")
return rewriteAutomatedSection(docsPath, "START AUTOMATED INSIDERS TOOLS", "END AUTOMATED INSIDERS TOOLS", body)
}
// generateFeatureFlagsDocs refreshes the auto-generated section of
// docs/feature-flags.md with the tools and schemas affected by each
// user-controllable feature flag.
func generateFeatureFlagsDocs(docsPath string) error {
body := generateFlaggedToolsDoc(github.AllowedFeatureFlags, "_No user-controllable feature flags affect tool registration._")
return rewriteAutomatedSection(docsPath, "START AUTOMATED FEATURE FLAG TOOLS", "END AUTOMATED FEATURE FLAG TOOLS", body)
}
// generateFlaggedToolsDoc renders, for each flag in the input set, the tools
// whose registration or definition differs from the default user experience.
// Each affected tool is printed with its full schema using the same writer
// used by the README so the output style stays consistent.
func generateFlaggedToolsDoc(flags []string, emptyMessage string) string {
t, _ := translations.TranslationHelper()
defaultTools := indexToolsByName(buildInventoryWithFlags(t, nil).ToolsForRegistration(context.Background()))
var buf strings.Builder
hasAny := false
for _, flag := range flags {
affected := flaggedToolDiff(t, flag, defaultTools)
if len(affected) == 0 {
continue
}
if hasAny {
buf.WriteString("\n\n")
}
hasAny = true
fmt.Fprintf(&buf, "### `%s`\n\n", flag)
for i, tool := range affected {
writeToolDoc(&buf, tool)
if i < len(affected)-1 {
buf.WriteString("\n\n")
}
}
}
if !hasAny {
return emptyMessage
}
// Leading/trailing newlines around the body produce blank lines between
// our content and the surrounding marker comments, so the trailing comment
// doesn't get absorbed into the final list item by markdown renderers.
return "\n" + strings.TrimSuffix(buf.String(), "\n") + "\n"
}
// flaggedToolDiff returns the tools whose definition (input schema or meta)
// differs from the default-flagged inventory when only the given flag is on,
// plus tools that exist only in the flag-on inventory. Results are sorted by
// tool name.
func flaggedToolDiff(t translations.TranslationHelperFunc, flag string, defaultTools map[string]inventory.ServerTool) []inventory.ServerTool {
flagTools := buildInventoryWithFlags(t, map[string]bool{flag: true}).ToolsForRegistration(context.Background())
out := make([]inventory.ServerTool, 0)
seen := make(map[string]struct{}, len(flagTools))
for _, tool := range flagTools {
if _, ok := seen[tool.Tool.Name]; ok {
continue
}
seen[tool.Tool.Name] = struct{}{}
baseline, hadBaseline := defaultTools[tool.Tool.Name]
if hadBaseline && reflect.DeepEqual(tool.Tool.InputSchema, baseline.Tool.InputSchema) && reflect.DeepEqual(tool.Tool.Meta, baseline.Tool.Meta) {
continue
}
out = append(out, tool)
}
sort.Slice(out, func(i, j int) bool { return out[i].Tool.Name < out[j].Tool.Name })
return out
}
// buildInventoryWithFlags constructs an inventory whose feature checker treats
// the given flags as enabled and every other flag as disabled. Passing nil
// produces the default-flagged inventory.
func buildInventoryWithFlags(t translations.TranslationHelperFunc, enabled map[string]bool) *inventory.Inventory {
checker := func(_ context.Context, flag string) (bool, error) {
return enabled[flag], nil
}
inv, _ := github.NewInventory(t).
WithToolsets([]string{"all"}).
WithFeatureChecker(checker).
Build()
return inv
}
// indexToolsByName returns a map keyed by tool name. When duplicates exist
// (e.g. flag-gated dual registrations), the first occurrence wins, mirroring
// AvailableTools' deterministic sort order.
func indexToolsByName(tools []inventory.ServerTool) map[string]inventory.ServerTool {
out := make(map[string]inventory.ServerTool, len(tools))
for _, tool := range tools {
if _, ok := out[tool.Tool.Name]; ok {
continue
}
out[tool.Tool.Name] = tool
}
return out
}
// rewriteAutomatedSection reads a markdown file, replaces the content between
// the named markers with body, and writes it back.
func rewriteAutomatedSection(path, startMarker, endMarker, body string) error {
content, err := os.ReadFile(path) //#nosec G304
if err != nil {
return fmt.Errorf("failed to read docs file: %w", err)
}
updated, err := replaceSection(string(content), startMarker, endMarker, body)
if err != nil {
return err
}
return os.WriteFile(path, []byte(updated), 0600) //#nosec G306
}
+509
View File
@@ -0,0 +1,509 @@
package main
import (
"context"
"fmt"
"net/url"
"os"
"slices"
"sort"
"strings"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/jsonschema-go/jsonschema"
"github.com/spf13/cobra"
)
var generateDocsCmd = &cobra.Command{
Use: "generate-docs",
Short: "Generate documentation for tools and toolsets",
Long: `Generate the automated sections of README.md and docs/remote-server.md with current tool and toolset information.`,
RunE: func(_ *cobra.Command, _ []string) error {
return generateAllDocs()
},
}
func init() {
rootCmd.AddCommand(generateDocsCmd)
}
// noFeatureFlagsChecker reports every feature flag as disabled. It models the
// default user experience used by the generated documentation.
func noFeatureFlagsChecker(_ context.Context, _ string) (bool, error) {
return false, nil
}
func generateAllDocs() error {
for _, doc := range []struct {
path string
fn func(string) error
}{
// File to edit, function to generate its docs
{"README.md", generateReadmeDocs},
{"docs/remote-server.md", generateRemoteServerDocs},
{"docs/insiders-features.md", generateInsidersFeaturesDocs},
{"docs/feature-flags.md", generateFeatureFlagsDocs},
{"docs/tool-renaming.md", generateDeprecatedAliasesDocs},
} {
if err := doc.fn(doc.path); err != nil {
return fmt.Errorf("failed to generate docs for %s: %w", doc.path, err)
}
fmt.Printf("Successfully updated %s with automated documentation\n", doc.path)
}
return nil
}
func generateReadmeDocs(readmePath string) error {
// Create translation helper
t, _ := translations.TranslationHelper()
// The README documents the default user experience: tools that are
// enabled with no special flags set. Installing a checker that reports
// every flag as disabled excludes tools gated by FeatureFlagEnable and
// keeps the legacy variants of tools gated by FeatureFlagDisable, so
// flag-gated duplicates don't appear twice.
// Build() can only fail if WithTools specifies invalid tools - not used here
r, _ := github.NewInventory(t).
WithToolsets([]string{"all"}).
WithFeatureChecker(noFeatureFlagsChecker).
Build()
// Generate toolsets documentation
toolsetsDoc := generateToolsetsDoc(r)
// Generate tools documentation
toolsDoc := generateToolsDoc(r)
// Read the current README.md
// #nosec G304 - readmePath is controlled by command line flag, not user input
content, err := os.ReadFile(readmePath)
if err != nil {
return fmt.Errorf("failed to read README.md: %w", err)
}
// Replace toolsets section
updatedContent, err := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc)
if err != nil {
return err
}
// Replace tools section
updatedContent, err = replaceSection(updatedContent, "START AUTOMATED TOOLS", "END AUTOMATED TOOLS", toolsDoc)
if err != nil {
return err
}
// Write back to file
err = os.WriteFile(readmePath, []byte(updatedContent), 0600)
if err != nil {
return fmt.Errorf("failed to write README.md: %w", err)
}
return nil
}
func generateRemoteServerDocs(docsPath string) error {
content, err := os.ReadFile(docsPath) //#nosec G304
if err != nil {
return fmt.Errorf("failed to read docs file: %w", err)
}
toolsetsDoc := generateRemoteToolsetsDoc()
// Replace content between markers
updatedContent, err := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc)
if err != nil {
return err
}
// Also generate remote-only toolsets section
remoteOnlyDoc := generateRemoteOnlyToolsetsDoc()
updatedContent, err = replaceSection(updatedContent, "START AUTOMATED REMOTE TOOLSETS", "END AUTOMATED REMOTE TOOLSETS", remoteOnlyDoc)
if err != nil {
return err
}
return os.WriteFile(docsPath, []byte(updatedContent), 0600) //#nosec G306
}
// octiconImg returns an img tag for an Octicon that works with GitHub's light/dark theme.
// Uses picture element with prefers-color-scheme for automatic theme switching.
// References icons from the repo's pkg/octicons/icons directory.
// Optional pathPrefix for files in subdirectories (e.g., "../" for docs/).
func octiconImg(name string, pathPrefix ...string) string {
if name == "" {
return ""
}
prefix := ""
if len(pathPrefix) > 0 {
prefix = pathPrefix[0]
}
// Use picture element with media queries for light/dark mode support
// GitHub renders these correctly in markdown
lightIcon := fmt.Sprintf("%spkg/octicons/icons/%s-light.png", prefix, name)
darkIcon := fmt.Sprintf("%spkg/octicons/icons/%s-dark.png", prefix, name)
return fmt.Sprintf(`<picture><source media="(prefers-color-scheme: dark)" srcset="%s"><source media="(prefers-color-scheme: light)" srcset="%s"><img src="%s" width="20" height="20" alt="%s"></picture>`, darkIcon, lightIcon, lightIcon, name)
}
func generateToolsetsDoc(i *inventory.Inventory) string {
var buf strings.Builder
// Add table header and separator (with icon column)
buf.WriteString("| | Toolset | Description |\n")
buf.WriteString("| --- | ----------------------- | ------------------------------------------------------------- |\n")
// Add the context toolset row with custom description (strongly recommended)
// Get context toolset for its icon
contextIcon := octiconImg("person")
fmt.Fprintf(&buf, "| %s | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |\n", contextIcon)
// AvailableToolsets() returns toolsets that have tools, sorted by ID
// Exclude context (custom description above)
for _, ts := range i.AvailableToolsets("context") {
icon := octiconImg(ts.Icon)
fmt.Fprintf(&buf, "| %s | `%s` | %s |\n", icon, ts.ID, ts.Description)
}
return strings.TrimSuffix(buf.String(), "\n")
}
func generateToolsDoc(r *inventory.Inventory) string {
tools := r.ToolsForRegistration(context.Background())
if len(tools) == 0 {
return ""
}
var buf strings.Builder
var toolBuf strings.Builder
var currentToolsetID inventory.ToolsetID
var currentToolsetIcon string
firstSection := true
writeSection := func() {
if toolBuf.Len() == 0 {
return
}
if !firstSection {
buf.WriteString("\n\n")
}
firstSection = false
sectionName := formatToolsetName(string(currentToolsetID))
icon := octiconImg(currentToolsetIcon)
if icon != "" {
icon += " "
}
fmt.Fprintf(&buf, "<details>\n\n<summary>%s%s</summary>\n\n%s\n\n</details>", icon, sectionName, strings.TrimSuffix(toolBuf.String(), "\n\n"))
toolBuf.Reset()
}
for _, tool := range tools {
// When toolset changes, emit the previous section
if tool.Toolset.ID != currentToolsetID {
writeSection()
currentToolsetID = tool.Toolset.ID
currentToolsetIcon = tool.Toolset.Icon
}
writeToolDoc(&toolBuf, tool)
toolBuf.WriteString("\n\n")
}
// Emit the last section
writeSection()
return buf.String()
}
func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
// Tool name (no icon - section header already has the toolset icon)
fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title)
// OAuth scopes if present
if len(tool.RequiredScopes) > 0 {
// Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes),
// so when multiple required scopes are listed, render them as alternatives
// rather than implying all are required.
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
if len(tool.RequiredScopes) > 1 {
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
} else {
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
}
// Only show accepted scopes if they differ from required scopes
if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) {
fmt.Fprintf(buf, " - **Accepted OAuth Scopes**: `%s`\n", strings.Join(tool.AcceptedScopes, "`, `"))
}
}
// MCP App UI metadata (only rendered when the remote_mcp_ui_apps flag
// applied to the inventory; for the no-flags README this section is
// stripped by inventory.ToolsForRegistration before rendering).
if ui, ok := tool.Tool.Meta["ui"].(map[string]any); ok {
if uri, ok := ui["resourceUri"].(string); ok && uri != "" {
fmt.Fprintf(buf, " - **MCP App UI**: `%s`\n", uri)
}
}
// Parameters
if tool.Tool.InputSchema == nil {
buf.WriteString(" - No parameters required")
return
}
schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema)
if !ok || schema == nil {
buf.WriteString(" - No parameters required")
return
}
if len(schema.Properties) > 0 {
// Get parameter names and sort them for deterministic order
var paramNames []string
for propName := range schema.Properties {
paramNames = append(paramNames, propName)
}
sort.Strings(paramNames)
for i, propName := range paramNames {
prop := schema.Properties[propName]
required := slices.Contains(schema.Required, propName)
requiredStr := "optional"
if required {
requiredStr = "required"
}
var typeStr string
// Get the type and description
switch prop.Type {
case "array":
if prop.Items != nil {
typeStr = prop.Items.Type + "[]"
} else {
typeStr = "array"
}
default:
typeStr = prop.Type
}
// Indent any continuation lines in the description to maintain markdown formatting
description := indentMultilineDescription(prop.Description, " ")
fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
if i < len(paramNames)-1 {
buf.WriteString("\n")
}
}
} else {
buf.WriteString(" - No parameters required")
}
}
// scopesEqual checks if two scope slices contain the same elements (order-independent)
func scopesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
// Create a map for quick lookup
aMap := make(map[string]bool, len(a))
for _, scope := range a {
aMap[scope] = true
}
// Check if all elements in b are in a
for _, scope := range b {
if !aMap[scope] {
return false
}
}
return true
}
// indentMultilineDescription adds the specified indent to all lines after the first line.
// This ensures that multi-line descriptions maintain proper markdown list formatting.
func indentMultilineDescription(description, indent string) string {
if !strings.Contains(description, "\n") {
return description
}
var buf strings.Builder
lines := strings.Split(description, "\n")
buf.WriteString(lines[0])
for i := 1; i < len(lines); i++ {
buf.WriteString("\n")
buf.WriteString(indent)
buf.WriteString(lines[i])
}
return buf.String()
}
func replaceSection(content, startMarker, endMarker, newContent string) (string, error) {
start := fmt.Sprintf("<!-- %s -->", startMarker)
end := fmt.Sprintf("<!-- %s -->", endMarker)
before, _, ok := strings.Cut(content, start)
endIdx := strings.Index(content, end)
if !ok || endIdx == -1 {
return "", fmt.Errorf("markers not found: %s / %s", start, end)
}
var buf strings.Builder
buf.WriteString(before)
buf.WriteString(start)
buf.WriteString("\n")
buf.WriteString(newContent)
buf.WriteString("\n")
buf.WriteString(content[endIdx:])
return buf.String(), nil
}
func generateRemoteToolsetsDoc() string {
var buf strings.Builder
// Create translation helper
t, _ := translations.TranslationHelper()
// Build inventory - stateless
// Build() can only fail if WithTools specifies invalid tools - not used here
r, _ := github.NewInventory(t).Build()
// Generate table header (icon is combined with Name column)
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")
// Add "default" and "all" meta toolsets first (special cases). The base
// URL serves the default toolset; /x/all enables every toolset at once.
metaIcon := octiconImg("apps", "../")
fmt.Fprintf(&buf, "| %s<br>`default` | Default toolset | 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", metaIcon)
fmt.Fprintf(&buf, "| %s<br>`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%2Freadonly%%22%%7D) |\n", metaIcon)
// AvailableToolsets() returns toolsets that have tools, sorted by ID
// Exclude context (handled separately)
for _, ts := range r.AvailableToolsets("context") {
idStr := string(ts.ID)
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))
readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL))
// Fix URL encoding to use %20 instead of + for spaces
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)", idStr, installConfig)
readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig)
icon := octiconImg(ts.Icon, "../")
fmt.Fprintf(&buf, "| %s<br>`%s` | %s | %s | %s | [read-only](%s) | %s |\n",
icon,
idStr,
ts.Description,
apiURL,
installLink,
readonlyURL,
readonlyInstallLink,
)
}
return strings.TrimSuffix(buf.String(), "\n")
}
func generateRemoteOnlyToolsetsDoc() string {
var buf strings.Builder
// Generate table header (icon is combined with Name column)
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")
// Use RemoteOnlyToolsets from github package
for _, ts := range github.RemoteOnlyToolsets() {
idStr := string(ts.ID)
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))
readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL))
// Fix URL encoding to use %20 instead of + for spaces
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)", idStr, installConfig)
readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig)
icon := octiconImg(ts.Icon, "../")
fmt.Fprintf(&buf, "| %s<br>`%s` | %s | %s | %s | [read-only](%s) | %s |\n",
icon,
idStr,
ts.Description,
apiURL,
installLink,
readonlyURL,
readonlyInstallLink,
)
}
return strings.TrimSuffix(buf.String(), "\n")
}
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, err := replaceSection(string(content), "START AUTOMATED ALIASES", "END AUTOMATED ALIASES", aliasesDoc)
if err != nil {
return err
}
// 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)
}
return nil
}
func generateDeprecatedAliasesTable() string {
var buf strings.Builder
// Add table header
buf.WriteString("| Old Name | New Name |\n")
buf.WriteString("|----------|----------|\n")
aliases := github.DeprecatedToolAliases
if len(aliases) == 0 {
buf.WriteString("| *(none currently)* | |")
} else {
// Sort keys for deterministic output
var oldNames []string
for oldName := range aliases {
oldNames = append(oldNames, oldName)
}
sort.Strings(oldNames)
for i, oldName := range oldNames {
newName := aliases[oldName]
fmt.Fprintf(&buf, "| `%s` | `%s` |", oldName, newName)
if i < len(oldNames)-1 {
buf.WriteString("\n")
}
}
}
return buf.String()
}
+29
View File
@@ -0,0 +1,29 @@
package main
import "strings"
// formatToolsetName converts a toolset ID to a human-readable name.
// Used by both generate_docs.go and list_scopes.go for consistent formatting.
func formatToolsetName(name string) string {
switch name {
case "pull_requests":
return "Pull Requests"
case "repos":
return "Repositories"
case "code_security":
return "Code Security"
case "secret_protection":
return "Secret Protection"
case "orgs":
return "Organizations"
default:
// Fallback: capitalize first letter and replace underscores with spaces
parts := strings.Split(name, "_")
for i, part := range parts {
if len(part) > 0 {
parts[i] = strings.ToUpper(string(part[0])) + part[1:]
}
}
return strings.Join(parts, " ")
}
}
+294
View File
@@ -0,0 +1,294 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// ToolScopeInfo contains scope information for a single tool.
type ToolScopeInfo struct {
Name string `json:"name"`
Toolset string `json:"toolset"`
ReadOnly bool `json:"read_only"`
RequiredScopes []string `json:"required_scopes"`
AcceptedScopes []string `json:"accepted_scopes,omitempty"`
}
// ScopesOutput is the full output structure for the list-scopes command.
type ScopesOutput struct {
Tools []ToolScopeInfo `json:"tools"`
UniqueScopes []string `json:"unique_scopes"`
ScopesByTool map[string][]string `json:"scopes_by_tool"`
ToolsByScope map[string][]string `json:"tools_by_scope"`
EnabledToolsets []string `json:"enabled_toolsets"`
ReadOnly bool `json:"read_only"`
}
var listScopesCmd = &cobra.Command{
Use: "list-scopes",
Short: "List required OAuth scopes for enabled tools",
Long: `List the required OAuth scopes for all enabled tools.
This command creates an inventory based on the same flags as the stdio command
and outputs the required OAuth scopes for each enabled tool. This is useful for
determining what scopes a token needs to use specific tools.
The output format can be controlled with the --output flag:
- text (default): Human-readable text output
- json: JSON output for programmatic use
- summary: Just the unique scopes needed
Examples:
# List scopes for default toolsets
github-mcp-server list-scopes
# List scopes for specific toolsets
github-mcp-server list-scopes --toolsets=repos,issues,pull_requests
# List scopes for all toolsets
github-mcp-server list-scopes --toolsets=all
# Output as JSON
github-mcp-server list-scopes --output=json
# Just show unique scopes needed
github-mcp-server list-scopes --output=summary`,
RunE: func(_ *cobra.Command, _ []string) error {
return runListScopes()
},
}
func init() {
listScopesCmd.Flags().StringP("output", "o", "text", "Output format: text, json, or summary")
_ = viper.BindPFlag("list-scopes-output", listScopesCmd.Flags().Lookup("output"))
rootCmd.AddCommand(listScopesCmd)
}
// formatScopeDisplay formats a scope string for display, handling empty scopes.
func formatScopeDisplay(scope string) string {
if scope == "" {
return "(no scope required for public read access)"
}
return scope
}
func runListScopes() error {
// Get toolsets configuration (same logic as stdio command)
var enabledToolsets []string
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"
// Get specific tools (similar to toolsets)
var enabledTools []string
if viper.IsSet("tools") {
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
return fmt.Errorf("failed to unmarshal tools: %w", err)
}
}
readOnly := viper.GetBool("read-only")
outputFormat := viper.GetString("list-scopes-output")
// Create translation helper
t, _ := translations.TranslationHelper()
// Build inventory using the same logic as the stdio server
inventoryBuilder := github.NewInventory(t).
WithReadOnly(readOnly)
// Configure toolsets (same as stdio)
if enabledToolsets != nil {
inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets)
}
// Configure specific tools
if len(enabledTools) > 0 {
inventoryBuilder = inventoryBuilder.WithTools(enabledTools)
}
inv, err := inventoryBuilder.Build()
if err != nil {
return fmt.Errorf("failed to build inventory: %w", err)
}
// Collect all tools and their scopes
output := collectToolScopes(inv, readOnly)
// Output based on format
switch outputFormat {
case "json":
return outputJSON(output)
case "summary":
return outputSummary(output)
default:
return outputText(output)
}
}
func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput {
var tools []ToolScopeInfo
scopeSet := make(map[string]bool)
scopesByTool := make(map[string][]string)
toolsByScope := make(map[string][]string)
// Get all available tools from the inventory
// Use context.Background() for feature flag evaluation
availableTools := inv.AvailableTools(context.Background())
for _, serverTool := range availableTools {
tool := serverTool.Tool
// Get scope information directly from ServerTool
requiredScopes := serverTool.RequiredScopes
acceptedScopes := serverTool.AcceptedScopes
// Determine if tool is read-only
isReadOnly := serverTool.IsReadOnly()
toolInfo := ToolScopeInfo{
Name: tool.Name,
Toolset: string(serverTool.Toolset.ID),
ReadOnly: isReadOnly,
RequiredScopes: requiredScopes,
AcceptedScopes: acceptedScopes,
}
tools = append(tools, toolInfo)
// Track unique scopes
for _, s := range requiredScopes {
scopeSet[s] = true
toolsByScope[s] = append(toolsByScope[s], tool.Name)
}
// Track scopes by tool
scopesByTool[tool.Name] = requiredScopes
}
// Sort tools by name
sort.Slice(tools, func(i, j int) bool {
return tools[i].Name < tools[j].Name
})
// Get unique scopes as sorted slice
var uniqueScopes []string
for s := range scopeSet {
uniqueScopes = append(uniqueScopes, s)
}
sort.Strings(uniqueScopes)
// Sort tools within each scope
for scope := range toolsByScope {
sort.Strings(toolsByScope[scope])
}
// Get enabled toolsets as string slice
toolsetIDs := inv.ToolsetIDs()
toolsetIDStrs := make([]string, len(toolsetIDs))
for i, id := range toolsetIDs {
toolsetIDStrs[i] = string(id)
}
return ScopesOutput{
Tools: tools,
UniqueScopes: uniqueScopes,
ScopesByTool: scopesByTool,
ToolsByScope: toolsByScope,
EnabledToolsets: toolsetIDStrs,
ReadOnly: readOnly,
}
}
func outputJSON(output ScopesOutput) error {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(output)
}
func outputSummary(output ScopesOutput) error {
if len(output.UniqueScopes) == 0 {
fmt.Println("No OAuth scopes required for enabled tools.")
return nil
}
fmt.Println("Required OAuth scopes for enabled tools:")
fmt.Println()
for _, scope := range output.UniqueScopes {
fmt.Printf(" %s\n", formatScopeDisplay(scope))
}
fmt.Printf("\nTotal: %d unique scope(s)\n", len(output.UniqueScopes))
return nil
}
func outputText(output ScopesOutput) error {
fmt.Printf("OAuth Scopes for Enabled Tools\n")
fmt.Printf("==============================\n\n")
fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", "))
fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly)
// Group tools by toolset
toolsByToolset := make(map[string][]ToolScopeInfo)
for _, tool := range output.Tools {
toolsByToolset[tool.Toolset] = append(toolsByToolset[tool.Toolset], tool)
}
// Get sorted toolset names
var toolsetNames []string
for name := range toolsByToolset {
toolsetNames = append(toolsetNames, name)
}
sort.Strings(toolsetNames)
for _, toolsetName := range toolsetNames {
tools := toolsByToolset[toolsetName]
fmt.Printf("## %s\n\n", formatToolsetName(toolsetName))
for _, tool := range tools {
rwIndicator := "📝"
if tool.ReadOnly {
rwIndicator = "👁"
}
scopeStr := "(no scope required)"
if len(tool.RequiredScopes) > 0 {
scopeStr = strings.Join(tool.RequiredScopes, ", ")
}
fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr)
}
fmt.Println()
}
// Summary
fmt.Println("## Summary")
fmt.Println()
if len(output.UniqueScopes) == 0 {
fmt.Println("No OAuth scopes required for enabled tools.")
} else {
fmt.Println("Unique scopes required:")
for _, scope := range output.UniqueScopes {
fmt.Printf(" • %s\n", formatScopeDisplay(scope))
}
}
fmt.Printf("\nTotal: %d tools, %d unique scopes\n", len(output.Tools), len(output.UniqueScopes))
// Legend
fmt.Println("\nLegend: 👁 = read-only, 📝 = read-write")
return nil
}
+291
View File
@@ -0,0 +1,291 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/github/github-mcp-server/internal/buildinfo"
"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/internal/oauth"
"github.com/github/github-mcp-server/pkg/github"
ghhttp "github.com/github/github-mcp-server/pkg/http"
ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
// These variables are set by the build process using ldflags.
var version = "version"
var commit = "commit"
var date = "date"
var (
rootCmd = &cobra.Command{
Use: "server",
Short: "GitHub MCP Server",
Long: `A GitHub MCP server that handles various tools and resources.`,
Version: fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s", version, commit, date),
}
stdioCmd = &cobra.Command{
Use: "stdio",
Short: "Start stdio server",
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
RunE: func(_ *cobra.Command, _ []string) error {
token := viper.GetString("personal_access_token")
oauthClientID := viper.GetString("oauth-client-id")
oauthClientSecret := viper.GetString("oauth-client-secret")
// Fall back to the build-time baked-in client (official releases) when none is
// configured explicitly. The baked-in app is registered on github.com, so it is
// only applied to the default host; GHES/ghe.com users must bring their own
// --oauth-client-id. Recognizing the host via NormalizeHost means an explicit
// GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps
// zero-config login working. The secret tracks the id, so an explicitly provided
// id with no secret never picks up the baked-in secret.
if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" {
oauthClientID = buildinfo.OAuthClientID
oauthClientSecret = buildinfo.OAuthClientSecret
}
if token == "" && oauthClientID == "" {
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth")
}
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
// 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 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 viper.IsSet("tools") {
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
return fmt.Errorf("failed to unmarshal tools: %w", err)
}
}
// Parse excluded tools (similar to tools)
var excludeTools []string
if viper.IsSet("exclude_tools") {
if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil {
return fmt.Errorf("failed to unmarshal exclude-tools: %w", err)
}
}
// Parse enabled features (similar to toolsets)
var enabledFeatures []string
if viper.IsSet("features") {
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
return fmt.Errorf("failed to unmarshal features: %w", err)
}
}
ttl := viper.GetDuration("repo-access-cache-ttl")
stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Host: viper.GetString("host"),
Token: token,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
ReadOnly: viper.GetBool("read-only"),
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
InsidersMode: viper.GetBool("insiders"),
ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
}
// When no static token is provided, log in via OAuth using the given
// client. The requested scopes default to the full supported set
// (which filters out no tools); an explicit, narrower --oauth-scopes
// both narrows the grant and hides tools needing other scopes.
if token == "" {
scopes := ghoauth.SupportedScopes
if viper.IsSet("oauth-scopes") {
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err)
}
}
oauthConfig := oauth.NewGitHubConfig(
oauthClientID,
oauthClientSecret,
scopes,
viper.GetString("host"),
viper.GetInt("oauth-callback-port"),
)
stdioServerConfig.OAuthManager = oauth.NewManager(oauthConfig, nil)
stdioServerConfig.OAuthScopes = scopes
}
return ghmcp.RunStdioServer(stdioServerConfig)
},
}
httpCmd = &cobra.Command{
Use: "http",
Short: "Start HTTP server",
Long: `Start an HTTP server that listens for MCP requests over HTTP.`,
RunE: func(_ *cobra.Command, _ []string) error {
// Parse toolsets (same approach as stdio — see comment there)
var enabledToolsets []string
if viper.IsSet("toolsets") {
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}
}
var enabledTools []string
if viper.IsSet("tools") {
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
return fmt.Errorf("failed to unmarshal tools: %w", err)
}
}
var excludeTools []string
if viper.IsSet("exclude_tools") {
if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil {
return fmt.Errorf("failed to unmarshal exclude-tools: %w", err)
}
}
var enabledFeatures []string
if viper.IsSet("features") {
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
return fmt.Errorf("failed to unmarshal features: %w", err)
}
}
ttl := viper.GetDuration("repo-access-cache-ttl")
httpConfig := ghhttp.ServerConfig{
Version: version,
Host: viper.GetString("host"),
Port: viper.GetInt("port"),
ListenHost: viper.GetString("listen-host"),
BaseURL: viper.GetString("base-url"),
ResourcePath: viper.GetString("base-path"),
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
RepoAccessCacheTTL: &ttl,
ScopeChallenge: viper.GetBool("scope-challenge"),
ReadOnly: viper.GetBool("read-only"),
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
ExcludeTools: excludeTools,
EnabledFeatures: enabledFeatures,
InsidersMode: viper.GetBool("insiders"),
TrustProxyHeaders: viper.GetBool("trust-proxy-headers"),
}
return ghhttp.RunHTTPServer(httpConfig)
},
}
)
func init() {
cobra.OnInitialize(initConfig)
rootCmd.SetGlobalNormalizationFunc(wordSepNormalizeFunc)
rootCmd.SetVersionTemplate("{{.Short}}\n{{.Version}}\n")
// 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("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings")
rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable")
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file")
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)")
rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size")
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
// stdio-specific OAuth flags. Provide --oauth-client-id (instead of a token)
// to log in via the browser-based OAuth flow on first use. Works for both
// OAuth Apps and GitHub Apps.
stdioCmd.Flags().String("oauth-client-id", "", "OAuth App or GitHub App client ID, enabling interactive OAuth login when no token is set")
stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret, if the app requires one (it is a public, non-confidential credential for distributed clients)")
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set")
stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker")
// HTTP-specific flags
httpCmd.Flags().Int("port", 8082, "HTTP server port")
httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.")
httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")
httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)")
httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses")
httpCmd.Flags().Bool("trust-proxy-headers", false, "Honor X-Forwarded-Host and X-Forwarded-Proto when constructing OAuth resource metadata URLs. Only enable when the server is deployed behind a trusted proxy that sets these headers. Ignored when --base-url is set.")
// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
_ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools"))
_ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features"))
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
_ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host"))
_ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
_ = viper.BindPFlag("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id"))
_ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret"))
_ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes"))
_ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port"))
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host"))
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))
_ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path"))
_ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge"))
_ = viper.BindPFlag("trust-proxy-headers", httpCmd.Flags().Lookup("trust-proxy-headers"))
// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(httpCmd)
}
func initConfig() {
// Initialize Viper configuration
viper.SetEnvPrefix("github")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.AutomaticEnv()
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
from := []string{"_"}
to := "-"
for _, sep := range from {
name = strings.ReplaceAll(name, sep, to)
}
return pflag.NormalizedName(name)
}