From 3978eb0e9c6be8400be6902ae252fabff885b7e6 Mon Sep 17 00:00:00 2001 From: juleve Date: Mon, 30 Jun 2025 10:26:03 +0700 Subject: [PATCH 1/4] feat: add sse server --- cmd/github-mcp-server/main.go | 49 +++++++++ pkg/ssecmd/ssecmd.go | 186 ++++++++++++++++++++++++++++++++++ pkg/ssecmd/ssecmd_test.go | 76 ++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 pkg/ssecmd/ssecmd.go create mode 100644 pkg/ssecmd/ssecmd_test.go diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index b39a8b7df..2f5c65f8e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -8,6 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/ssecmd" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -26,6 +27,45 @@ var ( Version: fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s", version, commit, date), } + // SSE Command - using the flexible ssecmd package + sseCmd = &cobra.Command{ + Use: "sse", + Short: "Start SSE server", + Long: `Start a Server-Sent Events (SSE) server that allows real-time streaming of events to clients over HTTP.`, + RunE: func(_ *cobra.Command, _ []string) error { + // Get token and validate + token := viper.GetString("personal_access_token") + if token == "" { + return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") + } + + // Parse toolsets + var enabledToolsets []string + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + + // Create server with options using the functional options pattern + server, err := ssecmd.CreateServerWithOptions( + ssecmd.WithToken(token), + ssecmd.WithHost(viper.GetString("host")), + ssecmd.WithAddress(viper.GetString("address")), + ssecmd.WithBasePath(viper.GetString("base-path")), + ssecmd.WithLogFilePath(viper.GetString("log-file")), + ssecmd.WithDynamicToolsets(viper.GetBool("dynamic_toolsets")), + ssecmd.WithReadOnly(viper.GetBool("read-only")), + ssecmd.WithEnabledToolsets(enabledToolsets), + ssecmd.WithVersion(version), + ) + if err != nil { + return err + } + + // Start the server + return server.Start() + }, + } + stdioCmd = &cobra.Command{ Use: "stdio", Short: "Start stdio server", @@ -85,8 +125,17 @@ func init() { _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) + // Setup flags for SSE command + sseCmd.Flags().String("address", "localhost:8080", "Address to listen on for SSE server") + sseCmd.Flags().String("base-path", "", "Base path for SSE server URLs") + + // Bind SSE flags to viper + _ = viper.BindPFlag("address", sseCmd.Flags().Lookup("address")) + _ = viper.BindPFlag("base-path", sseCmd.Flags().Lookup("base-path")) + // Add subcommands rootCmd.AddCommand(stdioCmd) + rootCmd.AddCommand(sseCmd) } func initConfig() { diff --git a/pkg/ssecmd/ssecmd.go b/pkg/ssecmd/ssecmd.go new file mode 100644 index 000000000..d28e0fbf3 --- /dev/null +++ b/pkg/ssecmd/ssecmd.go @@ -0,0 +1,186 @@ +// Package ssecmd provides functionality for creating and running an SSE server +// without any dependencies on specific CLI or configuration systems. +package ssecmd + +import ( + "errors" + "fmt" + "os" + + "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/mark3labs/mcp-go/server" + "github.com/sirupsen/logrus" +) + +// Config holds all configuration options for the SSE server +type Config struct { + Token string + Host string + Address string + BasePath string + LogFilePath string + EnabledToolsets []string + DynamicToolsets bool + ReadOnly bool + Version string +} + +// DefaultConfig creates a basic Config with sensible defaults +func DefaultConfig() Config { + return Config{ + Address: "localhost:8080", + BasePath: "", + ReadOnly: false, + } +} + +// Server represents an SSE server that can be started and stopped +type Server struct { + config Config + mcpServer interface{} // Using interface{} since we don't need to access it directly + sseServer *server.SSEServer +} + +// NewServer creates a new SSE server with the provided configuration +func NewServer(config Config) (*Server, error) { + if config.Token == "" { + return nil, errors.New("GitHub personal access token not set") + } + + // Configure logging + if config.LogFilePath != "" { + file, err := os.OpenFile(config.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + logger := logrus.New() + logger.SetOutput(file) + logger.SetLevel(logrus.DebugLevel) + logrus.SetOutput(file) // Set global logger output as well + } + + // Create a translation helper function + translator := func(key, defaultValue string) string { + return defaultValue // Simple implementation just returning the default value + } + + // Create the MCP server instance with GitHub tools + mcpServer, err := ghmcp.NewMCPServer(ghmcp.MCPServerConfig{ + Version: config.Version, + Host: config.Host, + Token: config.Token, + EnabledToolsets: config.EnabledToolsets, + DynamicToolsets: config.DynamicToolsets, + ReadOnly: config.ReadOnly, + Translator: translator, + }) + if err != nil { + return nil, fmt.Errorf("failed to create MCP server: %w", err) + } + + // Create SSE server using built-in functionality from mark3labs/mcp-go + sseServer := server.NewSSEServer(mcpServer, + server.WithStaticBasePath(config.BasePath), + ) + + return &Server{ + config: config, + mcpServer: mcpServer, + sseServer: sseServer, + }, nil +} + +// Start starts the SSE server +func (s *Server) Start() error { + // Print server info + fmt.Fprintf(os.Stderr, "GitHub MCP Server running in SSE mode on %s with base path %s\n", + s.config.Address, s.config.BasePath) + + // Start the server + return s.sseServer.Start(s.config.Address) +} + +// RunSSEServer is a convenience function that creates and starts an SSE server in one call +// This is provided for backward compatibility and simple use cases +func RunSSEServer(config Config) error { + server, err := NewServer(config) + if err != nil { + return err + } + return server.Start() +} + +// ServerOption represents an option for configuring an SSE server +type ServerOption func(*Config) + +// WithAddress sets the address for the SSE server +func WithAddress(address string) ServerOption { + return func(c *Config) { + c.Address = address + } +} + +// WithBasePath sets the base path for SSE server URLs +func WithBasePath(basePath string) ServerOption { + return func(c *Config) { + c.BasePath = basePath + } +} + +// WithLogFilePath sets the log file path for the SSE server +func WithLogFilePath(logFilePath string) ServerOption { + return func(c *Config) { + c.LogFilePath = logFilePath + } +} + +// WithReadOnly sets the read-only mode for the SSE server +func WithReadOnly(readOnly bool) ServerOption { + return func(c *Config) { + c.ReadOnly = readOnly + } +} + +// WithDynamicToolsets sets whether to use dynamic toolsets for the SSE server +func WithDynamicToolsets(dynamicToolsets bool) ServerOption { + return func(c *Config) { + c.DynamicToolsets = dynamicToolsets + } +} + +// WithEnabledToolsets sets the enabled toolsets for the SSE server +func WithEnabledToolsets(enabledToolsets []string) ServerOption { + return func(c *Config) { + c.EnabledToolsets = enabledToolsets + } +} + +// WithHost sets the GitHub host for the SSE server +func WithHost(host string) ServerOption { + return func(c *Config) { + c.Host = host + } +} + +// WithToken sets the GitHub token for the SSE server +func WithToken(token string) ServerOption { + return func(c *Config) { + c.Token = token + } +} + +// WithVersion sets the version for the SSE server +func WithVersion(version string) ServerOption { + return func(c *Config) { + c.Version = version + } +} + +// CreateServerWithOptions creates a new SSE server with the provided options +func CreateServerWithOptions(options ...ServerOption) (*Server, error) { + config := DefaultConfig() + for _, option := range options { + option(&config) + } + return NewServer(config) +} diff --git a/pkg/ssecmd/ssecmd_test.go b/pkg/ssecmd/ssecmd_test.go new file mode 100644 index 000000000..54621207e --- /dev/null +++ b/pkg/ssecmd/ssecmd_test.go @@ -0,0 +1,76 @@ +package ssecmd + +import ( + "testing" +) + +func TestNewServer(t *testing.T) { + // Test that a new server can be created with a valid config + config := Config{ + Token: "test-token", + Version: "test-version", + Address: "localhost:8080", + } + + server, err := NewServer(config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if server == nil { + t.Fatal("Expected non-nil server") + } + + // Test that server creation fails with an empty token + invalidConfig := Config{ + // No token set + Address: "localhost:8080", + } + + _, err = NewServer(invalidConfig) + if err == nil { + t.Fatal("Expected error for missing token, got nil") + } +} + +func TestCreateServerWithOptions(t *testing.T) { + // Test that a server can be created with functional options + server, err := CreateServerWithOptions( + WithToken("test-token"), + WithAddress("localhost:9090"), + WithVersion("1.0.0"), + ) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if server == nil { + t.Fatal("Expected non-nil server") + } + + if server.config.Address != "localhost:9090" { + t.Errorf("Expected address to be 'localhost:9090', got %s", server.config.Address) + } + + if server.config.Version != "1.0.0" { + t.Errorf("Expected version to be '1.0.0', got %s", server.config.Version) + } +} + +func TestDefaultConfig(t *testing.T) { + // Test default configuration values + config := DefaultConfig() + + if config.Address != "localhost:8080" { + t.Errorf("Expected default address to be 'localhost:8080', got %s", config.Address) + } + + if config.BasePath != "" { + t.Errorf("Expected default base path to be empty, got %s", config.BasePath) + } + + if config.ReadOnly != false { + t.Error("Expected default ReadOnly to be false") + } +} From df0964dc1b96190957a14d91efe599b5c3e36453 Mon Sep 17 00:00:00 2001 From: juleve Date: Mon, 30 Jun 2025 11:20:15 +0700 Subject: [PATCH 2/4] feat: integrate with mux --- cmd/github-mcp-server/main.go | 14 +++++++++++--- pkg/ssecmd/ssecmd.go | 9 +++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 2f5c65f8e..933cc9d1e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -3,12 +3,14 @@ package main import ( "errors" "fmt" + "net/http" "os" "strings" "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/ssecmd" + mcpserv "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -61,8 +63,14 @@ var ( return err } - // Start the server - return server.Start() + sseServer := mcpserv.NewSSEServer( + server.GetMcpServer(), + mcpserv.WithStaticBasePath(viper.GetString("base-path")), + ) + mux := http.NewServeMux() + mux.Handle("/v1/mcp/github/sse", sseServer.SSEHandler()) + mux.Handle("/v1/mcp/github/message", sseServer.MessageHandler()) + return http.ListenAndServe(viper.GetString("address"), mux) }, } @@ -128,7 +136,7 @@ func init() { // Setup flags for SSE command sseCmd.Flags().String("address", "localhost:8080", "Address to listen on for SSE server") sseCmd.Flags().String("base-path", "", "Base path for SSE server URLs") - + // Bind SSE flags to viper _ = viper.BindPFlag("address", sseCmd.Flags().Lookup("address")) _ = viper.BindPFlag("base-path", sseCmd.Flags().Lookup("base-path")) diff --git a/pkg/ssecmd/ssecmd.go b/pkg/ssecmd/ssecmd.go index d28e0fbf3..c136880f3 100644 --- a/pkg/ssecmd/ssecmd.go +++ b/pkg/ssecmd/ssecmd.go @@ -37,7 +37,7 @@ func DefaultConfig() Config { // Server represents an SSE server that can be started and stopped type Server struct { config Config - mcpServer interface{} // Using interface{} since we don't need to access it directly + mcpServer *server.MCPServer sseServer *server.SSEServer } @@ -84,12 +84,17 @@ func NewServer(config Config) (*Server, error) { ) return &Server{ - config: config, + config: config, mcpServer: mcpServer, sseServer: sseServer, }, nil } +// GetMcpServer get mcp server function +func (s *Server) GetMcpServer() *server.MCPServer { + return s.mcpServer +} + // Start starts the SSE server func (s *Server) Start() error { // Print server info From c23a456d841cafeb85ae284d164e600a6288a83b Mon Sep 17 00:00:00 2001 From: juleve Date: Mon, 30 Jun 2025 11:31:35 +0700 Subject: [PATCH 3/4] fix --- cmd/github-mcp-server/main.go | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 933cc9d1e..25b994eaa 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -3,14 +3,12 @@ package main import ( "errors" "fmt" - "net/http" "os" "strings" "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/ssecmd" - mcpserv "github.com/mark3labs/mcp-go/server" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -63,14 +61,7 @@ var ( return err } - sseServer := mcpserv.NewSSEServer( - server.GetMcpServer(), - mcpserv.WithStaticBasePath(viper.GetString("base-path")), - ) - mux := http.NewServeMux() - mux.Handle("/v1/mcp/github/sse", sseServer.SSEHandler()) - mux.Handle("/v1/mcp/github/message", sseServer.MessageHandler()) - return http.ListenAndServe(viper.GetString("address"), mux) + return server.Start() }, } From 0fa4f4b2fc9b09ddd37f96767deb4995a20a548a Mon Sep 17 00:00:00 2001 From: juleve Date: Tue, 1 Jul 2025 08:13:28 +0700 Subject: [PATCH 4/4] feat: remove unused log file path config --- cmd/github-mcp-server/main.go | 1 - pkg/ssecmd/ssecmd.go | 21 --------------------- 2 files changed, 22 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 25b994eaa..8bd22ff09 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -51,7 +51,6 @@ var ( ssecmd.WithHost(viper.GetString("host")), ssecmd.WithAddress(viper.GetString("address")), ssecmd.WithBasePath(viper.GetString("base-path")), - ssecmd.WithLogFilePath(viper.GetString("log-file")), ssecmd.WithDynamicToolsets(viper.GetBool("dynamic_toolsets")), ssecmd.WithReadOnly(viper.GetBool("read-only")), ssecmd.WithEnabledToolsets(enabledToolsets), diff --git a/pkg/ssecmd/ssecmd.go b/pkg/ssecmd/ssecmd.go index c136880f3..efff48270 100644 --- a/pkg/ssecmd/ssecmd.go +++ b/pkg/ssecmd/ssecmd.go @@ -9,7 +9,6 @@ import ( "github.com/github/github-mcp-server/internal/ghmcp" "github.com/mark3labs/mcp-go/server" - "github.com/sirupsen/logrus" ) // Config holds all configuration options for the SSE server @@ -18,7 +17,6 @@ type Config struct { Host string Address string BasePath string - LogFilePath string EnabledToolsets []string DynamicToolsets bool ReadOnly bool @@ -47,18 +45,6 @@ func NewServer(config Config) (*Server, error) { return nil, errors.New("GitHub personal access token not set") } - // Configure logging - if config.LogFilePath != "" { - file, err := os.OpenFile(config.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) - if err != nil { - return nil, fmt.Errorf("failed to open log file: %w", err) - } - logger := logrus.New() - logger.SetOutput(file) - logger.SetLevel(logrus.DebugLevel) - logrus.SetOutput(file) // Set global logger output as well - } - // Create a translation helper function translator := func(key, defaultValue string) string { return defaultValue // Simple implementation just returning the default value @@ -132,13 +118,6 @@ func WithBasePath(basePath string) ServerOption { } } -// WithLogFilePath sets the log file path for the SSE server -func WithLogFilePath(logFilePath string) ServerOption { - return func(c *Config) { - c.LogFilePath = logFilePath - } -} - // WithReadOnly sets the read-only mode for the SSE server func WithReadOnly(readOnly bool) ServerOption { return func(c *Config) {