From a54d6bd832fcb37f56319e92a6acdce410b69f48 Mon Sep 17 00:00:00 2001 From: pyama Date: Tue, 9 Jun 2026 23:33:50 +0900 Subject: [PATCH 1/2] feat(http): support custom listen address Add --listen-address flag (env: GITHUB_LISTEN_ADDRESS) so the HTTP server can bind to a specific host:port instead of always listening on all interfaces. When unset the server keeps the existing :PORT behavior. --- cmd/github-mcp-server/main.go | 5 ++++- pkg/http/server.go | 19 ++++++++++++++-- pkg/http/server_test.go | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 558fdb9980..74e0c5cdcb 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -138,6 +138,7 @@ var ( Version: version, Host: viper.GetString("host"), Port: viper.GetInt("port"), + ListenAddress: viper.GetString("listen-address"), BaseURL: viper.GetString("base-url"), ResourcePath: viper.GetString("base-path"), ExportTranslations: viper.GetBool("export-translations"), @@ -183,7 +184,8 @@ func init() { rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") // HTTP-specific flags - httpCmd.Flags().Int("port", 8082, "HTTP server port") + httpCmd.Flags().Int("port", 8082, "HTTP server port (ignored when --listen-address is set)") + httpCmd.Flags().String("listen-address", "", "HTTP server listen address (host:port). Overrides --port when set (e.g. 127.0.0.1:8082)") httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)") httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)") httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") @@ -204,6 +206,7 @@ func init() { _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) + _ = viper.BindPFlag("listen-address", httpCmd.Flags().Lookup("listen-address")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path")) _ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge")) diff --git a/pkg/http/server.go b/pkg/http/server.go index 3c9d7679e4..6dd27af299 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -32,9 +32,14 @@ type ServerConfig struct { // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string - // Port to listen on (default: 8082) + // Port to listen on (default: 8082). + // Ignored when ListenAddress is set. Port int + // ListenAddress is the full listen address (host:port) for the HTTP server. + // When set it takes precedence over Port; otherwise the server listens on ":". + ListenAddress string + // BaseURL is the publicly accessible URL of this server for OAuth resource metadata. // If not set, the server will derive the URL from incoming request headers. BaseURL string @@ -192,7 +197,7 @@ func RunHTTPServer(cfg ServerConfig) error { }) logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL) - addr := fmt.Sprintf(":%d", cfg.Port) + addr := resolveListenAddress(cfg.ListenAddress, cfg.Port) httpSvr := http.Server{ Addr: addr, Handler: r, @@ -223,6 +228,16 @@ func RunHTTPServer(cfg ServerConfig) error { return nil } +// resolveListenAddress returns the address string passed to http.Server. +// If listenAddress is non-empty it wins; otherwise the server binds to all +// interfaces on the given port. +func resolveListenAddress(listenAddress string, port int) string { + if listenAddress != "" { + return listenAddress + } + return fmt.Sprintf(":%d", port) +} + func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { // Build inventory with all tools to extract scope information inv, err := inventory.NewBuilder(). diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index 5458a6b395..50b2265bbd 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -131,6 +131,47 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { } } +func TestResolveListenAddress(t *testing.T) { + tests := []struct { + name string + listenAddress string + port int + want string + }{ + { + name: "empty address falls back to :port", + listenAddress: "", + port: 8082, + want: ":8082", + }, + { + name: "explicit host:port wins over port", + listenAddress: "127.0.0.1:9090", + port: 8082, + want: "127.0.0.1:9090", + }, + { + name: "explicit :port form is preserved", + listenAddress: ":9090", + port: 8082, + want: ":9090", + }, + { + name: "ipv6 address with port is preserved", + listenAddress: "[::1]:9090", + port: 8082, + want: "[::1]:9090", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveListenAddress(tt.listenAddress, tt.port) + assert.Equal(t, tt.want, got) + }) + } +} + func TestHeaderAllowedFeatureFlagsMatchesAllowed(t *testing.T) { // Ensure HeaderAllowedFeatureFlags delegates to AllowedFeatureFlags allowed := github.HeaderAllowedFeatureFlags() From 4be8c73aac2e9a4350d4a8f5124d5493306551a8 Mon Sep 17 00:00:00 2001 From: pyama Date: Mon, 15 Jun 2026 20:41:44 +0900 Subject: [PATCH 2/2] refactor(http): split listen-address into independent --listen-host Replace --listen-address (host:port) with --listen-host so the host and port are configured independently, per review feedback. --port is kept unchanged. When --listen-host is empty (default) the server still binds to all interfaces on Port, preserving previous behavior. resolveListenAddress now takes (host, port) and uses net.JoinHostPort so IPv6 hosts (e.g. ::1) are bracketed correctly. --- cmd/github-mcp-server/main.go | 8 +++---- pkg/http/server.go | 23 ++++++++++--------- pkg/http/server_test.go | 42 +++++++++++++++++------------------ 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 74e0c5cdcb..604556692c 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -138,7 +138,7 @@ var ( Version: version, Host: viper.GetString("host"), Port: viper.GetInt("port"), - ListenAddress: viper.GetString("listen-address"), + ListenHost: viper.GetString("listen-host"), BaseURL: viper.GetString("base-url"), ResourcePath: viper.GetString("base-path"), ExportTranslations: viper.GetBool("export-translations"), @@ -184,8 +184,8 @@ func init() { rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") // HTTP-specific flags - httpCmd.Flags().Int("port", 8082, "HTTP server port (ignored when --listen-address is set)") - httpCmd.Flags().String("listen-address", "", "HTTP server listen address (host:port). Overrides --port when set (e.g. 127.0.0.1:8082)") + httpCmd.Flags().Int("port", 8082, "HTTP server port") + httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)") httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)") httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") @@ -206,7 +206,7 @@ func init() { _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) - _ = viper.BindPFlag("listen-address", httpCmd.Flags().Lookup("listen-address")) + _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path")) _ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge")) diff --git a/pkg/http/server.go b/pkg/http/server.go index 6dd27af299..36d3e111bc 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -5,9 +5,11 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "os" "os/signal" + "strconv" "syscall" "time" @@ -33,12 +35,11 @@ type ServerConfig struct { Host string // Port to listen on (default: 8082). - // Ignored when ListenAddress is set. Port int - // ListenAddress is the full listen address (host:port) for the HTTP server. - // When set it takes precedence over Port; otherwise the server listens on ":". - ListenAddress string + // ListenHost is the host the HTTP server binds to (e.g. "127.0.0.1"). + // When empty, the server binds to all interfaces. Combined with Port. + ListenHost string // BaseURL is the publicly accessible URL of this server for OAuth resource metadata. // If not set, the server will derive the URL from incoming request headers. @@ -197,7 +198,7 @@ func RunHTTPServer(cfg ServerConfig) error { }) logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL) - addr := resolveListenAddress(cfg.ListenAddress, cfg.Port) + addr := resolveListenAddress(cfg.ListenHost, cfg.Port) httpSvr := http.Server{ Addr: addr, Handler: r, @@ -229,13 +230,13 @@ func RunHTTPServer(cfg ServerConfig) error { } // resolveListenAddress returns the address string passed to http.Server. -// If listenAddress is non-empty it wins; otherwise the server binds to all -// interfaces on the given port. -func resolveListenAddress(listenAddress string, port int) string { - if listenAddress != "" { - return listenAddress +// When host is empty the server binds to all interfaces on the given port; +// otherwise host and port are joined into a single address. +func resolveListenAddress(host string, port int) string { + if host == "" { + return fmt.Sprintf(":%d", port) } - return fmt.Sprintf(":%d", port) + return net.JoinHostPort(host, strconv.Itoa(port)) } func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index 50b2265bbd..3ee97c890c 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -133,40 +133,40 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { func TestResolveListenAddress(t *testing.T) { tests := []struct { - name string - listenAddress string - port int - want string + name string + host string + port int + want string }{ { - name: "empty address falls back to :port", - listenAddress: "", - port: 8082, - want: ":8082", + name: "empty host falls back to :port", + host: "", + port: 8082, + want: ":8082", }, { - name: "explicit host:port wins over port", - listenAddress: "127.0.0.1:9090", - port: 8082, - want: "127.0.0.1:9090", + name: "ipv4 host is joined with port", + host: "127.0.0.1", + port: 9090, + want: "127.0.0.1:9090", }, { - name: "explicit :port form is preserved", - listenAddress: ":9090", - port: 8082, - want: ":9090", + name: "ipv6 host is bracketed and joined with port", + host: "::1", + port: 9090, + want: "[::1]:9090", }, { - name: "ipv6 address with port is preserved", - listenAddress: "[::1]:9090", - port: 8082, - want: "[::1]:9090", + name: "hostname is joined with port", + host: "localhost", + port: 8082, + want: "localhost:8082", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := resolveListenAddress(tt.listenAddress, tt.port) + got := resolveListenAddress(tt.host, tt.port) assert.Equal(t, tt.want, got) }) }