Skip to content

Commit 77d37ee

Browse files
author
Federico Cia
committed
Add SSE server mode with /healthz endpoint
1 parent 82c4930 commit 77d37ee

File tree

3 files changed

+205
-2
lines changed

3 files changed

+205
-2
lines changed

Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server"
2626
WORKDIR /server
2727
# Copy the binary from the build stage
2828
COPY --from=build /bin/github-mcp-server .
29+
# Expose the default SSE port
30+
EXPOSE 8080
2931
# Set the entrypoint to the server binary
3032
ENTRYPOINT ["/server/github-mcp-server"]
31-
# Default arguments for ENTRYPOINT
32-
CMD ["stdio"]
33+
# Default arguments for ENTRYPOINT (SSE mode)
34+
CMD ["sse", "--sse-addr=:8080"]

cmd/github-mcp-server/main.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,56 @@ var (
7676
return ghmcp.RunStdioServer(stdioServerConfig)
7777
},
7878
}
79+
80+
sseCmd = &cobra.Command{
81+
Use: "sse",
82+
Short: "Start SSE server",
83+
Long: `Start a server that communicates via Server-Sent Events (SSE) over HTTP.`,
84+
RunE: func(_ *cobra.Command, _ []string) error {
85+
token := viper.GetString("personal_access_token")
86+
if token == "" {
87+
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
88+
}
89+
90+
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
91+
// it's because viper doesn't handle comma-separated values correctly for env
92+
// vars when using GetStringSlice.
93+
// https://github.com/spf13/viper/issues/380
94+
var enabledToolsets []string
95+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
96+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
97+
}
98+
99+
// Parse tools (similar to toolsets)
100+
var enabledTools []string
101+
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
102+
return fmt.Errorf("failed to unmarshal tools: %w", err)
103+
}
104+
105+
// If neither toolset config nor tools config is passed we enable the default toolset
106+
if len(enabledToolsets) == 0 && len(enabledTools) == 0 {
107+
enabledToolsets = []string{github.ToolsetMetadataDefault.ID}
108+
}
109+
110+
ttl := viper.GetDuration("repo-access-cache-ttl")
111+
sseServerConfig := ghmcp.SSEServerConfig{
112+
Version: version,
113+
Host: viper.GetString("host"),
114+
Token: token,
115+
EnabledToolsets: enabledToolsets,
116+
EnabledTools: enabledTools,
117+
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
118+
ReadOnly: viper.GetBool("read-only"),
119+
ExportTranslations: viper.GetBool("export-translations"),
120+
LogFilePath: viper.GetString("log-file"),
121+
ContentWindowSize: viper.GetInt("content-window-size"),
122+
LockdownMode: viper.GetBool("lockdown-mode"),
123+
RepoAccessCacheTTL: &ttl,
124+
SSEAddr: viper.GetString("sse-addr"),
125+
}
126+
return ghmcp.RunSSEServer(sseServerConfig)
127+
},
128+
}
79129
)
80130

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

163+
// Add SSE-specific flags
164+
sseCmd.Flags().String("sse-addr", ":8080", "Address to listen on for SSE connections (e.g., :8080 or localhost:8080)")
165+
_ = viper.BindPFlag("sse-addr", sseCmd.Flags().Lookup("sse-addr"))
166+
113167
// Add subcommands
114168
rootCmd.AddCommand(stdioCmd)
169+
rootCmd.AddCommand(sseCmd)
115170
}
116171

