From b102004cad793a65503a181c56646e525ef04534 Mon Sep 17 00:00:00 2001 From: Anika Reiter Date: Fri, 17 Jul 2026 15:39:03 +0200 Subject: [PATCH 1/2] feat(http): add --authorization-server flag to override OAuth AS URL When deploying the MCP server behind an OAuth proxy (e.g. for GHES, which does not natively support RFC 8414, RFC 7591, or PKCE), the /.well-known/oauth-protected-resource endpoint currently always derives the authorization_servers URL from GITHUB_HOST. There is no way to point clients at a different authorization server without intercepting that endpoint at the ingress/proxy layer. The oauth.Config struct already has an AuthorizationServer field with the conditional logic in place (pkg/http/oauth/oauth.go), but it was never wired to any configuration surface. This commit exposes it as: - --authorization-server CLI flag on the http subcommand - GITHUB_AUTHORIZATION_SERVER environment variable (via viper's existing GITHUB_ prefix + automatic env mapping) When set, the value is passed through ServerConfig into oauth.Config, and the protected resource metadata advertises it directly instead of calling apiHost.AuthorizationServerURL(). --- cmd/github-mcp-server/main.go | 3 +++ docs/streamable-http.md | 25 +++++++++++++++++++++++++ pkg/http/server.go | 14 +++++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 3450aabe24..61779f3e03 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -203,6 +203,7 @@ var ( ListenHost: viper.GetString("listen-host"), BaseURL: viper.GetString("base-url"), ResourcePath: viper.GetString("base-path"), + AuthorizationServer: viper.GetString("authorization-server"), ExportTranslations: viper.GetBool("export-translations"), EnableCommandLogging: viper.GetBool("enable-command-logging"), LogFilePath: viper.GetString("log-file"), @@ -264,6 +265,7 @@ func init() { 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().String("authorization-server", "", "Override the authorization server URL in OAuth resource metadata. Useful when deploying behind an OAuth proxy (e.g. for GHES). Env: GITHUB_AUTHORIZATION_SERVER") httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") httpCmd.Flags().Bool("trust-proxy-headers", false, "Honor X-Forwarded-Host and X-Forwarded-Proto when constructing OAuth resource metadata URLs. Only enable when the server is deployed behind a trusted proxy that sets these headers. Ignored when --base-url is set.") @@ -292,6 +294,7 @@ func init() { _ = 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("authorization-server", httpCmd.Flags().Lookup("authorization-server")) _ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge")) _ = viper.BindPFlag("trust-proxy-headers", httpCmd.Flags().Lookup("trust-proxy-headers")) // Add subcommands diff --git a/docs/streamable-http.md b/docs/streamable-http.md index bdcf149b8f..c41971d025 100644 --- a/docs/streamable-http.md +++ b/docs/streamable-http.md @@ -92,6 +92,31 @@ github-mcp-server http --trust-proxy-headers Equivalent environment variable: `GITHUB_TRUST_PROXY_HEADERS=1`. Only enable this when the upstream proxy is trusted to set or strip these headers; otherwise prefer `--base-url`. When `--base-url` is set, it always takes precedence and `--trust-proxy-headers` has no effect. +### With an OAuth Proxy (GHES / non-standard authorization server) + +When deploying against GitHub Enterprise Server, GHES does not natively support the OAuth extensions required by MCP (RFC 8414 metadata discovery, RFC 7591 Dynamic Client Registration, PKCE). In this case you can run a separate OAuth proxy that implements the MCP OAuth spec and forwards authentication to GHES. Use `--authorization-server` to advertise the proxy's URL in the protected resource metadata instead of the one derived from `--gh-host`: + +```bash +github-mcp-server http \ + --gh-host https://github.example.com \ + --base-url https://mcp.example.com \ + --authorization-server https://mcp.example.com/oauth-proxy +``` + +The `authorization_servers` field in the protected resource metadata will then point at your proxy: + +```json +{ + "resource": "https://mcp.example.com", + "authorization_servers": [ + "https://mcp.example.com/oauth-proxy" + ], + ... +} +``` + +Equivalent environment variable: `GITHUB_AUTHORIZATION_SERVER=https://mcp.example.com/oauth-proxy`. + ## Client Configuration ### Using OAuth Authentication diff --git a/pkg/http/server.go b/pkg/http/server.go index 0cac24af30..6c4ccd00a3 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -53,6 +53,13 @@ type ServerConfig struct { // This is used to restore the original path when a proxy strips a base path before forwarding. ResourcePath string + // AuthorizationServer overrides the authorization server URL advertised in the + // OAuth Protected Resource Metadata (/.well-known/oauth-protected-resource). + // When set, this URL is used instead of the one derived from the GitHub host. + // Useful when deploying behind an OAuth proxy (e.g. for GHES, which does not + // natively support RFC 8414 / RFC 7591 / PKCE). + AuthorizationServer string + // TrustProxyHeaders indicates whether X-Forwarded-Host and X-Forwarded-Proto // should be honored when constructing OAuth resource metadata URLs. Only // enable this when the server is deployed behind a trusted proxy that sets @@ -191,9 +198,10 @@ func RunHTTPServer(cfg ServerConfig) error { // Register OAuth protected resource metadata endpoints oauthCfg := &oauth.Config{ - BaseURL: cfg.BaseURL, - ResourcePath: cfg.ResourcePath, - TrustProxyHeaders: cfg.TrustProxyHeaders, + BaseURL: cfg.BaseURL, + ResourcePath: cfg.ResourcePath, + TrustProxyHeaders: cfg.TrustProxyHeaders, + AuthorizationServer: cfg.AuthorizationServer, } serverOptions := []HandlerOption{ From 2aea819e3fb8d4508ff37aca9a76b9a025021bdf Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 20 Aug 2026 16:22:45 +0200 Subject: [PATCH 2/2] test(http): cover authorization server override wiring Verify the HTTP-only configuration surface, unchanged host-derived default, and explicit override propagation through OAuth metadata. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/github-mcp-server/main_test.go | 15 +++++++++++++ docs/streamable-http.md | 2 ++ pkg/http/oauth/oauth_test.go | 20 +++++++++++++++++- pkg/http/server.go | 16 ++++++++------ pkg/http/server_test.go | 34 ++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/cmd/github-mcp-server/main_test.go b/cmd/github-mcp-server/main_test.go index a1ed642a64..ad927701a2 100644 --- a/cmd/github-mcp-server/main_test.go +++ b/cmd/github-mcp-server/main_test.go @@ -9,6 +9,7 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,6 +42,20 @@ func TestGitHubAppFlagsAreStdioOnly(t *testing.T) { assert.Nil(t, httpCmd.Flags().Lookup("app-id")) } +func TestAuthorizationServerConfigurationIsHTTPOnly(t *testing.T) { + flag := httpCmd.Flags().Lookup("authorization-server") + require.NotNil(t, flag) + assert.Empty(t, flag.DefValue) + assert.Nil(t, stdioCmd.Flags().Lookup("authorization-server")) + + t.Setenv("GITHUB_AUTHORIZATION_SERVER", "") + initConfig() + assert.Empty(t, viper.GetString("authorization-server")) + + t.Setenv("GITHUB_AUTHORIZATION_SERVER", "https://oauth-proxy.example.com") + assert.Equal(t, "https://oauth-proxy.example.com", viper.GetString("authorization-server")) +} + func TestWriteToolDocScopeSemantics(t *testing.T) { tests := []struct { name string diff --git a/docs/streamable-http.md b/docs/streamable-http.md index c41971d025..97d970c36c 100644 --- a/docs/streamable-http.md +++ b/docs/streamable-http.md @@ -117,6 +117,8 @@ The `authorization_servers` field in the protected resource metadata will then p Equivalent environment variable: `GITHUB_AUTHORIZATION_SERVER=https://mcp.example.com/oauth-proxy`. +When neither the flag nor environment variable is set, the server preserves the existing behavior and derives the authorization server from `--gh-host`. The override only changes the URL advertised in OAuth protected resource metadata; it does not change token validation or the GitHub API host. + ## Client Configuration ### Using OAuth Authentication diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 958b347076..bb75ea5d4d 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -1,10 +1,12 @@ package oauth import ( + "context" "crypto/tls" "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/github/github-mcp-server/pkg/http/headers" @@ -18,6 +20,16 @@ var ( defaultAuthorizationServer = "https://github.com/login/oauth" ) +type countingAPIHostResolver struct { + utils.APIHostResolver + authorizationServerURLCalls int +} + +func (r *countingAPIHostResolver) AuthorizationServerURL(ctx context.Context) (*url.URL, error) { + r.authorizationServerURLCalls++ + return r.APIHostResolver.AuthorizationServerURL(ctx) +} + func TestNewAuthHandler(t *testing.T) { t.Parallel() @@ -729,6 +741,7 @@ func TestAPIHostResolver_AuthorizationServerURL(t *testing.T) { return } require.NoError(t, err) + countingAPIHost := &countingAPIHostResolver{APIHostResolver: apiHost} config := tc.oauthConfig if config == nil { @@ -736,7 +749,7 @@ func TestAPIHostResolver_AuthorizationServerURL(t *testing.T) { } config.BaseURL = tc.host - handler, err := NewAuthHandler(config, apiHost) + handler, err := NewAuthHandler(config, countingAPIHost) require.NoError(t, err) router := chi.NewRouter() @@ -767,6 +780,11 @@ func TestAPIHostResolver_AuthorizationServerURL(t *testing.T) { require.True(t, ok) require.Len(t, responseAuthServers, 1) assert.Equal(t, tc.expectedURL, responseAuthServers[0]) + if config.AuthorizationServer == "" { + assert.Equal(t, 1, countingAPIHost.authorizationServerURLCalls) + } else { + assert.Zero(t, countingAPIHost.authorizationServerURLCalls) + } }) } } diff --git a/pkg/http/server.go b/pkg/http/server.go index 6c4ccd00a3..e82be64bb7 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -197,12 +197,7 @@ func RunHTTPServer(cfg ServerConfig) error { } // Register OAuth protected resource metadata endpoints - oauthCfg := &oauth.Config{ - BaseURL: cfg.BaseURL, - ResourcePath: cfg.ResourcePath, - TrustProxyHeaders: cfg.TrustProxyHeaders, - AuthorizationServer: cfg.AuthorizationServer, - } + oauthCfg := newOAuthConfig(cfg) serverOptions := []HandlerOption{ WithInventoryFactory(inventoryFactory), @@ -264,6 +259,15 @@ func RunHTTPServer(cfg ServerConfig) error { return nil } +func newOAuthConfig(cfg ServerConfig) *oauth.Config { + return &oauth.Config{ + BaseURL: cfg.BaseURL, + ResourcePath: cfg.ResourcePath, + TrustProxyHeaders: cfg.TrustProxyHeaders, + AuthorizationServer: cfg.AuthorizationServer, + } +} + // resolveListenAddress returns the address string passed to http.Server. // When host is empty the server binds to all interfaces on the given port; // otherwise host and port are joined into a single address. diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index e7f9dd9da1..d32354091c 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -9,6 +9,7 @@ import ( ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/http/oauth" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/utils" "github.com/stretchr/testify/assert" @@ -44,6 +45,39 @@ func TestRunHTTPServerRejectsInvalidStaticTools(t *testing.T) { } } +func TestNewOAuthConfig(t *testing.T) { + tests := []struct { + name string + authorizationServer string + }{ + { + name: "unset preserves host-derived authorization server", + }, + { + name: "explicit override is propagated", + authorizationServer: "https://oauth-proxy.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newOAuthConfig(ServerConfig{ + BaseURL: "https://mcp.example.com", + ResourcePath: "/mcp", + TrustProxyHeaders: true, + AuthorizationServer: tt.authorizationServer, + }) + + assert.Equal(t, &oauth.Config{ + BaseURL: "https://mcp.example.com", + ResourcePath: "/mcp", + TrustProxyHeaders: true, + AuthorizationServer: tt.authorizationServer, + }, cfg) + }) + } +} + func TestInitGlobalToolScopeMapUsesHost(t *testing.T) { tests := []struct { name string