Skip to content

Commit 7801dc5

Browse files
committed
Merge branch 'oauth-handler-implementation' into scope-challenge-http
2 parents 4b690f5 + 3990325 commit 7801dc5

File tree

7 files changed

+177
-225
lines changed

7 files changed

+177
-225
lines changed

cmd/github-mcp-server/main.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ var (
102102
Host: viper.GetString("host"),
103103
Port: viper.GetInt("port"),
104104
BaseURL: viper.GetString("base-url"),
105+
ResourcePath: viper.GetString("base-path"),
105106
ExportTranslations: viper.GetBool("export-translations"),
106107
EnableCommandLogging: viper.GetBool("enable-command-logging"),
107108
LogFilePath: viper.GetString("log-file"),
@@ -136,9 +137,10 @@ func init() {
136137
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
137138
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
138139

139-
// Add port flag to http command
140-
httpCmd.PersistentFlags().Int("port", 8082, "HTTP server port")
141-
httpCmd.PersistentFlags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")
140+
// HTTP command flags
141+
rootCmd.PersistentFlags().Int("port", 8082, "HTTP server port")
142+
rootCmd.PersistentFlags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")
143+
rootCmd.PersistentFlags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)")
142144

143145
// Bind flag to viper
144146
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -154,8 +156,11 @@ func init() {
154156
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
155157
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
156158
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
159+
160+
// HTTP command flags
157161
_ = viper.BindPFlag("port", httpCmd.PersistentFlags().Lookup("port"))
158162
_ = viper.BindPFlag("base-url", httpCmd.PersistentFlags().Lookup("base-url"))
163+
_ = viper.BindPFlag("base-path", httpCmd.PersistentFlags().Lookup("base-path"))
159164

160165
// Add subcommands
161166
rootCmd.AddCommand(stdioCmd)

pkg/http/headers/headers.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,6 @@ const (
2626
// ForwardedProtoHeader is a standard HTTP Header for preserving the original protocol when proxying.
2727
ForwardedProtoHeader = "X-Forwarded-Proto"
2828

29-
// OriginalPathHeader is set to preserve the original request path
30-
// before the /mcp prefix was stripped during proxying.
31-
OriginalPathHeader = "X-GitHub-Original-Path"
32-
3329
// RequestHmacHeader is used to authenticate requests to the Raw API.
3430
RequestHmacHeader = "Request-Hmac"
3531

pkg/http/middleware/scope_challenge.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func WithScopeChallenge(oauthCfg *oauth.Config) func(http.Handler) http.Handler
150150
// Build the resource metadata URL using the shared utility
151151
// GetEffectiveResourcePath returns the original path (e.g., /mcp or /mcp/x/all)
152152
// which is used to construct the well-known OAuth protected resource URL
153-
resourcePath := oauth.GetEffectiveResourcePath(r)
153+
resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
154154
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
155155

156156
// Build recommended scopes: existing scopes + required scopes

pkg/http/middleware/token.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handl
3737
// sendAuthChallenge sends a 401 Unauthorized response with WWW-Authenticate header
3838
// containing the OAuth protected resource metadata URL as per RFC 6750 and MCP spec.
3939
func sendAuthChallenge(w http.ResponseWriter, r *http.Request, oauthCfg *oauth.Config) {
40-
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, "mcp")
40+
resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
41+
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
4142
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMetadataURL))
4243
http.Error(w, "Unauthorized", http.StatusUnauthorized)
4344
}

pkg/http/oauth/oauth.go