117172
func initConfig() {

internal/ghmcp/server.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,152 @@ func RunStdioServer(cfg StdioServerConfig) error {
323323
return nil
324324
}
325325

326+
// SSEServerConfig holds configuration for the SSE server mode.
327+
type SSEServerConfig struct {
328+
// Version of the server
329+
Version string
330+
331+
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
332+
Host string
333+
334+
// GitHub Token to authenticate with the GitHub API
335+
Token string
336+
337+
// EnabledToolsets is a list of toolsets to enable
338+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
339+
EnabledToolsets []string
340+
341+
// EnabledTools is a list of specific tools to enable (additive to toolsets)
342+
// When specified, these tools are registered in addition to any specified toolset tools
343+
EnabledTools []string
344+
345+
// Whether to enable dynamic toolsets
346+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery
347+
DynamicToolsets bool
348+
349+
// ReadOnly indicates if we should only register read-only tools
350+
ReadOnly bool
351+
352+
// ExportTranslations indicates if we should export translations
353+
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
354+
ExportTranslations bool
355+
356+
// Path to the log file if not stderr
357+
LogFilePath string
358+
359+
// Content window size
360+
ContentWindowSize int
361+
362+
// LockdownMode indicates if we should enable lockdown mode
363+
LockdownMode bool
364+
365+
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
366+
RepoAccessCacheTTL *time.Duration
367+
368+
// SSEAddr is the address to listen on for SSE connections (e.g., ":8080" or "localhost:8080")
369+
SSEAddr string
370+
}
371+
372+
// RunSSEServer starts an HTTP server with SSE transport for the MCP server.
373+
func RunSSEServer(cfg SSEServerConfig) error {
374+
// Create app context
375+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
376+
defer stop()
377+
378+
t, dumpTranslations := translations.TranslationHelper()
379+
380+
var slogHandler slog.Handler
381+
var logOutput io.Writer
382+
if cfg.LogFilePath != "" {
383+
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
384+
if err != nil {
385+
return fmt.Errorf("failed to open log file: %w", err)
386+
}
387+
logOutput = file
388+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
389+
} else {
390+
logOutput = os.Stderr
391+
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
392+
}
393+
logger := slog.New(slogHandler)
394+
logger.Info("starting SSE server", "version", cfg.Version, "host", cfg.Host, "addr", cfg.SSEAddr, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
395+
396+
ghServer, err := NewMCPServer(MCPServerConfig{
397+
Version: cfg.Version,
398+
Host: cfg.Host,
399+
Token: cfg.Token,
400+
EnabledToolsets: cfg.EnabledToolsets,
401+
EnabledTools: cfg.EnabledTools,
402+
DynamicToolsets: cfg.DynamicToolsets,
403+
ReadOnly: cfg.ReadOnly,
404+
Translator: t,
405+
ContentWindowSize: cfg.ContentWindowSize,
406+
LockdownMode: cfg.LockdownMode,
407+
Logger: logger,
408+
RepoAccessTTL: cfg.RepoAccessCacheTTL,
409+
})
410+
if err != nil {
411+
return fmt.Errorf("failed to create MCP server: %w", err)
412+
}
413+
414+
if cfg.ExportTranslations {
415+
// Once server is initialized, all translations are loaded
416+
dumpTranslations()
417+
}
418+
419+
// Create SSE handler using the MCP SDK's SSEHandler
420+
sseHandler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
421+
return ghServer
422+
}, nil)
423+
424+
// Create HTTP mux with health endpoint and SSE handler
425+
mux := http.NewServeMux()
426+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
427+
w.Header().Set("Content-Type", "application/json")
428+
w.WriteHeader(http.StatusOK)
429+
_, _ = w.Write([]byte(`{"status":"ok"}`))
430+
})
431+
mux.Handle("/", sseHandler)
432+
433+
// Create HTTP server
434+
httpServer := &http.Server{
435+
Addr: cfg.SSEAddr,
436+
Handler: mux,
437+
}
438+
439+
// Start HTTP server in a goroutine
440+
errC := make(chan error, 1)
441+
go func() {
442+
logger.Info("HTTP server listening", "addr", cfg.SSEAddr)
443+
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
444+
errC <- err
445+
}
446+
}()
447+
448+
// Output startup message
449+
_, _ = fmt.Fprintf(os.Stderr, "GitHub MCP Server running on SSE at %s\n", cfg.SSEAddr)
450+
451+
// Wait for shutdown signal
452+
select {
453+
case <-ctx.Done():
454+
logger.Info("shutting down SSE server", "signal", "context done")
455+
// Gracefully shutdown the HTTP server
456+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
457+
defer cancel()
458+
if err := httpServer.Shutdown(shutdownCtx); err != nil {
459+
logger.Error("error shutting down HTTP server", "error", err)
460+
return fmt.Errorf("error shutting down HTTP server: %w", err)
461+
}
462+
case err := <-errC:
463+
if err != nil {
464+
logger.Error("error running SSE server", "error", err)
465+
return fmt.Errorf("error running SSE server: %w", err)
466+
}
467+
}
468+
469+
return nil
470+
}
471+
326472
type apiHost struct {
327473
baseRESTURL *url.URL
328474
graphqlURL *url.URL

0 commit comments

Comments
 (0)