Skip to content

Commit 370ebca

Browse files
committed
initial oauth metadata implementation
1 parent abffbf5 commit 370ebca

File tree

7 files changed

+294
-4
lines changed

7 files changed

+294
-4
lines changed

cmd/github-mcp-server/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ var (
9999
Version: version,
100100
Host: viper.GetString("host"),
101101
Port: viper.GetInt("port"),
102+
BaseURL: viper.GetString("base-url"),
102103
ExportTranslations: viper.GetBool("export-translations"),
103104
EnableCommandLogging: viper.GetBool("enable-command-logging"),
104105
LogFilePath: viper.GetString("log-file"),
@@ -130,6 +131,7 @@ func init() {
130131
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
131132
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
132133
rootCmd.PersistentFlags().Int("port", 8082, "HTTP server port")
134+
rootCmd.PersistentFlags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")
133135

134136
// Bind flag to viper
135137
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -145,6 +147,7 @@ func init() {
145147
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
146148
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
147149
_ = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
150+
_ = viper.BindPFlag("base-url", rootCmd.PersistentFlags().Lookup("base-url"))
148151

149152
// Add subcommands
150153
rootCmd.AddCommand(stdioCmd)

pkg/http/handler.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/github/github-mcp-server/pkg/github"
1010
"github.com/github/github-mcp-server/pkg/http/headers"
1111
"github.com/github/github-mcp-server/pkg/http/middleware"
12+
"github.com/github/github-mcp-server/pkg/http/oauth"
1213
"github.com/github/github-mcp-server/pkg/inventory"
1314
"github.com/github/github-mcp-server/pkg/translations"
1415
"github.com/go-chi/chi/v5"
@@ -25,11 +26,13 @@ type HTTPMcpHandler struct {
2526
t translations.TranslationHelperFunc
2627
githubMcpServerFactory GitHubMCPServerFactoryFunc
2728
inventoryFactoryFunc InventoryFactoryFunc
29+
oauthCfg *oauth.Config
2830
}
2931

3032
type HTTPMcpHandlerOptions struct {
3133
GitHubMcpServerFactory GitHubMCPServerFactoryFunc
3234
InventoryFactory InventoryFactoryFunc
35+
OAuthConfig *oauth.Config
3336
}
3437

3538
type HTTPMcpHandlerOption func(*HTTPMcpHandlerOptions)
@@ -46,6 +49,12 @@ func WithInventoryFactory(f InventoryFactoryFunc) HTTPMcpHandlerOption {
4649
}
4750
}
4851

52+
func WithOAuthConfig(cfg *oauth.Config) HTTPMcpHandlerOption {
53+
return func(o *HTTPMcpHandlerOptions) {
54+
o.OAuthConfig = cfg
55+
}
56+
}
57+
4958
func NewHTTPMcpHandler(cfg *HTTPServerConfig,
5059
deps github.ToolDependencies,
5160
t translations.TranslationHelperFunc,
@@ -73,6 +82,7 @@ func NewHTTPMcpHandler(cfg *HTTPServerConfig,
7382
t: t,
7483
githubMcpServerFactory: githubMcpServerFactory,
7584
inventoryFactoryFunc: inventoryFactory,
85+
oauthCfg: opts.OAuthConfig,
7686
}
7787
}
7888

@@ -101,7 +111,7 @@ func (h *HTTPMcpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
101111
Stateless: true,
102112
})
103113

104-
middleware.ExtractUserToken()(mcpHandler).ServeHTTP(w, r)
114+
middleware.ExtractUserToken(h.oauthCfg)(mcpHandler).ServeHTTP(w, r)
105115
}
106116

