// Package oauth provides OAuth 2.0 Protected Resource Metadata (RFC 9728) support // for the GitHub MCP Server HTTP mode. package oauth import ( "fmt" "net/http" "strings" "github.com/github/github-mcp-server/pkg/http/headers" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-chi/chi/v5" "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/oauthex" ) const ( // OAuthProtectedResourcePrefix is the well-known path prefix for OAuth protected resource metadata. OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource" ) // SupportedScopes lists every OAuth scope that an MCP tool may require. It is the // source of truth in two places: HTTP mode advertises it as scopes_supported in // the protected-resource metadata, and stdio OAuth login requests it by default // and then filters the exposed tools to the granted scopes. A tool whose required // scope is absent here is therefore hidden under default OAuth even though a PAT // carrying that scope would expose it, so keep this list in sync with tool scope // requirements when scopes change. var SupportedScopes = []string{ "repo", "read:org", "read:user", "user:email", "read:packages", "write:packages", "read:project", "project", "gist", "notifications", "workflow", "codespace", } // Config holds the OAuth configuration for the MCP server. type Config struct { // BaseURL is the publicly accessible URL where this server is hosted. // This is used to construct the OAuth resource URL. BaseURL string // AuthorizationServer is the OAuth authorization server URL. // Defaults to GitHub's OAuth server if not specified. AuthorizationServer string // ResourcePath is the externally visible base path for the MCP server (e.g., "/mcp"). // This is used to restore the original path when a proxy strips a base path before forwarding. // If empty, requests are treated as already using the external path. ResourcePath string // TrustProxyHeaders indicates whether X-Forwarded-Host and X-Forwarded-Proto // should be honored when deriving the effective host and scheme for OAuth // resource URLs. This must only be enabled when the server is deployed // behind a trusted proxy that sets these headers; otherwise an untrusted // client can influence the OAuth resource metadata URL advertised to MCP // clients. When BaseURL is set, it always takes precedence and these // headers are unused. TrustProxyHeaders bool } // AuthHandler handles OAuth-related HTTP endpoints. type AuthHandler struct { cfg *Config apiHost utils.APIHostResolver } // NewAuthHandler creates a new OAuth auth handler. func NewAuthHandler(cfg *Config, apiHost utils.APIHostResolver) (*AuthHandler, error) { if cfg == nil { cfg = &Config{} } if apiHost == nil { var err error apiHost, err = utils.NewAPIHost("https://api.github.com") if err != nil { return nil, fmt.Errorf("failed to create default API host: %w", err) } } return &AuthHandler{ cfg: cfg, apiHost: apiHost, }, nil } // routePatterns defines the route patterns for OAuth protected resource metadata. var routePatterns = []string{ "", // Root: /.well-known/oauth-protected-resource "/readonly", // Read-only mode "/insiders", // Insiders mode "/x/{toolset}", "/x/{toolset}/readonly", } // RegisterRoutes registers the OAuth protected resource metadata routes. func (h *AuthHandler) RegisterRoutes(r chi.Router) { for _, pattern := range routePatterns { for _, route := range h.routesForPattern(pattern) { path := OAuthProtectedResourcePrefix + route r.Handle(path, h.metadataHandler()) } } } func (h *AuthHandler) metadataHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() resourcePath := resolveResourcePath( strings.TrimPrefix(r.URL.Path, OAuthProtectedResourcePrefix), h.cfg.ResourcePath, ) resourceURL := h.buildResourceURL(r, resourcePath) var authorizationServerURL string if h.cfg.AuthorizationServer != "" { authorizationServerURL = h.cfg.AuthorizationServer } else { authURL, err := h.apiHost.AuthorizationServerURL(ctx) if err != nil { http.Error(w, fmt.Sprintf("failed to resolve authorization server URL: %v", err), http.StatusInternalServerError) return } authorizationServerURL = authURL.String() } metadata := &oauthex.ProtectedResourceMetadata{ Resource: resourceURL, AuthorizationServers: []string{authorizationServerURL}, ResourceName: "GitHub MCP Server", ScopesSupported: SupportedScopes, BearerMethodsSupported: []string{"header"}, } auth.ProtectedResourceMetadataHandler(metadata).ServeHTTP(w, r) }) } // routesForPattern generates route variants for a given pattern. // GitHub strips the /mcp prefix before forwarding, so we register both variants: // - With /mcp prefix: for direct access or when GitHub doesn't strip // - Without /mcp prefix: for when GitHub has stripped the prefix func (h *AuthHandler) routesForPattern(pattern string) []string { basePaths := []string{""} if basePath := normalizeBasePath(h.cfg.ResourcePath); basePath != "" { basePaths = append(basePaths, basePath) } else { basePaths = append(basePaths, "/mcp") } routes := make([]string, 0, len(basePaths)*2) for _, basePath := range basePaths { routes = append(routes, joinRoute(basePath, pattern)) routes = append(routes, joinRoute(basePath, pattern)+"/") } return routes } // resolveResourcePath returns the externally visible resource path, // restoring the configured base path when proxies strip it before forwarding. func resolveResourcePath(path, basePath string) string { if path == "" { path = "/" } base := normalizeBasePath(basePath) if base == "" { return path } if path == "/" { return base } if path == base || strings.HasPrefix(path, base+"/") { return path } return base + path } // ResolveResourcePath returns the externally visible resource path for a request. // Exported for use by middleware. func ResolveResourcePath(r *http.Request, cfg *Config) string { basePath := "" if cfg != nil { basePath = cfg.ResourcePath } return resolveResourcePath(r.URL.Path, basePath) } // buildResourceURL constructs the full resource URL for OAuth metadata. func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) string { host, scheme := GetEffectiveHostAndScheme(r, h.cfg) baseURL := fmt.Sprintf("%s://%s", scheme, host) if h.cfg.BaseURL != "" { baseURL = strings.TrimSuffix(h.cfg.BaseURL, "/") } if resourcePath == "" { resourcePath = "/" } if !strings.HasPrefix(resourcePath, "/") { resourcePath = "/" + resourcePath } return baseURL + resourcePath } // GetEffectiveHostAndScheme returns the effective host and scheme for a request. // // X-Forwarded-Host and X-Forwarded-Proto are only honored when cfg.TrustProxyHeaders // is true. Without that opt-in, an untrusted client could otherwise influence the // OAuth resource metadata URL advertised to MCP clients. func GetEffectiveHostAndScheme(r *http.Request, cfg *Config) (host, scheme string) { //nolint:revive trustProxy := cfg != nil && cfg.TrustProxyHeaders if trustProxy { if fh := r.Header.Get(headers.ForwardedHostHeader); fh != "" { host = fh } } if host == "" { host = r.Host } if host == "" { host = "localhost" } if trustProxy { if fp := r.Header.Get(headers.ForwardedProtoHeader); fp != "" { scheme = strings.ToLower(fp) } } if scheme == "" { if r.TLS != nil { scheme = "https" } else { scheme = "http" } } return } // BuildResourceMetadataURL constructs the full URL to the OAuth protected resource metadata endpoint. func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string) string { host, scheme := GetEffectiveHostAndScheme(r, cfg) suffix := "" if resourcePath != "" && resourcePath != "/" { if !strings.HasPrefix(resourcePath, "/") { suffix = "/" + resourcePath } else { suffix = resourcePath } } if cfg != nil && cfg.BaseURL != "" { return strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix } return fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix) } func normalizeBasePath(path string) string { trimmed := strings.TrimSpace(path) if trimmed == "" || trimmed == "/" { return "" } if !strings.HasPrefix(trimmed, "/") { trimmed = "/" + trimmed } return strings.TrimSuffix(trimmed, "/") } func joinRoute(basePath, pattern string) string { if basePath == "" { return pattern } if pattern == "" { return basePath } if before, ok := strings.CutSuffix(basePath, "/"); ok { return before + pattern } return basePath + pattern }