Skip to content

Commit e3c565a

Browse files
committed
initial oauth metadata implementation
1 parent bbaa877 commit e3c565a

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
@@ -101,6 +101,7 @@ var (
101101
Version: version,
102102
Host: viper.GetString("host"),
103103
Port: viper.GetInt("port"),
104+
BaseURL: viper.GetString("base-url"),
104105
ExportTranslations: viper.GetBool("export-translations"),
105106
EnableCommandLogging: viper.GetBool("enable-command-logging"),
106107
LogFilePath: viper.GetString("log-file"),
@@ -135,6 +136,7 @@ func init() {
135136
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
136137
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
137138
rootCmd.PersistentFlags().Int("port", 8082, "HTTP server port")
139+
rootCmd.PersistentFlags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)")
138140

139141
// Bind flag to viper
140142
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -151,6 +153,7 @@ func init() {
151153
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
152154
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
153155
_ = viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
156+
_ = viper.BindPFlag("base-url", rootCmd.PersistentFlags().Lookup("base-url"))
154157

155158
// Add subcommands
156159
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"
@@ -26,11 +27,13 @@ type Handler struct {
2627
t translations.TranslationHelperFunc
2728
githubMcpServerFactory GitHubMCPServerFactoryFunc
2829
inventoryFactoryFunc InventoryFactoryFunc
30+
oauthCfg *oauth.Config
2931
}
3032

3133
type HandlerOptions struct {
3234
GitHubMcpServerFactory GitHubMCPServerFactoryFunc
3335
InventoryFactory InventoryFactoryFunc
36+
OAuthConfig *oauth.Config
3437
}
3538

3639
type HandlerOption func(*HandlerOptions)
@@ -47,6 +50,12 @@ func WithInventoryFactory(f InventoryFactoryFunc) HandlerOption {
4750
}
4851
}
4952

53+
func WithOAuthConfig(cfg *oauth.Config) HandlerOption {
54+
return func(o *HandlerOptions) {
55+
o.OAuthConfig = cfg
56+
}
57+
}
58+
5059
func NewHTTPMcpHandler(
5160
ctx context.Context,
5261
cfg *ServerConfig,
@@ -77,6 +86,7 @@ func NewHTTPMcpHandler(
7786
t: t,
7887
githubMcpServerFactory: githubMcpServerFactory,
7988
inventoryFactoryFunc: inventoryFactory,
89+
oauthCfg: opts.OAuthConfig,
8090
}
8191
}
8292

@@ -134,7 +144,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
134144
Stateless: true,
135145
})
136146

137-
middleware.ExtractUserToken()(mcpHandler).ServeHTTP(w, r)
147+
middleware.ExtractUserToken(h.oauthCfg)(mcpHandler).ServeHTTP(w, r)
138148
}
139149

140150
func DefaultGitHubMCPServerFactory(r *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
@@ -39,14 +40,14 @@ var supportedThirdPartyTokenPrefixes = []string{
3940
// were 40 characters long and only contained the characters a-f and 0-9.
4041
var oldPatternRegexp = regexp.MustCompile(`\A[a-f0-9]{40}\z`)
4142

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