From aa9623c2cab3e18d32bf6750d2ef44eee2855745 Mon Sep 17 00:00:00 2001 From: "Ian C. Anderson" Date: Fri, 28 Mar 2025 08:35:42 -0400 Subject: [PATCH] Add HTTP+SSE server command with graceful shutdown and logging middleware I'm not sure we want to merge this, but this allowed me to try dotcom chat action as an MCP host, using `mark3labs/mcp-go` in copilot-api, connecting to a localhost version of github-mcp-server via HTTP/SSE. ``` export GITHUB_PERSONAL_ACCESS_TOKEN= go run cmd/github-mcp-server/main.go http --gh-host http://api.github.localhost --port 4567 ``` --- README.md | 41 +++++++++-- cmd/github-mcp-server/main.go | 126 +++++++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 7 deletions(-) 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)