chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user