107117
func DefaultGitHubMCPServerFactory(ctx context.Context, _ *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) {

pkg/http/headers/headers.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ const (
2121
// RealIPHeader is a standard HTTP Header used to indicate the real IP address of the client.
2222
RealIPHeader = "X-Real-IP"
2323

24+
// ForwardedHostHeader is a standard HTTP Header for preserving the original Host header when proxying.
25+
ForwardedHostHeader = "X-Forwarded-Host"
26+
// ForwardedProtoHeader is a standard HTTP Header for preserving the original protocol when proxying.
27+
ForwardedProtoHeader = "X-Forwarded-Proto"
28+
2429
// RequestHmacHeader is used to authenticate requests to the Raw API.
2530
RequestHmacHeader = "Request-Hmac"
2631

pkg/http/middleware/token.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
ghcontext "github.com/github/github-mcp-server/pkg/context"
1111
httpheaders "github.com/github/github-mcp-server/pkg/http/headers"
1212
"github.com/github/github-mcp-server/pkg/http/mark"
13+
"github.com/github/github-mcp-server/pkg/http/oauth"
1314
)
1415

1516
type authType int
@@ -40,14 +41,14 @@ var supportedThirdPartyTokenPrefixes = []string{
4041
// were 40 characters long and only contained the characters a-f and 0-9.
4142
var oldPatternRegexp = regexp.MustCompile(`\A[a-f0-9]{40}\z`)
4243

43-
func ExtractUserToken() func(next http.Handler) http.Handler {
44+
func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handler {
4445
return func(next http.Handler) http.Handler {
4546
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4647
_, token, err := parseAuthorizationHeader(r)
4748
if err != nil {
4849
// For missing Authorization header, return 401 with WWW-Authenticate header per MCP spec
4950
if errors.Is(err, errMissingAuthorizationHeader) {
50-
// sendAuthChallenge(w, r, cfg, obsv)
51+
sendAuthChallenge(w, r, oauthCfg)
5152
return
5253
}
5354
// For other auth errors (bad format, unsupported), return 400
@@ -63,6 +64,15 @@ func ExtractUserToken() func(next http.Handler) http.Handler {
6364
})
6465
}
6566
}
67+
68+
// sendAuthChallenge sends a 401 Unauthorized response with WWW-Authenticate header
69+
// containing the OAuth protected resource metadata URL as per RFC 6750 and MCP spec.
70+
func sendAuthChallenge(w http.ResponseWriter, r *http.Request, oauthCfg *oauth.Config) {
71+
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, "mcp")
72+
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMetadataURL))
73+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
74+
}
75+
6676
func parseAuthorizationHeader(req *http.Request) (authType authType, token string, _ error) {
6777
authHeader := req.Header.Get(httpheaders.AuthorizationHeader)
6878
if authHeader == "" {

pkg/http/oauth/oauth.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Package oauth provides OAuth 2.0 Protected Resource Metadata (RFC 9728) support
2+
// for the GitHub MCP Server HTTP mode.
3+
package oauth
4+
5+
import (
6+
"bytes"
7+
_ "embed"
8+
"fmt"
9+
"html"
10+
"net/http"
11+
"strings"
12+
"text/template"
13+
14+
"github.com/github/github-mcp-server/pkg/http/headers"
15+
"github.com/go-chi/chi/v5"
16+
)
17+
18+
const (
19+
// OAuthProtectedResourcePrefix is the well-known path prefix for OAuth protected resource metadata.
20+
OAuthProtectedResourcePrefix = "/.well-known/oauth-protected-resource"
21+
22+
// DefaultAuthorizationServer is GitHub's OAuth authorization server.
23+
DefaultAuthorizationServer = "https://github.com/login/oauth"
24+
)
25+
26+
//go:embed protected_resource.json.tmpl
27+
var protectedResourceTemplate []byte
28+
29+
// SupportedScopes lists all OAuth scopes that may be required by MCP tools.
30+
var SupportedScopes = []string{
31+
"repo",
32+
"read:org",
33+
"read:user",
34+
"user:email",
35+
"read:packages",
36+
"write:packages",
37+
"read:project",
38+
"project",
39+
"gist",
40+
"notifications",
41+
"workflow",
42+
"codespace",
43+
}
44+
45+
// Config holds the OAuth configuration for the MCP server.
46+
type Config struct {
47+
// BaseURL is the publicly accessible URL where this server is hosted.
48+
// This is used to construct the OAuth resource URL.
49+
// Example: "https://mcp.example.com"
50+
BaseURL string
51+
52+
// AuthorizationServer is the OAuth authorization server URL.
53+
// Defaults to GitHub's OAuth server if not specified.
54+
AuthorizationServer string
55+
56+
// ResourcePath is the resource path suffix (e.g., "/mcp").
57+
// If empty, defaults to "/"
58+
ResourcePath string
59+
}
60+
61+
// ProtectedResourceData contains the data needed to build an OAuth protected resource response.
62+
type ProtectedResourceData struct {
63+
ResourceURL string
64+
AuthorizationServer string
65+
}
66+
67+
// AuthHandler handles OAuth-related HTTP endpoints.
68+
type AuthHandler struct {
69+
cfg *Config
70+
protectedResourceTemplate *template.Template
71+
}
72+
73+
// NewAuthHandler creates a new OAuth auth handler.
74+
func NewAuthHandler(cfg *Config) (*AuthHandler, error) {
75+
if cfg == nil {
76+
cfg = &Config{}
77+
}
78+
79+
// Default authorization server to GitHub
80+
if cfg.AuthorizationServer == "" {
81+
cfg.AuthorizationServer = DefaultAuthorizationServer
82+
}
83+
84+
tmpl, err := template.New("protected-resource").Parse(string(protectedResourceTemplate))
85+
if err != nil {
86+
return nil, fmt.Errorf("failed to parse protected resource template: %w", err)
87+
}
88+
89+
return &AuthHandler{
90+
cfg: cfg,
91+
protectedResourceTemplate: tmpl,
92+
}, nil
93+
}
94+
95+
// routePatterns defines the route patterns for OAuth protected resource metadata.
96+
var routePatterns = []string{
97+
"", // Root: /.well-known/oauth-protected-resource
98+
"/readonly", // Read-only mode
99+
"/x/{toolset}",
100+
"/x/{toolset}/readonly",
101+
}
102+
103+
// RegisterRoutes registers the OAuth protected resource metadata routes.
104+
func (h *AuthHandler) RegisterRoutes(r chi.Router) {
105+
for _, pattern := range routePatterns {
106+
for _, route := range h.routesForPattern(pattern) {
107+
path := OAuthProtectedResourcePrefix + route
108+
r.Get(path, h.handleProtectedResource)
109+
r.Options(path, h.handleProtectedResource) // CORS support
110+
}
111+
}
112+
}
113+
114+
// routesForPattern generates route variants for a given pattern.
115+
func (h *AuthHandler) routesForPattern(pattern string) []string {
116+
routes := []string{
117+
pattern,
118+
pattern + "/",
119+
pattern + "/mcp",
120+
pattern + "/mcp/",
121+
}
122+
return routes
123+
}
124+
125+
// handleProtectedResource handles requests for OAuth protected resource metadata.
126+
func (h *AuthHandler) handleProtectedResource(w http.ResponseWriter, r *http.Request) {
127+
// Extract the resource path from the URL
128+
resourcePath := strings.TrimPrefix(r.URL.Path, OAuthProtectedResourcePrefix)
129+
if resourcePath == "" || resourcePath == "/" {
130+
resourcePath = "/"
131+
} else {
132+
resourcePath = strings.TrimPrefix(resourcePath, "/")
133+
}
134+
135+
data, err := h.GetProtectedResourceData(r, html.EscapeString(resourcePath))
136+
if err != nil {
137+
http.Error(w, err.Error(), http.StatusBadRequest)
138+
return
139+
}
140+
141+
var buf bytes.Buffer
142+
if err := h.protectedResourceTemplate.Execute(&buf, data); err != nil {
143+
http.Error(w, "Internal server error", http.StatusInternalServerError)
144+
return
145+
}
146+
147+
// Set CORS headers
148+
w.Header().Set("Access-Control-Allow-Origin", "*")
149+
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
150+
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
151+
w.Header().Set("Content-Type", "application/json")
152+
w.WriteHeader(http.StatusOK)
153+
_, _ = w.Write(buf.Bytes())
154+
}
155+
156+
// GetProtectedResourceData builds the OAuth protected resource data for a request.
157+
func (h *AuthHandler) GetProtectedResourceData(r *http.Request, resourcePath string) (*ProtectedResourceData, error) {
158+
host, scheme := GetEffectiveHostAndScheme(r, h.cfg)
159+
160+
// Build the resource URL
161+
var resourceURL string
162+
if h.cfg.BaseURL != "" {
163+
// Use configured base URL
164+
baseURL := strings.TrimSuffix(h.cfg.BaseURL, "/")
165+
if resourcePath == "/" {
166+
resourceURL = baseURL + "/"
167+
} else {
168+
resourceURL = baseURL + "/" + resourcePath
169+
}
170+
} else {
171+
// Derive from request
172+
if resourcePath == "/" {
173+
resourceURL = fmt.Sprintf("%s://%s/", scheme, host)
174+
} else {
175+
resourceURL = fmt.Sprintf("%s://%s/%s", scheme, host, resourcePath)
176+
}
177+
}
178+
179+
return &ProtectedResourceData{
180+
ResourceURL: resourceURL,
181+
AuthorizationServer: h.cfg.AuthorizationServer,
182+
}, nil
183+
}
184+
185+
// GetEffectiveHostAndScheme returns the effective host and scheme for a request.
186+
// It checks X-Forwarded-Host and X-Forwarded-Proto headers first (set by proxies),
187+
// then falls back to the request's Host and TLS state.
188+
func GetEffectiveHostAndScheme(r *http.Request, cfg *Config) (host, scheme string) {
189+
// Check for forwarded headers first (typically set by reverse proxies)
190+
if forwardedHost := r.Header.Get(headers.ForwardedHostHeader); forwardedHost != "" {
191+
host = forwardedHost
192+
} else {
193+
host = r.Host
194+
}
195+
196+
// Determine scheme
197+
switch {
198+
case r.Header.Get(headers.ForwardedProtoHeader) != "":
199+
scheme = strings.ToLower(r.Header.Get(headers.ForwardedProtoHeader))
200+
case r.TLS != nil:
201+
scheme = "https"
202+
default:
203+
// Default to HTTPS in production scenarios
204+
scheme = "https"
205+
}
206+
207+
return host, scheme
208+
}
209+
210+
// BuildResourceMetadataURL constructs the full URL to the OAuth protected resource metadata endpoint.
211+
func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string) string {
212+
host, scheme := GetEffectiveHostAndScheme(r, cfg)
213+
214+
if cfg != nil && cfg.BaseURL != "" {
215+
baseURL := strings.TrimSuffix(cfg.BaseURL, "/")
216+
return baseURL + OAuthProtectedResourcePrefix + "/" + strings.TrimPrefix(resourcePath, "/")
217+
}
218+
219+
path := OAuthProtectedResourcePrefix
220+
if resourcePath != "" && resourcePath != "/" {
221+
path = path + "/" + strings.TrimPrefix(resourcePath, "/")
222+
}
223+
224+
return fmt.Sprintf("%s://%s%s", scheme, host, path)
225+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"resource_name": "GitHub MCP Server",
3+
"resource": "{{.ResourceURL}}",
4+
"authorization_servers": ["{{.AuthorizationServer}}"],
5+
"bearer_methods_supported": ["header"],
6+
"scopes_supported": [
7+
"repo",
8+
"read:org",
9+
"read:user",
10+
"user:email",
11+
"read:packages",
12+
"write:packages",
13+
"read:project",
14+
"project",
15+
"gist",
16+
"notifications",
17+
"workflow",
18+
"codespace"
19+
]
20+
}

0 commit comments

Comments
 (0)