Skip to content
Merged
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
47 changes: 47 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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() {
Expand Down
170 changes: 170 additions & 0 deletions pkg/ssecmd/ssecmd.go
Original file line number Diff line number Diff line change
@@ -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)
}
76 changes: 76 additions & 0 deletions pkg/ssecmd/ssecmd_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading