diff --git a/Dockerfile b/Dockerfile index 9d865cb21..974865101 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,9 @@ LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" WORKDIR /server # Copy the binary from the build stage COPY --from=build /bin/github-mcp-server . + +EXPOSE 8080 + # Set the entrypoint to the server binary ENTRYPOINT ["/server/github-mcp-server"] # Default arguments for ENTRYPOINT diff --git a/README.md b/README.md index c0ac851a7..57f5e17e5 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,12 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block ### Configuration -#### Default toolset configuration - -The default configuration is: -- context -- repos -- issues -- pull_requests -- users +#### Toolset configuration See [Remote Server Documentation](docs/remote-server.md) for full details on remote server configuration, toolsets, headers, and advanced usage. This file provides comprehensive instructions and examples for connecting, customizing, and installing the remote GitHub MCP Server in VS Code and other MCP hosts. +When no toolsets are specified, [default toolsets](#default-toolset) are used. + #### Enterprise Cloud with data residency (ghe.com) GitHub Enterprise Cloud can also make use of the remote server. @@ -121,6 +116,43 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to --- +## HTTP Server Mode + +The GitHub MCP Server can run in HTTP mode, allowing it to serve multiple clients concurrently. This is useful for enterprise scenarios where you want to run a single MCP server instance that handles multiple external clients. + +### Starting the HTTP Server + +To run the server in HTTP mode, use the `http` command: + +```bash +github-mcp-server http --port 8080 +``` + +Or with Docker: + +```bash +docker run -p 8080:8080 \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + ghcr.io/github/github-mcp-server http --port 8080 +``` + +### HTTP Server with "Bring Your Own Token" + +When running the server in HTTP mode, clients can provide their own GitHub token with each request using the `Authorization` header: + +```http +Authorization: Bearer +``` + +This allows each client to authenticate with their own credentials, enabling: +- Multi-tenant deployments where each user has their own access level +- Enterprise use cases with centralized MCP server infrastructure +- OAuth-based authentication flows + +If no `Authorization` header is provided, the server will fall back to using the token specified via the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable (if configured). + +--- + ## Local GitHub MCP Server [![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) @@ -329,7 +361,7 @@ The GitHub MCP Server supports enabling or disabling specific groups of function _Toolsets are not limited to Tools. Relevant MCP Resources and Prompts are also included where applicable._ -The Local GitHub MCP Server follows the same [default toolset configuration](#default-toolset-configuration) as the remote version. +When no toolsets are specified, [default toolsets](#default-toolset) are used. #### Specifying Toolsets @@ -359,7 +391,9 @@ docker run -i --rm \ ghcr.io/github/github-mcp-server ``` -### The "all" Toolset +### Special toolsets + +#### "all" toolset The special toolset `all` can be provided to enable all available toolsets regardless of any other configuration: @@ -373,9 +407,25 @@ Or using the environment variable: GITHUB_TOOLSETS="all" ./github-mcp-server ``` +#### "default" toolset +The default toolset `default` is the configuration that gets passed to the server if no toolsets are specified. + +The default configuration is: +- context +- repos +- issues +- pull_requests +- users + +To keep the default configuration and add additional toolsets: + +```bash +GITHUB_TOOLSETS="default,stargazers" ./github-mcp-server +``` + ### Available Toolsets -The following sets of tools are available (all are on by default): +The following sets of tools are available: | Toolset | Description | @@ -400,6 +450,14 @@ The following sets of tools are available (all are on by default): | `users` | GitHub User related tools | +### Additional Toolsets in Remote Github MCP Server + +| Toolset | Description | +| ----------------------- | ------------------------------------------------------------- | +| `copilot` | Copilot related tools (e.g. Copilot Coding Agent) | +| `copilot_spaces` | Copilot Spaces related tools | +| `github_support_docs_search` | Search docs to answer GitHub product and support questions | + ## Tools @@ -1167,7 +1225,7 @@ Possible options:
-Copilot coding agent +Copilot - **create_pull_request_with_copilot** - Perform task with GitHub Copilot coding agent - `owner`: Repository owner. You can guess the owner, but confirm it with the user before proceeding. (string, required) @@ -1189,6 +1247,14 @@ Possible options: - **list_copilot_spaces** - List Copilot Spaces
+
+ +GitHub Support Docs Search + +- **github_support_docs_search** - Retrieve documentation relevant to answer GitHub product and support questions. Support topics include: GitHub Actions Workflows, Authentication, GitHub Support Inquiries, Pull Request Practices, Repository Maintenance, GitHub Pages, GitHub Packages, GitHub Discussions, Copilot Spaces + - `query`: Input from the user about the question they need answered. This is the latest raw unedited user message. You should ALWAYS leave the user message as it is, you should never modify it. (string, required) +
+ ## Dynamic Tool Discovery **Note**: This feature is currently in beta and may not be available in all environments. Please test it out and let us know if you encounter any issues. diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index a0e225293..cb385cff6 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -26,6 +26,39 @@ var ( Version: fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s", version, commit, date), } + httpCmd = &cobra.Command{ + Use: "http", + Short: "Start HTTP server", + Long: `Start a server that communicates via HTTP using the MCP protocol.`, + RunE: func(_ *cobra.Command, _ []string) error { + token := viper.GetString("personal_access_token") + + var enabledToolsets []string + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + + if len(enabledToolsets) == 0 { + enabledToolsets = github.GetDefaultToolsetIDs() + } + + httpServerConfig := ghmcp.HTTPServerConfig{ + Version: version, + Host: viper.GetString("host"), + Token: token, + EnabledToolsets: enabledToolsets, + DynamicToolsets: viper.GetBool("dynamic_toolsets"), + 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"), + Port: viper.GetInt("port"), + } + return ghmcp.RunHTTPServer(httpServerConfig) + }, + } + stdioCmd = &cobra.Command{ Use: "stdio", Short: "Start stdio server", @@ -45,8 +78,9 @@ var ( return fmt.Errorf("failed to unmarshal toolsets: %w", err) } + // No passed toolsets configuration means we enable the default toolset if len(enabledToolsets) == 0 { - enabledToolsets = github.GetDefaultToolsetIDs() + enabledToolsets = []string{github.ToolsetMetadataDefault.ID} } stdioServerConfig := ghmcp.StdioServerConfig{ @@ -94,6 +128,10 @@ func init() { // Add subcommands rootCmd.AddCommand(stdioCmd) + rootCmd.AddCommand(httpCmd) + + httpCmd.Flags().Int("port", 8080, "Port to listen on for HTTP server") + _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) } func initConfig() { diff --git a/docs/installation-guides/install-gemini-cli.md b/docs/installation-guides/install-gemini-cli.md index 21abc8653..1a55c1171 100644 --- a/docs/installation-guides/install-gemini-cli.md +++ b/docs/installation-guides/install-gemini-cli.md @@ -40,7 +40,6 @@ The simplest way is to use GitHub's hosted MCP server: "mcpServers": { "github": { "httpUrl": "https://api.githubcopilot.com/mcp/", - "trust": true, "headers": { "Authorization": "Bearer $GITHUB_PAT" } @@ -122,6 +121,10 @@ To verify that the GitHub MCP server has been configured, start Gemini CLI in yo List my GitHub repositories ``` +## Additional Configuration + +You can find more MCP configuration options for Gemini CLI here: [MCP Configuration Structure](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html#configuration-structure). For example, bypassing tool confirmations or excluding specific tools. + ## Troubleshooting ### Local Server Issues diff --git a/docs/remote-server.md b/docs/remote-server.md index 61815a482..3a4ec444a 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -46,7 +46,9 @@ These toolsets are only available in the remote GitHub MCP Server and are not in | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | -------------------- | --------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Copilot coding agent | Perform task with GitHub Copilot coding agent | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| GitHub support docs search | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-support&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-support&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | ### Optional Headers diff --git a/go.mod b/go.mod index 61b4b971a..162d9c073 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/invopop/jsonschema v0.13.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect diff --git a/go.sum b/go.sum index 184f3005d..ee8590d1a 100644 --- a/go.sum +++ b/go.sum @@ -72,6 +72,8 @@ github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkv github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= @@ -87,6 +89,7 @@ github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -103,6 +106,7 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 2fb2fb19b..7e94383d9 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -12,6 +12,7 @@ import ( "os/signal" "strings" "syscall" + "time" "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/github" @@ -22,6 +23,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" + "github.com/sirupsen/logrus" ) type MCPServerConfig struct { @@ -105,15 +107,10 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { }, } - enabledToolsets := cfg.EnabledToolsets - if cfg.DynamicToolsets { - // filter "all" from the enabled toolsets - enabledToolsets = make([]string, 0, len(cfg.EnabledToolsets)) - for _, toolset := range cfg.EnabledToolsets { - if toolset != "all" { - enabledToolsets = append(enabledToolsets, toolset) - } - } + enabledToolsets, invalidToolsets := cleanToolsets(cfg.EnabledToolsets, cfg.DynamicToolsets) + + if len(invalidToolsets) > 0 { + fmt.Fprintf(os.Stderr, "Invalid toolsets ignored: %s\n", strings.Join(invalidToolsets, ", ")) } // Generate instructions based on enabled toolsets @@ -124,11 +121,39 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { server.WithHooks(hooks), ) - getClient := func(_ context.Context) (*gogithub.Client, error) { + getClient := func(ctx context.Context) (*gogithub.Client, error) { + if tokenVal := ctx.Value(githubTokenKey{}); tokenVal != nil { + if token, ok := tokenVal.(string); ok && token != "" { + client := gogithub.NewClient(nil).WithAuthToken(token) + client.UserAgent = restClient.UserAgent + client.BaseURL = apiHost.baseRESTURL + client.UploadURL = apiHost.uploadURL + return client, nil + } + } return restClient, nil // closing over client } - getGQLClient := func(_ context.Context) (*githubv4.Client, error) { + getGQLClient := func(ctx context.Context) (*githubv4.Client, error) { + if tokenVal := ctx.Value(githubTokenKey{}); tokenVal != nil { + if token, ok := tokenVal.(string); ok && token != "" { + httpClient := &http.Client{ + Transport: &bearerAuthTransport{ + transport: http.DefaultTransport, + token: token, + }, + } + if gqlHTTPClient.Transport != nil { + if uaTransport, ok := gqlHTTPClient.Transport.(*userAgentTransport); ok { + httpClient.Transport = &userAgentTransport{ + transport: httpClient.Transport, + agent: uaTransport.agent, + } + } + } + return githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), httpClient), nil + } + } return gqlClient, nil // closing over client } @@ -159,6 +184,46 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { return ghServer, nil } +type githubTokenKey struct{} + +type HTTPServerConfig struct { + // Version of the server + Version string + + // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) + Host string + + // GitHub Token to authenticate with the GitHub API (optional for HTTP mode with OAuth) + Token string + + // EnabledToolsets is a list of toolsets to enable + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration + EnabledToolsets []string + + // Whether to enable dynamic toolsets + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery + DynamicToolsets bool + + // ReadOnly indicates if we should only register read-only tools + ReadOnly bool + + // ExportTranslations indicates if we should export translations + // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions + ExportTranslations bool + + // EnableCommandLogging indicates if we should log commands + EnableCommandLogging bool + + // Path to the log file if not stderr + LogFilePath string + + // Content window size + ContentWindowSize int + + // Port to listen on for HTTP server + Port int +} + type StdioServerConfig struct { // Version of the server Version string @@ -194,6 +259,77 @@ type StdioServerConfig struct { ContentWindowSize int } +func RunHTTPServer(cfg HTTPServerConfig) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + t, dumpTranslations := translations.TranslationHelper() + + ghServer, err := NewMCPServer(MCPServerConfig{ + Version: cfg.Version, + Host: cfg.Host, + Token: cfg.Token, + EnabledToolsets: cfg.EnabledToolsets, + DynamicToolsets: cfg.DynamicToolsets, + ReadOnly: cfg.ReadOnly, + Translator: t, + ContentWindowSize: cfg.ContentWindowSize, + }) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + + logrusLogger := logrus.New() + if cfg.LogFilePath != "" { + file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + + logrusLogger.SetLevel(logrus.DebugLevel) + logrusLogger.SetOutput(file) + } + + httpOptions := []server.StreamableHTTPOption{ + server.WithLogger(logrusLogger), + server.WithHeartbeatInterval(30 * time.Second), + server.WithHTTPContextFunc(extractTokenFromAuthHeader), + } + + httpServer := server.NewStreamableHTTPServer(ghServer, httpOptions...) + + if cfg.ExportTranslations { + dumpTranslations() + } + + addr := fmt.Sprintf(":%d", cfg.Port) + srv := &http.Server{ + Addr: addr, + Handler: httpServer, + } + + _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on HTTP at %s\n", addr) + + errC := make(chan error, 1) + go func() { + errC <- srv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + logrusLogger.Infof("Shutting down server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + case err := <-errC: + if err != nil && err != http.ErrServerClosed { + return fmt.Errorf("error running server: %w", err) + } + } + + return nil +} + // RunStdioServer is not concurrent safe. func RunStdioServer(cfg StdioServerConfig) error { // Create app context @@ -363,11 +499,30 @@ func newGHESHost(hostname string) (apiHost, error) { return apiHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err) } - uploadURL, err := url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname())) + // Check if subdomain isolation is enabled + // See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation + hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname()) + + var uploadURL *url.URL + if hasSubdomainIsolation { + // With subdomain isolation: https://uploads.hostname/ + uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname())) + } else { + // Without subdomain isolation: https://hostname/api/uploads/ + uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname())) + } if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err) } - rawURL, err := url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname())) + + var rawURL *url.URL + if hasSubdomainIsolation { + // With subdomain isolation: https://raw.hostname/ + rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname())) + } else { + // Without subdomain isolation: https://hostname/raw/ + rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname())) + } if err != nil { return apiHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err) } @@ -380,6 +535,29 @@ func newGHESHost(hostname string) (apiHost, error) { }, nil } +// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled +// by attempting to ping the raw./_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation. +func checkSubdomainIsolation(scheme, hostname string) bool { + subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname) + + client := &http.Client{ + Timeout: 5 * time.Second, + // Don't follow redirects - we just want to check if the endpoint exists + //nolint:revive // parameters are required by http.Client.CheckRedirect signature + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := client.Get(subdomainURL) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusOK +} + // Note that this does not handle ports yet, so development environments are out. func parseAPIHost(s string) (apiHost, error) { if s == "" { @@ -427,3 +605,65 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro req.Header.Set("Authorization", "Bearer "+t.token) return t.transport.RoundTrip(req) } + +func extractTokenFromAuthHeader(ctx context.Context, r *http.Request) context.Context { + authHeader := r.Header.Get("Authorization") + if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + return context.WithValue(ctx, githubTokenKey{}, token) + } + return ctx +} +// cleanToolsets cleans and handles special toolset keywords: +// - Duplicates are removed from the result +// - Removes whitespaces +// - Validates toolset names and returns invalid ones separately +// - "all": Returns ["all"] immediately, ignoring all other toolsets +// - when dynamicToolsets is true, filters out "all" from the enabled toolsets +// - "default": Replaces with the actual default toolset IDs from GetDefaultToolsetIDs() +// Returns: (validToolsets, invalidToolsets) +func cleanToolsets(enabledToolsets []string, dynamicToolsets bool) ([]string, []string) { + seen := make(map[string]bool) + result := make([]string, 0, len(enabledToolsets)) + invalid := make([]string, 0) + validIDs := github.GetValidToolsetIDs() + + // Add non-default toolsets, removing duplicates and trimming whitespace + for _, toolset := range enabledToolsets { + trimmed := strings.TrimSpace(toolset) + if trimmed == "" { + continue + } + if !seen[trimmed] { + seen[trimmed] = true + if trimmed != github.ToolsetMetadataDefault.ID && trimmed != github.ToolsetMetadataAll.ID { + // Validate the toolset name + if validIDs[trimmed] { + result = append(result, trimmed) + } else { + invalid = append(invalid, trimmed) + } + } + } + } + + hasDefault := seen[github.ToolsetMetadataDefault.ID] + hasAll := seen[github.ToolsetMetadataAll.ID] + + // Handle "all" keyword - return early if not in dynamic mode + if hasAll && !dynamicToolsets { + return []string{github.ToolsetMetadataAll.ID}, invalid + } + + // Expand "default" keyword to actual default toolsets + if hasDefault { + for _, defaultToolset := range github.GetDefaultToolsetIDs() { + if !seen[defaultToolset] { + result = append(result, defaultToolset) + seen[defaultToolset] = true + } + } + } + + return result, invalid +} diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go new file mode 100644 index 000000000..c675306f6 --- /dev/null +++ b/internal/ghmcp/server_test.go @@ -0,0 +1,278 @@ +package ghmcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanToolsets(t *testing.T) { + tests := []struct { + name string + input []string + dynamicToolsets bool + expected []string + expectedInvalid []string + }{ + { + name: "empty slice", + input: []string{}, + dynamicToolsets: false, + expected: []string{}, + }, + { + name: "nil input slice", + input: nil, + dynamicToolsets: false, + expected: []string{}, + }, + // all test cases + { + name: "all only", + input: []string{"all"}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + { + name: "all appears multiple times", + input: []string{"all", "actions", "all"}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + { + name: "all with other toolsets", + input: []string{"all", "actions", "gists"}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + { + name: "all with default", + input: []string{"default", "all", "actions"}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + // default test cases + { + name: "default only", + input: []string{"default"}, + dynamicToolsets: false, + expected: []string{ + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "default with additional toolsets", + input: []string{"default", "actions", "gists"}, + dynamicToolsets: false, + expected: []string{ + "actions", + "gists", + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "no default present", + input: []string{"actions", "gists", "notifications"}, + dynamicToolsets: false, + expected: []string{"actions", "gists", "notifications"}, + }, + { + name: "duplicate toolsets without default", + input: []string{"actions", "gists", "actions"}, + dynamicToolsets: false, + expected: []string{"actions", "gists"}, + }, + { + name: "duplicate toolsets with default", + input: []string{"context", "repos", "issues", "pull_requests", "users", "default"}, + dynamicToolsets: false, + expected: []string{ + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "default appears multiple times with different toolsets in between", + input: []string{"default", "actions", "default", "gists", "default"}, + dynamicToolsets: false, + expected: []string{ + "actions", + "gists", + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + // Dynamic toolsets test cases + { + name: "dynamic toolsets - all only should be filtered", + input: []string{"all"}, + dynamicToolsets: true, + expected: []string{}, + }, + { + name: "dynamic toolsets - all with other toolsets", + input: []string{"all", "actions", "gists"}, + dynamicToolsets: true, + expected: []string{"actions", "gists"}, + }, + { + name: "dynamic toolsets - all with default", + input: []string{"all", "default", "actions"}, + dynamicToolsets: true, + expected: []string{ + "actions", + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "dynamic toolsets - no all present", + input: []string{"actions", "gists"}, + dynamicToolsets: true, + expected: []string{"actions", "gists"}, + }, + { + name: "dynamic toolsets - default only", + input: []string{"default"}, + dynamicToolsets: true, + expected: []string{ + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "only special keywords with dynamic mode", + input: []string{"all", "default"}, + dynamicToolsets: true, + expected: []string{ + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "all with default and overlapping default toolsets in dynamic mode", + input: []string{"all", "default", "issues", "repos"}, + dynamicToolsets: true, + expected: []string{ + "issues", + "repos", + "context", + "pull_requests", + "users", + }, + }, + // Whitespace test cases + { + name: "whitespace check - leading and trailing whitespace on regular toolsets", + input: []string{" actions ", " gists ", "notifications"}, + dynamicToolsets: false, + expected: []string{"actions", "gists", "notifications"}, + }, + { + name: "whitespace check - default toolset", + input: []string{" actions ", " default ", "notifications"}, + dynamicToolsets: false, + expected: []string{ + "actions", + "notifications", + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "whitespace check - all toolset", + input: []string{" actions ", " gists ", "notifications", " all "}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + // Invalid toolset test cases + { + name: "mix of valid and invalid toolsets", + input: []string{"actions", "invalid_toolset", "gists", "typo_repo"}, + dynamicToolsets: false, + expected: []string{"actions", "gists"}, + expectedInvalid: []string{"invalid_toolset", "typo_repo"}, + }, + { + name: "invalid with whitespace", + input: []string{" invalid_tool ", " actions ", " typo_gist "}, + dynamicToolsets: false, + expected: []string{"actions"}, + expectedInvalid: []string{"invalid_tool", "typo_gist"}, + }, + { + name: "empty string in toolsets", + input: []string{"", "actions", " ", "gists"}, + dynamicToolsets: false, + expected: []string{"actions", "gists"}, + expectedInvalid: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, invalid := cleanToolsets(tt.input, tt.dynamicToolsets) + + require.Len(t, result, len(tt.expected), "result length should match expected length") + + if tt.expectedInvalid == nil { + tt.expectedInvalid = []string{} + } + require.Len(t, invalid, len(tt.expectedInvalid), "invalid length should match expected invalid length") + + resultMap := make(map[string]bool) + for _, toolset := range result { + resultMap[toolset] = true + } + + expectedMap := make(map[string]bool) + for _, toolset := range tt.expected { + expectedMap[toolset] = true + } + + invalidMap := make(map[string]bool) + for _, toolset := range invalid { + invalidMap[toolset] = true + } + + expectedInvalidMap := make(map[string]bool) + for _, toolset := range tt.expectedInvalid { + expectedInvalidMap[toolset] = true + } + + assert.Equal(t, expectedMap, resultMap, "result should contain all expected toolsets without duplicates") + assert.Equal(t, expectedInvalidMap, invalidMap, "invalid should contain all expected invalid toolsets") + + assert.Len(t, resultMap, len(result), "result should not contain duplicates") + + assert.False(t, resultMap["default"], "result should not contain 'default'") + }) + } +} diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index dfd718f7e..7ffc5fc0c 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -542,6 +542,8 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t // If the path is (most likely) not to be a directory, we will // first try to get the raw content from the GitHub raw content API. + + var rawAPIResponseCode int if path != "" && !strings.HasSuffix(path, "/") { // First, get file info from Contents API to retrieve SHA var fileSHA string @@ -631,8 +633,8 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t return mcp.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)", fileSHA), result), nil } return mcp.NewToolResultResource("successfully downloaded binary file", result), nil - } + rawAPIResponseCode = resp.StatusCode } if rawOpts.SHA != "" { @@ -677,7 +679,7 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t if err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil } - return mcp.NewToolResultText(fmt.Sprintf("Path did not point to a file or directory, but resolved git ref to %s with possible path matches: %s", resolvedRefs, matchingFilesJSON)), nil + return mcp.NewToolResultError(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the raw content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil } return mcp.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a982060de..a0b1690c9 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -23,6 +23,14 @@ type ToolsetMetadata struct { } var ( + ToolsetMetadataAll = ToolsetMetadata{ + ID: "all", + Description: "Special toolset that enables all available toolsets", + } + ToolsetMetadataDefault = ToolsetMetadata{ + ID: "default", + Description: "Special toolset that enables the default toolset configuration. When no toolsets are specified, this is the set that is enabled", + } ToolsetMetadataContext = ToolsetMetadata{ ID: "context", Description: "Tools that provide context about the current user and GitHub context you are operating in", @@ -125,6 +133,18 @@ func AvailableTools() []ToolsetMetadata { } } +// GetValidToolsetIDs returns a map of all valid toolset IDs for quick lookup +func GetValidToolsetIDs() map[string]bool { + validIDs := make(map[string]bool) + for _, tool := range AvailableTools() { + validIDs[tool.ID] = true + } + // Add special keywords + validIDs[ToolsetMetadataAll.ID] = true + validIDs[ToolsetMetadataDefault.ID] = true + return validIDs +} + func GetDefaultToolsetIDs() []string { return []string{ ToolsetMetadataContext.ID, @@ -414,8 +434,14 @@ func GenerateToolsetsHelp() string { availableTools := strings.Join(availableToolsLines, ",\n\t ") toolsetsHelp := fmt.Sprintf("Comma-separated list of tool groups to enable (no spaces).\n"+ - "Default: %s\n"+ - "Available: %s\n", defaultTools, availableTools) + - "To enable all tools, use \"all\"." + "Available: %s\n", availableTools) + + "Special toolset keywords:\n" + + " - all: Enables all available toolsets\n" + + fmt.Sprintf(" - default: Enables the default toolset configuration of:\n\t %s\n", defaultTools) + + "Examples:\n" + + " - --toolsets=actions,gists,notifications\n" + + " - Default + additional: --toolsets=default,actions,gists\n" + + " - All tools: --toolsets=all" + return toolsetsHelp }