Lines changed: 108 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package oauth
55
import (
66
"fmt"
77
"net/http"
8-
"net/url"
98
"strings"
109

1110
"github.com/github/github-mcp-server/pkg/http/headers"
@@ -51,17 +50,12 @@ type Config struct {
5150
// Defaults to GitHub's OAuth server if not specified.
5251
AuthorizationServer string
5352

54-
// ResourcePath is the resource path suffix (e.g., "/mcp").
55-
// If empty, defaults to "/"
53+
// ResourcePath is the externally visible base path for the MCP server (e.g., "/mcp").
54+
// This is used to restore the original path when a proxy strips a base path before forwarding.
55+
// If empty, requests are treated as already using the external path.
5656
ResourcePath string
5757
}
5858

59-
// ProtectedResourceData contains the data needed to build an OAuth protected resource response.
60-
type ProtectedResourceData struct {
61-
ResourceURL string
62-
AuthorizationServer string
63-
}
64-
6559
// AuthHandler handles OAuth-related HTTP endpoints.
6660
type AuthHandler struct {
6761
cfg *Config
@@ -97,127 +91,152 @@ func (h *AuthHandler) RegisterRoutes(r chi.Router) {
9791
for _, pattern := range routePatterns {
9892
for _, route := range h.routesForPattern(pattern) {
9993
path := OAuthProtectedResourcePrefix + route
100-
101-
// Build metadata for this specific resource path
102-
metadata := h.buildMetadata(route)
103-
r.Handle(path, auth.ProtectedResourceMetadataHandler(metadata))
94+
r.Handle(path, h.metadataHandler())
10495
}
10596
}
10697
}
10798

108-
func (h *AuthHandler) buildMetadata(resourcePath string) *oauthex.ProtectedResourceMetadata {
109-
baseURL := strings.TrimSuffix(h.cfg.BaseURL, "/")
110-
resourceURL := baseURL
111-
if resourcePath != "" && resourcePath != "/" {
112-
resourceURL = baseURL + resourcePath
113-
}
99+
func (h *AuthHandler) metadataHandler() http.Handler {
100+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101+
resourcePath := resolveResourcePath(
102+
strings.TrimPrefix(r.URL.Path, OAuthProtectedResourcePrefix),
103+
h.cfg.ResourcePath,
104+
)
105+
resourceURL := h.buildResourceURL(r, resourcePath)
106+
107+
metadata := &oauthex.ProtectedResourceMetadata{
108+
Resource: resourceURL,
109+
AuthorizationServers: []string{h.cfg.AuthorizationServer},
110+
ResourceName: "GitHub MCP Server",
111+
ScopesSupported: SupportedScopes,
112+
BearerMethodsSupported: []string{"header"},
113+
}
114114

115-
return &oauthex.ProtectedResourceMetadata{
116-
Resource: resourceURL,
117-
AuthorizationServers: []string{h.cfg.AuthorizationServer},
118-
ResourceName: "GitHub MCP Server",
119-
ScopesSupported: SupportedScopes,
120-
BearerMethodsSupported: []string{"header"},
121-
}
115+
auth.ProtectedResourceMetadataHandler(metadata).ServeHTTP(w, r)
116+
})
122117
}
123118

124119
// routesForPattern generates route variants for a given pattern.
125120
// GitHub strips the /mcp prefix before forwarding, so we register both variants:
126121
// - With /mcp prefix: for direct access or when GitHub doesn't strip
127122
// - Without /mcp prefix: for when GitHub has stripped the prefix
128123
func (h *AuthHandler) routesForPattern(pattern string) []string {
129-
return []string{
130-
pattern,
131-
"/mcp" + pattern,
132-
pattern + "/",
133-
"/mcp" + pattern + "/",
124+
basePaths := []string{""}
125+
if basePath := normalizeBasePath(h.cfg.ResourcePath); basePath != "" {
126+
basePaths = append(basePaths, basePath)
127+
} else {
128+
basePaths = append(basePaths, "/mcp")
134129
}
130+
131+
routes := make([]string, 0, len(basePaths)*2)
132+
for _, basePath := range basePaths {
133+
routes = append(routes, joinRoute(basePath, pattern))
134+
routes = append(routes, joinRoute(basePath, pattern)+"/")
135+
}
136+
137+
return routes
135138
}
136139

137-
// GetEffectiveResourcePath returns the resource path for OAuth protected resource URLs.
138-
// It checks for the X-GitHub-Original-Path header set by GitHub, which contains
139-
// the exact path the client requested before the /mcp prefix was stripped.
140-
// If the header is not present, it falls back to
141-
// restoring the /mcp prefix.
142-
func GetEffectiveResourcePath(r *http.Request) string {
143-
// Check for the original path header from GitHub (preferred method)
144-
if originalPath := r.Header.Get(headers.OriginalPathHeader); originalPath != "" {
145-
return originalPath
140+
// resolveResourcePath returns the externally visible resource path,
141+
// restoring the configured base path when proxies strip it before forwarding.
142+
func resolveResourcePath(path, basePath string) string {
143+
if path == "" {
144+
path = "/"
145+
}
146+
base := normalizeBasePath(basePath)
147+
if base == "" {
148+
return path
149+
}
150+
if path == "/" {
151+
return base
152+
}
153+
if path == base || strings.HasPrefix(path, base+"/") {
154+
return path
146155
}
156+
return base + path
157+
}
147158

148-
// Fallback: GitHub strips /mcp prefix, so we need to restore it for the external URL
149-
if r.URL.Path == "/" {
150-
return "/mcp"
159+
// ResolveResourcePath returns the externally visible resource path for a request.
160+
// Exported for use by middleware.
161+
func ResolveResourcePath(r *http.Request, cfg *Config) string {
162+
basePath := ""
163+
if cfg != nil {
164+
basePath = cfg.ResourcePath
151165
}
152-
return "/mcp" + r.URL.Path
166+
return resolveResourcePath(r.URL.Path, basePath)
153167
}
154168

155-
// GetProtectedResourceData builds the OAuth protected resource data for a request.
156-
func (h *AuthHandler) GetProtectedResourceData(r *http.Request, resourcePath string) (*ProtectedResourceData, error) {
169+
// buildResourceURL constructs the full resource URL for OAuth metadata.
170+
func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) string {
157171
host, scheme := GetEffectiveHostAndScheme(r, h.cfg)
158-
159-
// Build the base URL
160172
baseURL := fmt.Sprintf("%s://%s", scheme, host)
161173
if h.cfg.BaseURL != "" {
162174
baseURL = strings.TrimSuffix(h.cfg.BaseURL, "/")
163175
}
164-
165-
// Build the resource URL using url.JoinPath for proper path handling
166-
var resourceURL string
167-
var err error
168-
if resourcePath == "/" {
169-
resourceURL = baseURL + "/"
170-
} else {
171-
resourceURL, err = url.JoinPath(baseURL, resourcePath)
172-
if err != nil {
173-
return nil, fmt.Errorf("failed to build resource URL: %w", err)
174-
}
176+
if resourcePath == "" {
177+
resourcePath = "/"
175178
}
176-
177-
return &ProtectedResourceData{
178-
ResourceURL: resourceURL,
179-
AuthorizationServer: h.cfg.AuthorizationServer,
180-
}, nil
179+
if !strings.HasPrefix(resourcePath, "/") {
180+
resourcePath = "/" + resourcePath
181+
}
182+
return baseURL + resourcePath
181183
}
182184

183185
// GetEffectiveHostAndScheme returns the effective host and scheme for a request.
184-
// It checks X-Forwarded-Host and X-Forwarded-Proto headers first (set by proxies),
185-
// then falls back to the request's Host and TLS state.
186-
func GetEffectiveHostAndScheme(r *http.Request, cfg *Config) (host, scheme string) { //nolint:revive // parameters are required by http.oauth.BuildResourceMetadataURL signature
187-
// Check for forwarded headers first (typically set by reverse proxies)
188-
if forwardedHost := r.Header.Get(headers.ForwardedHostHeader); forwardedHost != "" {
189-
host = forwardedHost
186+
func GetEffectiveHostAndScheme(r *http.Request, cfg *Config) (host, scheme string) { //nolint:revive
187+
if fh := r.Header.Get(headers.ForwardedHostHeader); fh != "" {
188+
host = fh
190189
} else {
191190
host = r.Host
192191
}
193-
194-
// Determine scheme
195-
switch {
196-
case r.Header.Get(headers.ForwardedProtoHeader) != "":
197-
scheme = strings.ToLower(r.Header.Get(headers.ForwardedProtoHeader))
198-
case r.TLS != nil:
199-
scheme = "https"
200-
default:
201-
// Default to HTTPS in production scenarios
202-
scheme = "https"
192+
if host == "" {
193+
host = "localhost"
203194
}
204-
205-
return host, scheme
195+
if fp := r.Header.Get(headers.ForwardedProtoHeader); fp != "" {
196+
scheme = strings.ToLower(fp)
197+
} else {
198+
scheme = "https" // Default to HTTPS
199+
}
200+
return
206201
}
207202

208203
// BuildResourceMetadataURL constructs the full URL to the OAuth protected resource metadata endpoint.
209204
func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string) string {
210205
host, scheme := GetEffectiveHostAndScheme(r, cfg)
211-
206+
suffix := ""
207+
if resourcePath != "" && resourcePath != "/" {
208+
if !strings.HasPrefix(resourcePath, "/") {
209+
suffix = "/" + resourcePath
210+
} else {
211+
suffix = resourcePath
212+
}
213+
}
212214
if cfg != nil && cfg.BaseURL != "" {
213-
baseURL := strings.TrimSuffix(cfg.BaseURL, "/")
214-
return baseURL + OAuthProtectedResourcePrefix + "/" + strings.TrimPrefix(resourcePath, "/")
215+
return strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix
215216
}
217+
return fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix)
218+
}
216219

217-
path := OAuthProtectedResourcePrefix
218-
if resourcePath != "" && resourcePath != "/" {
219-
path = path + "/" + strings.TrimPrefix(resourcePath, "/")
220+
func normalizeBasePath(path string) string {
221+
trimmed := strings.TrimSpace(path)
222+
if trimmed == "" || trimmed == "/" {
223+
return ""
224+
}
225+
if !strings.HasPrefix(trimmed, "/") {
226+
trimmed = "/" + trimmed
220227
}
228+
return strings.TrimSuffix(trimmed, "/")
229+
}
221230

222-
return fmt.Sprintf("%s://%s%s", scheme, host, path)
231+
func joinRoute(basePath, pattern string) string {
232+
if basePath == "" {
233+
return pattern
234+
}
235+
if pattern == "" {
236+
return basePath
237+
}
238+
if strings.HasSuffix(basePath, "/") {
239+
return strings.TrimSuffix(basePath, "/") + pattern
240+
}
241+
return basePath + pattern
223242
}

0 commit comments

Comments
 (0)