Skip to content
Open
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
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +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 the default SSE port
EXPOSE 8080
# Set the entrypoint to the server binary
ENTRYPOINT ["/server/github-mcp-server"]
# Default arguments for ENTRYPOINT
CMD ["stdio"]
# Default arguments for ENTRYPOINT (SSE mode)
CMD ["sse", "--sse-addr=:8080"]
55 changes: 55 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,56 @@ var (
return ghmcp.RunStdioServer(stdioServerConfig)
},
}

sseCmd = &cobra.Command{
Use: "sse",
Short: "Start SSE server",
Long: `Start a server that communicates via Server-Sent Events (SSE) over HTTP.`,
RunE: func(_ *cobra.Command, _ []string) error {
token := viper.GetString("personal_access_token")
if token == "" {
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
}

// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
// it's because viper doesn't handle comma-separated values correctly for env
// vars when using GetStringSlice.
// https://github.com/spf13/viper/issues/380
var enabledToolsets []string
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}

// Parse tools (similar to toolsets)
var enabledTools []string
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
return fmt.Errorf("failed to unmarshal tools: %w", err)
}

// If neither toolset config nor tools config is passed we enable the default toolset
if len(enabledToolsets) == 0 && len(enabledTools) == 0 {
enabledToolsets = []string{github.ToolsetMetadataDefault.ID}
}

ttl := viper.GetDuration("repo-access-cache-ttl")
sseServerConfig := ghmcp.SSEServerConfig{
Version: version,
Host: viper.GetString("host"),
Token: token,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
ReadOnly: viper.GetBool("read-only"),
ExportTranslations: viper.GetBool("export-translations"),
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
RepoAccessCacheTTL: &ttl,
SSEAddr: viper.GetString("sse-addr"),
}
return ghmcp.RunSSEServer(sseServerConfig)
},
}
)

func init() {
Expand Down Expand Up @@ -110,8 +160,13 @@ func init() {
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))

// Add SSE-specific flags
sseCmd.Flags().String("sse-addr", ":8080", "Address to listen on for SSE connections (e.g., :8080 or localhost:8080)")
_ = viper.BindPFlag("sse-addr", sseCmd.Flags().Lookup("sse-addr"))

// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(sseCmd)
}

func initConfig() {
Expand Down
146 changes: 146 additions & 0 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,152 @@ func RunStdioServer(cfg StdioServerConfig) error {
return nil
}

// SSEServerConfig holds configuration for the SSE server mode.
type SSEServerConfig 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
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

// EnabledTools is a list of specific tools to enable (additive to toolsets)
// When specified, these tools are registered in addition to any specified toolset tools
EnabledTools []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

// Path to the log file if not stderr
LogFilePath string

// Content window size
ContentWindowSize int

// LockdownMode indicates if we should enable lockdown mode
LockdownMode bool

// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration

// SSEAddr is the address to listen on for SSE connections (e.g., ":8080" or "localhost:8080")
SSEAddr string
}

// RunSSEServer starts an HTTP server with SSE transport for the MCP server.
func RunSSEServer(cfg SSEServerConfig) error {
// Create app context
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

t, dumpTranslations := translations.TranslationHelper()

var slogHandler slog.Handler
var logOutput io.Writer
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)
}
logOutput = file
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
logOutput = os.Stderr
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
}
logger := slog.New(slogHandler)
logger.Info("starting SSE server", "version", cfg.Version, "host", cfg.Host, "addr", cfg.SSEAddr, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)

ghServer, err := NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
EnabledTools: cfg.EnabledTools,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}

if cfg.ExportTranslations {
// Once server is initialized, all translations are loaded
dumpTranslations()
}

// Create SSE handler using the MCP SDK's SSEHandler
sseHandler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
return ghServer
}, nil)

// Create HTTP mux with health endpoint and SSE handler
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
mux.Handle("/", sseHandler)

// Create HTTP server
httpServer := &http.Server{
Addr: cfg.SSEAddr,
Handler: mux,
}

// Start HTTP server in a goroutine
errC := make(chan error, 1)
go func() {
logger.Info("HTTP server listening", "addr", cfg.SSEAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errC <- err
}
}()

// Output startup message
_, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on SSE at %s\n", cfg.SSEAddr)

// Wait for shutdown signal
select {
case <-ctx.Done():
logger.Info("shutting down SSE server", "signal", "context done")
// Gracefully shutdown the HTTP server
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
logger.Error("error shutting down HTTP server", "error", err)
return fmt.Errorf("error shutting down HTTP server: %w", err)
}
case err := <-errC:
if err != nil {
logger.Error("error running SSE server", "error", err)
return fmt.Errorf("error running SSE server: %w", err)
}
}

return nil
}

type apiHost struct {
baseRESTURL *url.URL
graphqlURL *url.URL
Expand Down