Skip to content

Add HTTP+SSE server command #47

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*}`
Expand All @@ -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*}`
Expand All @@ -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*}`
Expand All @@ -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*}`
Expand All @@ -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*}`
Expand Down Expand Up @@ -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:<port>` 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.
Expand Down Expand Up @@ -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}"
},
}
}
}
}
Expand Down
126 changes: 125 additions & 1 deletion cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
stdlog "log"
"net/http"
"os"
"os/signal"
"syscall"
Expand Down Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down