@@ -5,7 +5,6 @@ package oauth
55import (
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.
6660type 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
128123func (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.
209204func 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