diff --git a/README.md b/README.md index 10e500373b..8ab0b92c66 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. ### Repository Content -- **Get Repository Content** +- **Get Repository Content** Retrieves the content of a repository at a specific path. - **Template**: `repo://{owner}/{repo}/contents{/path*}` @@ -246,7 +246,7 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. - `repo`: Repository name (string, required) - `path`: File or directory path (string, optional) -- **Get Repository Content for a Specific Branch** +- **Get Repository Content for a Specific Branch** Retrieves the content of a repository at a specific path for a given branch. - **Template**: `repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}` @@ -256,7 +256,7 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. - `branch`: Branch name (string, required) - `path`: File or directory path (string, optional) -- **Get Repository Content for a Specific Commit** +- **Get Repository Content for a Specific Commit** Retrieves the content of a repository at a specific path for a given commit. - **Template**: `repo://{owner}/{repo}/sha/{sha}/contents{/path*}` @@ -266,7 +266,7 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. - `sha`: Commit SHA (string, required) - `path`: File or directory path (string, optional) -- **Get Repository Content for a Specific Tag** +- **Get Repository Content for a Specific Tag** Retrieves the content of a repository at a specific path for a given tag. - **Template**: `repo://{owner}/{repo}/refs/tags/{tag}/contents{/path*}` @@ -276,7 +276,7 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. - `tag`: Tag name (string, required) - `path`: File or directory path (string, optional) -- **Get Repository Content for a Specific Pull Request** +- **Get Repository Content for a Specific Pull Request** Retrieves the content of a repository at a specific path for a given pull request. - **Template**: `repo://{owner}/{repo}/refs/pull/{pr_number}/head/contents{/path*}` @@ -319,6 +319,35 @@ GitHub MCP Server running on stdio ``` +## HTTP+SSE server + +> [!WARNING] +> This version of the server works with the [2024-11-05 MCP Spec](https://spec.modelcontextprotocol.io/specification/2024-11-05/), which requires a stateful connection for SSE. We plan to add support for a stateless mode in the future, as allowed by the [2025-03-26 MCP Spec](https://spec.modelcontextprotocol.io/specification/2025-03-26/changelog). + +Run the server in HTTP mode with Server-Sent Events (SSE): + +```sh +go run cmd/github-mcp-server/main.go http +``` + +The server will start on port 8080 by default. You can specify a different port using the `--port` flag: + +```sh +go run cmd/github-mcp-server/main.go http --port 3000 +``` + +The server accepts connections at `http://localhost:` and communicates using Server-Sent Events (SSE). + +Like the stdio server, ensure your GitHub Personal Access Token is set in the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable before starting the server. + +You can use the same flags as the stdio server: + +- `--read-only`: Restrict the server to read-only operations +- `--log-file`: Path to log file +- `--enable-command-logging`: Enable logging of all command requests and responses +- `--export-translations`: Save translations to a JSON file +- `--gh-host`: Specify the GitHub hostname (for GitHub Enterprise, localhost etc.) + ## i18n / Overriding descriptions The descriptions of the tools can be overridden by creating a github-mcp-server.json file in the same directory as the binary. @@ -376,7 +405,7 @@ Run **Preferences: Open User Settings (JSON)**, and create or append to the `mcp "args": ["stdio"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:githubpat}" - }, + } } } } diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 5e8c520e49..3049dc2822 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -5,6 +5,7 @@ import ( "fmt" "io" stdlog "log" + "net/http" "os" "os/signal" "syscall" @@ -44,6 +45,26 @@ var ( } }, } + + httpCmd = &cobra.Command{ + Use: "http", + Short: "Start HTTP server", + Long: `Start a server that communicates via HTTP using Server-Sent Events (SSE).`, + Run: func(cmd *cobra.Command, args []string) { + logFile := viper.GetString("log-file") + readOnly := viper.GetBool("read-only") + exportTranslations := viper.GetBool("export-translations") + port := viper.GetString("port") + logger, err := initLogger(logFile) + if err != nil { + stdlog.Fatal("Failed to initialize logger:", err) + } + logCommands := viper.GetBool("enable-command-logging") + if err := runHTTPServer(readOnly, logger, logCommands, exportTranslations, port); err != nil { + stdlog.Fatal("failed to run http server:", err) + } + }, + } ) func init() { @@ -56,15 +77,20 @@ func init() { 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.)") - // Bind flag to viper + // Add HTTP specific flags + httpCmd.Flags().String("port", "8080", "Port for the HTTP server") + + // Bind flags to viper 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("gh-host", rootCmd.PersistentFlags().Lookup("gh-host")) + viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) // Add subcommands rootCmd.AddCommand(stdioCmd) + rootCmd.AddCommand(httpCmd) } func initConfig() { @@ -159,6 +185,104 @@ func runStdioServer(readOnly bool, logger *log.Logger, logCommands bool, exportT return nil } +func runHTTPServer(readOnly bool, logger *log.Logger, logCommands bool, exportTranslations bool, port string) error { + // Create app context + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Create GH client + token := os.Getenv("GITHUB_PERSONAL_ACCESS_TOKEN") + if token == "" { + logger.Fatal("GITHUB_PERSONAL_ACCESS_TOKEN not set") + } + ghClient := gogithub.NewClient(nil).WithAuthToken(token) + + // Check GH_HOST env var first, then fall back to viper config + host := os.Getenv("GH_HOST") + if host == "" { + host = viper.GetString("gh-host") + } + + if host != "" { + var err error + ghClient, err = ghClient.WithEnterpriseURLs(host, host) + if err != nil { + return fmt.Errorf("failed to create GitHub client with host: %w", err) + } + } + + t, dumpTranslations := translations.TranslationHelper() + + // Create GitHub server + ghServer := github.NewServer(ghClient, readOnly, t) + + if exportTranslations { + // Once server is initialized, all translations are loaded + dumpTranslations() + } + + // Create SSE server + sseServer := server.NewSSEServer(ghServer) + + // Start listening for messages + errC := make(chan error, 1) + go func() { + // Configure and start HTTP server + mux := http.NewServeMux() + + // Add SSE handler with logging middleware if enabled + var handler http.Handler = sseServer + if logCommands { + handler = loggingMiddleware(handler, logger) + } + mux.Handle("/", handler) + + srv := &http.Server{ + Addr: ":" + port, + Handler: mux, + } + + // Graceful shutdown + go func() { + <-ctx.Done() + if err := srv.Shutdown(context.Background()); err != nil { + logger.Errorf("HTTP server shutdown error: %v", err) + } + }() + + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + errC <- err + } + }() + + // Output github-mcp-server string + _, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on http://localhost:%s\n", port) + + // Wait for shutdown signal + select { + case <-ctx.Done(): + logger.Infof("shutting down server...") + case err := <-errC: + if err != nil { + return fmt.Errorf("error running server: %w", err) + } + } + + return nil +} + +// loggingMiddleware wraps an http.Handler and logs requests +func loggingMiddleware(next http.Handler, logger *log.Logger) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger.WithFields(log.Fields{ + "method": r.Method, + "path": r.URL.Path, + }).Info("Received request") + + next.ServeHTTP(w, r) + }) +} + func main() { if err := rootCmd.Execute(); err != nil { fmt.Println(err)