diff --git a/Dockerfile b/Dockerfile index a26f19a81..33dadd936 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,9 @@ FROM gcr.io/distroless/base-debian12 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 e4543ecf5..4c1902f9c 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,22 @@ See [Remote Server Documentation](/docs/remote-server.md) on how to pass additio --- +## HTTP Server Mode + +To run the server in HTTP mode, use the `http` command: + +```bash +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 +``` + ## 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) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cad002666..148d3fe8b 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -26,6 +26,34 @@ 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) + } + + 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"), + Port: viper.GetInt("port"), + } + return ghmcp.RunHTTPServer(httpServerConfig) + }, + } + stdioCmd = &cobra.Command{ Use: "stdio", Short: "Start stdio server", @@ -87,6 +115,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/internal/ghmcp/server.go b/internal/ghmcp/server.go index 5fb9582b9..c6c1a36ca 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" @@ -115,12 +116,40 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { } } - getClient := func(_ context.Context) (*gogithub.Client, error) { - return restClient, nil // closing over client - } - - getGQLClient := func(_ context.Context) (*githubv4.Client, error) { - return gqlClient, nil // closing over client + 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 + } + + 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 } getRawClient := func(ctx context.Context) (*raw.Client, error) { @@ -131,7 +160,6 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { return raw.NewClient(client, apiHost.rawURL), nil // closing over client } - // Create default toolsets tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator) err = tsg.EnableToolsets(enabledToolsets) @@ -139,7 +167,6 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { return nil, fmt.Errorf("failed to enable toolsets: %w", err) } - // Register all mcp functionality with the server tsg.RegisterAll(ghServer) if cfg.DynamicToolsets { @@ -150,6 +177,21 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { return ghServer, nil } +type githubTokenKey struct{} + +type HTTPServerConfig struct { + Version string + Host string + Token string + EnabledToolsets []string + DynamicToolsets bool + ReadOnly bool + ExportTranslations bool + EnableCommandLogging bool + LogFilePath string + Port int +} + type StdioServerConfig struct { // Version of the server Version string @@ -182,6 +224,76 @@ type StdioServerConfig struct { LogFilePath string } +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, + }) + 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 @@ -414,3 +526,12 @@ 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 +}