diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index b39a8b7df..8bd22ff09 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,43 @@ 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.WithDynamicToolsets(viper.GetBool("dynamic_toolsets")), + ssecmd.WithReadOnly(viper.GetBool("read-only")), + ssecmd.WithEnabledToolsets(enabledToolsets), + ssecmd.WithVersion(version), + ) + if err != nil { + return err + } + + return server.Start() + }, + } + stdioCmd = &cobra.Command{ Use: "stdio", Short: "Start stdio server", @@ -85,8 +123,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..efff48270 --- /dev/null +++ b/pkg/ssecmd/ssecmd.go @@ -0,0 +1,170 @@ +// 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" +) + +// Config holds all configuration options for the SSE server +type Config struct { + Token string + Host string + Address string + BasePath 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 *server.MCPServer + 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") + } + + // 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 +} + +// 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 + 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 + } +} + +// 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") + } +}