chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:57 +08:00
commit e30f8ba47c
533 changed files with 115926 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
package middleware
import (
"net/http"
"strings"
"github.com/github/github-mcp-server/pkg/http/headers"
)
// SetCorsHeaders is middleware that sets CORS headers to allow browser-based
// MCP clients to connect from any origin. This is safe because the server
// authenticates via bearer tokens (not cookies), so cross-origin requests
// cannot exploit ambient credentials.
func SetCorsHeaders(h http.Handler) http.Handler {
allowHeaders := strings.Join([]string{
"Content-Type",
"Mcp-Session-Id",
"Mcp-Protocol-Version",
"Last-Event-ID",
headers.AuthorizationHeader,
headers.MCPReadOnlyHeader,
headers.MCPToolsetsHeader,
headers.MCPToolsHeader,
headers.MCPExcludeToolsHeader,
headers.MCPFeaturesHeader,
headers.MCPLockdownHeader,
headers.MCPInsidersHeader,
}, ", ")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
w.Header().Set("Access-Control-Max-Age", "86400")
w.Header().Set("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate")
w.Header().Set("Access-Control-Allow-Headers", allowHeaders)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
h.ServeHTTP(w, r)
})
}
+45
View File
@@ -0,0 +1,45 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/github/github-mcp-server/pkg/http/middleware"
"github.com/stretchr/testify/assert"
)
func TestSetCorsHeaders(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware.SetCorsHeaders(inner)
t.Run("OPTIONS preflight returns 200 with CORS headers", func(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/", nil)
req.Header.Set("Origin", "http://localhost:6274")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Methods"), "POST")
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Authorization")
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Content-Type")
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Mcp-Session-Id")
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Lockdown")
assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Insiders")
assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id")
assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "WWW-Authenticate")
})
t.Run("POST request includes CORS headers", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Origin", "http://localhost:6274")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
})
}
+126
View File
@@ -0,0 +1,126 @@
package middleware
import (
"bytes"
"encoding/json"
"io"
"net/http"
ghcontext "github.com/github/github-mcp-server/pkg/context"
)
// mcpJSONRPCRequest represents the structure of an MCP JSON-RPC request.
// We only parse the fields needed for routing and optimization.
type mcpJSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params struct {
// For tools/call
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
// For prompts/get
// Name is shared with tools/call
// For resources/read
URI string `json:"uri,omitempty"`
} `json:"params"`
}
// WithMCPParse creates a middleware that parses MCP JSON-RPC requests early in the
// request lifecycle and stores the parsed information in the request context.
// This enables:
// - Registry filtering via ForMCPRequest (only register needed tools/resources/prompts)
// - Avoiding duplicate JSON parsing in downstream middlewares
// - Access to owner/repo for secret-scanning middleware
//
// The middleware reads the request body, parses it, restores the body for downstream
// handlers, and stores the parsed MCPMethodInfo in the request context.
func WithMCPParse() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Skip health check endpoints
if r.URL.Path == "/_ping" {
next.ServeHTTP(w, r)
return
}
// Only parse POST requests (MCP uses JSON-RPC over POST)
if r.Method != http.MethodPost {
next.ServeHTTP(w, r)
return
}
// Read the request body
body, err := io.ReadAll(r.Body)
if err != nil {
// Log but continue - don't block requests on parse errors
next.ServeHTTP(w, r)
return
}
// Restore the body for downstream handlers
r.Body = io.NopCloser(bytes.NewReader(body))
// Skip empty bodies
if len(body) == 0 {
next.ServeHTTP(w, r)
return
}
// Parse the JSON-RPC request
var mcpReq mcpJSONRPCRequest
err = json.Unmarshal(body, &mcpReq)
if err != nil {
// Log but continue - could be a non-MCP request or malformed JSON
next.ServeHTTP(w, r)
return
}
// Skip if not a valid JSON-RPC 2.0 request
if mcpReq.JSONRPC != "2.0" || mcpReq.Method == "" {
next.ServeHTTP(w, r)
return
}
// Build the MCPMethodInfo
methodInfo := &ghcontext.MCPMethodInfo{
Method: mcpReq.Method,
}
// Extract item name based on method type
switch mcpReq.Method {
case "tools/call":
methodInfo.ItemName = mcpReq.Params.Name
// Parse arguments if present
if len(mcpReq.Params.Arguments) > 0 {
var args map[string]any
err := json.Unmarshal(mcpReq.Params.Arguments, &args)
if err == nil {
methodInfo.Arguments = args
// Extract owner and repo if present
if owner, ok := args["owner"].(string); ok {
methodInfo.Owner = owner
}
if repo, ok := args["repo"].(string); ok {
methodInfo.Repo = repo
}
}
}
case "prompts/get":
methodInfo.ItemName = mcpReq.Params.Name
case "resources/read":
methodInfo.ItemName = mcpReq.Params.URI
default:
// Whatever
}
// Store the parsed info in context
ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo)
next.ServeHTTP(w, r.WithContext(ctx))
}
return http.HandlerFunc(fn)
}
}
+191
View File
@@ -0,0 +1,191 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithMCPParse(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
expectInfo bool
expectedMethod string
expectedItem string
expectedOwner string
expectedRepo string
expectedArgs map[string]any
}{
{
name: "health check path is skipped",
method: http.MethodPost,
path: "/_ping",
body: `{"jsonrpc":"2.0","method":"tools/list"}`,
expectInfo: false,
},
{
name: "GET request is skipped",
method: http.MethodGet,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":"tools/list"}`,
expectInfo: false,
},
{
name: "empty body is skipped",
method: http.MethodPost,
path: "/mcp",
body: "",
expectInfo: false,
},
{
name: "invalid JSON is skipped",
method: http.MethodPost,
path: "/mcp",
body: "not valid json",
expectInfo: false,
},
{
name: "non-JSON-RPC 2.0 is skipped",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"1.0","method":"tools/list"}`,
expectInfo: false,
},
{
name: "empty method is skipped",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":""}`,
expectInfo: false,
},
{
name: "tools/list parses method only",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":"tools/list"}`,
expectInfo: true,
expectedMethod: "tools/list",
},
{
name: "tools/call parses name",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents"}}`,
expectInfo: true,
expectedMethod: "tools/call",
expectedItem: "get_file_contents",
},
{
name: "tools/call parses owner and repo from arguments",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":{"owner":"github","repo":"github-mcp-server","path":"README.md"}}}`,
expectInfo: true,
expectedMethod: "tools/call",
expectedItem: "get_file_contents",
expectedOwner: "github",
expectedRepo: "github-mcp-server",
expectedArgs: map[string]any{"owner": "github", "repo": "github-mcp-server", "path": "README.md"},
},
{
name: "tools/call with invalid arguments JSON continues without args",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_file_contents","arguments":"not an object"}}`,
expectInfo: true,
expectedMethod: "tools/call",
expectedItem: "get_file_contents",
},
{
name: "prompts/get parses name",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":"prompts/get","params":{"name":"my_prompt"}}`,
expectInfo: true,
expectedMethod: "prompts/get",
expectedItem: "my_prompt",
},
{
name: "resources/read parses URI as item name",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":"resources/read","params":{"uri":"repo://github/github-mcp-server"}}`,
expectInfo: true,
expectedMethod: "resources/read",
expectedItem: "repo://github/github-mcp-server",
},
{
name: "initialize method parses correctly",
method: http.MethodPost,
path: "/mcp",
body: `{"jsonrpc":"2.0","method":"initialize","params":{"capabilities":{}}}`,
expectInfo: true,
expectedMethod: "initialize",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedInfo *ghcontext.MCPMethodInfo
var infoCaptured bool
// Create a handler that captures the MCPMethodInfo from context
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
capturedInfo, infoCaptured = ghcontext.MCPMethod(r.Context())
})
middleware := WithMCPParse()
handler := middleware(nextHandler)
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if tt.expectInfo {
require.True(t, infoCaptured, "MCPMethodInfo should be present in context")
require.NotNil(t, capturedInfo)
assert.Equal(t, tt.expectedMethod, capturedInfo.Method)
assert.Equal(t, tt.expectedItem, capturedInfo.ItemName)
assert.Equal(t, tt.expectedOwner, capturedInfo.Owner)
assert.Equal(t, tt.expectedRepo, capturedInfo.Repo)
if tt.expectedArgs != nil {
assert.Equal(t, tt.expectedArgs, capturedInfo.Arguments)
}
} else {
assert.False(t, infoCaptured, "MCPMethodInfo should not be present in context")
}
})
}
}
func TestWithMCPParse_BodyRestoration(t *testing.T) {
originalBody := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test_tool"}}`
var capturedBody string
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
capturedBody = string(body)
})
middleware := WithMCPParse()
handler := middleware(nextHandler)
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(originalBody))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, originalBody, capturedBody, "body should be restored for downstream handlers")
}
+54
View File
@@ -0,0 +1,54 @@
package middleware
import (
"log/slog"
"net/http"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/utils"
)
// WithPATScopes is a middleware that fetches and stores scopes for classic Personal Access Tokens (PATs) in the request context.
func WithPATScopes(logger *slog.Logger, scopeFetcher scopes.FetcherInterface) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
if !ok || tokenInfo == nil {
logger.Warn("no token info found in context")
next.ServeHTTP(w, r)
return
}
// Fetch token scopes for scope-based tool filtering (PAT tokens only)
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
// Fine-grained PATs and other token types don't support this, so we skip filtering.
if tokenInfo.TokenType == utils.TokenTypePersonalAccessToken {
existingScopes, ok := ghcontext.GetTokenScopes(ctx)
if ok {
logger.Debug("using existing scopes from context", "scopes", existingScopes)
next.ServeHTTP(w, r)
return
}
scopesList, err := scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token)
if err != nil {
logger.Warn("failed to fetch PAT scopes", "error", err)
next.ServeHTTP(w, r)
return
}
// Store fetched scopes in context for downstream use
ctx = ghcontext.WithTokenScopes(ctx, scopesList)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
+190
View File
@@ -0,0 +1,190 @@
package middleware
import (
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockScopeFetcher is a mock implementation of scopes.FetcherInterface
type mockScopeFetcher struct {
scopes []string
err error
}
func (m *mockScopeFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
return m.scopes, m.err
}
func TestWithPATScopes(t *testing.T) {
logger := slog.Default()
tests := []struct {
name string
tokenInfo *ghcontext.TokenInfo
fetcherScopes []string
fetcherErr error
expectScopesFetched bool
expectedScopes []string
expectNextHandlerCalled bool
}{
{
name: "no token info in context calls next handler",
tokenInfo: nil,
expectScopesFetched: false,
expectedScopes: nil,
expectNextHandlerCalled: true,
},
{
name: "non-PAT token type skips scope fetching",
tokenInfo: &ghcontext.TokenInfo{
Token: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
TokenType: utils.TokenTypeOAuthAccessToken,
},
expectScopesFetched: false,
expectedScopes: nil,
expectNextHandlerCalled: true,
},
{
name: "fine-grained PAT skips scope fetching",
tokenInfo: &ghcontext.TokenInfo{
Token: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
TokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
},
expectScopesFetched: false,
expectedScopes: nil,
expectNextHandlerCalled: true,
},
{
name: "classic PAT fetches and stores scopes",
tokenInfo: &ghcontext.TokenInfo{
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
TokenType: utils.TokenTypePersonalAccessToken,
},
fetcherScopes: []string{"repo", "user", "read:org"},
expectScopesFetched: true,
expectedScopes: []string{"repo", "user", "read:org"},
expectNextHandlerCalled: true,
},
{
name: "classic PAT with empty scopes",
tokenInfo: &ghcontext.TokenInfo{
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
TokenType: utils.TokenTypePersonalAccessToken,
},
fetcherScopes: []string{},
expectScopesFetched: true,
expectedScopes: []string{},
expectNextHandlerCalled: true,
},
{
name: "fetcher error calls next handler without scopes",
tokenInfo: &ghcontext.TokenInfo{
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
TokenType: utils.TokenTypePersonalAccessToken,
},
fetcherErr: errors.New("network error"),
expectScopesFetched: false,
expectedScopes: nil,
expectNextHandlerCalled: true,
},
{
name: "old-style PAT (40 hex chars) fetches scopes",
tokenInfo: &ghcontext.TokenInfo{
Token: "0123456789abcdef0123456789abcdef01234567",
TokenType: utils.TokenTypePersonalAccessToken,
},
fetcherScopes: []string{"repo"},
expectScopesFetched: true,
expectedScopes: []string{"repo"},
expectNextHandlerCalled: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedScopes []string
var scopesFound bool
var nextHandlerCalled bool
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextHandlerCalled = true
capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context())
w.WriteHeader(http.StatusOK)
})
fetcher := &mockScopeFetcher{
scopes: tt.fetcherScopes,
err: tt.fetcherErr,
}
middleware := WithPATScopes(logger, fetcher)
handler := middleware(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// Set up context with token info if provided
if tt.tokenInfo != nil {
ctx := ghcontext.WithTokenInfo(req.Context(), tt.tokenInfo)
req = req.WithContext(ctx)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, tt.expectNextHandlerCalled, nextHandlerCalled, "next handler called mismatch")
if tt.expectNextHandlerCalled {
assert.Equal(t, tt.expectScopesFetched, scopesFound, "scopes found mismatch")
assert.Equal(t, tt.expectedScopes, capturedScopes)
}
})
}
}
func TestWithPATScopes_PreservesExistingTokenInfo(t *testing.T) {
logger := slog.Default()
var capturedTokenInfo *ghcontext.TokenInfo
var capturedScopes []string
var scopesFound bool
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedTokenInfo, _ = ghcontext.GetTokenInfo(r.Context())
capturedScopes, scopesFound = ghcontext.GetTokenScopes(r.Context())
w.WriteHeader(http.StatusOK)
})
fetcher := &mockScopeFetcher{
scopes: []string{"repo", "user"},
}
originalTokenInfo := &ghcontext.TokenInfo{
Token: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
TokenType: utils.TokenTypePersonalAccessToken,
}
middleware := WithPATScopes(logger, fetcher)
handler := middleware(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
ctx := ghcontext.WithTokenInfo(req.Context(), originalTokenInfo)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.NotNil(t, capturedTokenInfo)
assert.Equal(t, originalTokenInfo.Token, capturedTokenInfo.Token)
assert.Equal(t, originalTokenInfo.TokenType, capturedTokenInfo.TokenType)
assert.True(t, scopesFound)
assert.Equal(t, []string{"repo", "user"}, capturedScopes)
}
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"net/http"
"slices"
"strings"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/headers"
)
// WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context.
// This includes readonly mode, toolsets, tools, lockdown mode, insiders mode, and feature flags.
func WithRequestConfig(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Readonly mode
if relaxedParseBool(r.Header.Get(headers.MCPReadOnlyHeader)) {
ctx = ghcontext.WithReadonly(ctx, true)
}
// Toolsets
if toolsets := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsetsHeader)); len(toolsets) > 0 {
ctx = ghcontext.WithToolsets(ctx, toolsets)
}
// Tools
if tools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPToolsHeader)); len(tools) > 0 {
ctx = ghcontext.WithTools(ctx, tools)
}
// Lockdown mode
if relaxedParseBool(r.Header.Get(headers.MCPLockdownHeader)) {
ctx = ghcontext.WithLockdownMode(ctx, true)
}
// Excluded tools
if excludeTools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPExcludeToolsHeader)); len(excludeTools) > 0 {
ctx = ghcontext.WithExcludeTools(ctx, excludeTools)
}
// Insiders mode
if relaxedParseBool(r.Header.Get(headers.MCPInsidersHeader)) {
ctx = ghcontext.WithInsidersMode(ctx, true)
}
// Feature flags
if features := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader)); len(features) > 0 {
ctx = ghcontext.WithHeaderFeatures(ctx, features)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// relaxedParseBool parses a string into a boolean value, treating various
// common false values or empty strings as false, and everything else as true.
// It is case-insensitive and trims whitespace.
func relaxedParseBool(s string) bool {
s = strings.TrimSpace(strings.ToLower(s))
falseValues := []string{"", "false", "0", "no", "off", "n", "f"}
return !slices.Contains(falseValues, s)
}
+145
View File
@@ -0,0 +1,145 @@
package middleware
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/utils"
)
// WithScopeChallenge creates a new middleware that determines if an OAuth request contains sufficient scopes to
// complete the request and returns a scope challenge if not.
func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInterface) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Skip health check endpoints
if r.URL.Path == "/_ping" {
next.ServeHTTP(w, r)
return
}
// Get user from context
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
if !ok {
next.ServeHTTP(w, r)
return
}
// Only check OAuth tokens - scope challenge allows OAuth apps to request additional scopes
if tokenInfo.TokenType != utils.TokenTypeOAuthAccessToken {
next.ServeHTTP(w, r)
return
}
// Try to use pre-parsed MCP method info first (performance optimization)
// This avoids re-parsing the JSON body if WithMCPParse middleware ran earlier
var toolName string
if methodInfo, ok := ghcontext.MCPMethod(ctx); ok && methodInfo != nil {
// Only check tools/call requests
if methodInfo.Method != "tools/call" {
next.ServeHTTP(w, r)
return
}
toolName = methodInfo.ItemName
} else {
// Fallback: parse the request body directly
body, err := io.ReadAll(r.Body)
if err != nil {
next.ServeHTTP(w, r)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
var mcpRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params struct {
Name string `json:"name,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
} `json:"params"`
}
err = json.Unmarshal(body, &mcpRequest)
if err != nil {
next.ServeHTTP(w, r)
return
}
// Only check tools/call requests
if mcpRequest.Method != "tools/call" {
next.ServeHTTP(w, r)
return
}
toolName = mcpRequest.Params.Name
}
toolScopeInfo, err := scopes.GetToolScopeInfo(toolName)
if err != nil {
next.ServeHTTP(w, r)
return
}
// If tool not found in scope map, allow the request
if toolScopeInfo == nil {
next.ServeHTTP(w, r)
return
}
// Get OAuth scopes for Token. First check if scopes are already in context, then fetch from GitHub if not present.
// This allows Remote Server to pass scope info to avoid redundant GitHub API calls.
activeScopes, ok := ghcontext.GetTokenScopes(ctx)
if !ok || (len(activeScopes) == 0 && tokenInfo.Token != "") {
activeScopes, err = scopeFetcher.FetchTokenScopes(ctx, tokenInfo.Token)
if err != nil {
next.ServeHTTP(w, r)
return
}
}
// Store active scopes in context for downstream use
ctx = ghcontext.WithTokenScopes(ctx, activeScopes)
r = r.WithContext(ctx)
// Check if user has the required scopes
if toolScopeInfo.HasAcceptedScope(activeScopes...) {
next.ServeHTTP(w, r)
return
}
// User lacks required scopes - get the scopes they need
requiredScopes := toolScopeInfo.GetRequiredScopesSlice()
// Build the resource metadata URL using the shared utility
// GetEffectiveResourcePath returns the original path (e.g., /mcp or /mcp/x/all)
// which is used to construct the well-known OAuth protected resource URL
resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
// Build recommended scopes: existing scopes + required scopes
recommendedScopes := make([]string, 0, len(activeScopes)+len(requiredScopes))
recommendedScopes = append(recommendedScopes, activeScopes...)
recommendedScopes = append(recommendedScopes, requiredScopes...)
// Build the WWW-Authenticate header value
wwwAuthenticateHeader := fmt.Sprintf(`Bearer error="insufficient_scope", scope=%q, resource_metadata=%q, error_description=%q`,
strings.Join(recommendedScopes, " "),
resourceMetadataURL,
"Additional scopes required: "+strings.Join(requiredScopes, ", "),
)
// Send scope challenge response with the superset of existing and required scopes
w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader)
http.Error(w, "Forbidden: insufficient scopes", http.StatusForbidden)
}
return http.HandlerFunc(fn)
}
}
+56
View File
@@ -0,0 +1,56 @@
package middleware
import (
"errors"
"fmt"
"net/http"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/github/github-mcp-server/pkg/utils"
)
func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Check if token info already exists in context, if it does, skip extraction.
// In remote setup, we may have already extracted token info earlier.
if _, ok := ghcontext.GetTokenInfo(ctx); ok {
// Token info already exists in context, skip extraction
next.ServeHTTP(w, r)
return
}
tokenType, token, err := utils.ParseAuthorizationHeader(r)
if err != nil {
// For missing Authorization header, return 401 with WWW-Authenticate header per MCP spec
if errors.Is(err, utils.ErrMissingAuthorizationHeader) {
sendAuthChallenge(w, r, oauthCfg)
return
}
// For other auth errors (bad format, unsupported), return 400
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx = ghcontext.WithTokenInfo(ctx, &ghcontext.TokenInfo{
Token: token,
TokenType: tokenType,
})
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
}
// sendAuthChallenge sends a 401 Unauthorized response with WWW-Authenticate header
// containing the OAuth protected resource metadata URL as per RFC 6750 and MCP spec.
func sendAuthChallenge(w http.ResponseWriter, r *http.Request, oauthCfg *oauth.Config) {
resourcePath := oauth.ResolveResourcePath(r, oauthCfg)
resourceMetadataURL := oauth.BuildResourceMetadataURL(r, oauthCfg, resourcePath)
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer resource_metadata=%q`, resourceMetadataURL))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
+321
View File
@@ -0,0 +1,321 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExtractUserToken(t *testing.T) {
oauthCfg := &oauth.Config{
BaseURL: "https://example.com",
AuthorizationServer: "https://github.com/login/oauth",
}
tests := []struct {
name string
authHeader string
expectedStatusCode int
expectedTokenType utils.TokenType
expectedToken string
expectTokenInfo bool
expectWWWAuth bool
}{
// Missing authorization header
{
name: "missing Authorization header returns 401 with WWW-Authenticate",
authHeader: "",
expectedStatusCode: http.StatusUnauthorized,
expectTokenInfo: false,
expectWWWAuth: true,
},
// Personal Access Token (classic) - ghp_ prefix
{
name: "personal access token (classic) with Bearer prefix",
authHeader: "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypePersonalAccessToken,
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
{
name: "personal access token (classic) with bearer lowercase",
authHeader: "bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypePersonalAccessToken,
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
{
name: "personal access token (classic) without Bearer prefix",
authHeader: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypePersonalAccessToken,
expectedToken: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
// Fine-grained Personal Access Token - github_pat_ prefix
{
name: "fine-grained personal access token with Bearer prefix",
authHeader: "Bearer github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
{
name: "fine-grained personal access token without Bearer prefix",
authHeader: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypeFineGrainedPersonalAccessToken,
expectedToken: "github_pat_xxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
// OAuth Access Token - gho_ prefix
{
name: "OAuth access token with Bearer prefix",
authHeader: "Bearer gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypeOAuthAccessToken,
expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
{
name: "OAuth access token without Bearer prefix",
authHeader: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypeOAuthAccessToken,
expectedToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
// User-to-Server GitHub App Token - ghu_ prefix
{
name: "user-to-server GitHub App token with Bearer prefix",
authHeader: "Bearer ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken,
expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
{
name: "user-to-server GitHub App token without Bearer prefix",
authHeader: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypeUserToServerGitHubAppToken,
expectedToken: "ghu_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
// Server-to-Server GitHub App Token (installation token) - ghs_ prefix
{
name: "server-to-server GitHub App token with Bearer prefix",
authHeader: "Bearer ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken,
expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
{
name: "server-to-server GitHub App token without Bearer prefix",
authHeader: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypeServerToServerGitHubAppToken,
expectedToken: "ghs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
expectTokenInfo: true,
},
// Old-style Personal Access Token (40 hex characters, pre-2021)
{
name: "old-style personal access token (40 hex chars) with Bearer prefix",
authHeader: "Bearer 0123456789abcdef0123456789abcdef01234567",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypePersonalAccessToken,
expectedToken: "0123456789abcdef0123456789abcdef01234567",
expectTokenInfo: true,
},
{
name: "old-style personal access token (40 hex chars) without Bearer prefix",
authHeader: "0123456789abcdef0123456789abcdef01234567",
expectedStatusCode: http.StatusOK,
expectedTokenType: utils.TokenTypePersonalAccessToken,
expectedToken: "0123456789abcdef0123456789abcdef01234567",
expectTokenInfo: true,
},
// Error cases
{
name: "unsupported GitHub-Bearer header returns 400",
authHeader: "GitHub-Bearer some_encrypted_token",
expectedStatusCode: http.StatusBadRequest,
expectTokenInfo: false,
},
{
name: "invalid token format returns 400",
authHeader: "Bearer invalid_token_format",
expectedStatusCode: http.StatusBadRequest,
expectTokenInfo: false,
},
{
name: "unrecognized prefix returns 400",
authHeader: "Bearer xyz_notavalidprefix",
expectedStatusCode: http.StatusBadRequest,
expectTokenInfo: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedTokenInfo *ghcontext.TokenInfo
var tokenInfoCaptured bool
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context())
w.WriteHeader(http.StatusOK)
})
middleware := ExtractUserToken(oauthCfg)
handler := middleware(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
if tt.authHeader != "" {
req.Header.Set(headers.AuthorizationHeader, tt.authHeader)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, tt.expectedStatusCode, rr.Code)
if tt.expectWWWAuth {
wwwAuth := rr.Header().Get("WWW-Authenticate")
assert.NotEmpty(t, wwwAuth, "expected WWW-Authenticate header")
assert.Contains(t, wwwAuth, "Bearer resource_metadata=")
}
if tt.expectTokenInfo {
require.True(t, tokenInfoCaptured, "expected TokenInfo to be present in context")
require.NotNil(t, capturedTokenInfo)
assert.Equal(t, tt.expectedTokenType, capturedTokenInfo.TokenType)
assert.Equal(t, tt.expectedToken, capturedTokenInfo.Token)
} else {
assert.False(t, tokenInfoCaptured, "expected no TokenInfo in context")
}
})
}
}
func TestExtractUserToken_NilOAuthConfig(t *testing.T) {
var capturedTokenInfo *ghcontext.TokenInfo
var tokenInfoCaptured bool
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedTokenInfo, tokenInfoCaptured = ghcontext.GetTokenInfo(r.Context())
w.WriteHeader(http.StatusOK)
})
middleware := ExtractUserToken(nil)
handler := middleware(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
require.True(t, tokenInfoCaptured)
require.NotNil(t, capturedTokenInfo)
assert.Equal(t, utils.TokenTypePersonalAccessToken, capturedTokenInfo.TokenType)
}
func TestExtractUserToken_MissingAuthHeader_WWWAuthenticateFormat(t *testing.T) {
oauthCfg := &oauth.Config{
BaseURL: "https://api.example.com",
AuthorizationServer: "https://github.com/login/oauth",
ResourcePath: "/mcp",
}
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
middleware := ExtractUserToken(oauthCfg)
handler := middleware(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// No Authorization header
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
wwwAuth := rr.Header().Get("WWW-Authenticate")
assert.NotEmpty(t, wwwAuth)
assert.Contains(t, wwwAuth, "Bearer")
assert.Contains(t, wwwAuth, "resource_metadata=")
assert.Contains(t, wwwAuth, "/.well-known/oauth-protected-resource")
}
func TestSendAuthChallenge(t *testing.T) {
tests := []struct {
name string
oauthCfg *oauth.Config
requestPath string
expectedContains []string
}{
{
name: "with base URL configured",
oauthCfg: &oauth.Config{
BaseURL: "https://mcp.example.com",
},
requestPath: "/api/test",
expectedContains: []string{
"Bearer",
"resource_metadata=",
"https://mcp.example.com/.well-known/oauth-protected-resource",
},
},
{
name: "with nil config uses request host",
oauthCfg: nil,
requestPath: "/api/test",
expectedContains: []string{
"Bearer",
"resource_metadata=",
"/.well-known/oauth-protected-resource",
},
},
{
name: "with resource path configured",
oauthCfg: &oauth.Config{
BaseURL: "https://mcp.example.com",
ResourcePath: "/mcp",
},
requestPath: "/api/test",
expectedContains: []string{
"Bearer",
"resource_metadata=",
"/mcp",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil)
sendAuthChallenge(rr, req, tt.oauthCfg)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
wwwAuth := rr.Header().Get("WWW-Authenticate")
for _, expected := range tt.expectedContains {
assert.Contains(t, wwwAuth, expected)
}
})
}
}