chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/github"
|
||||
"github.com/github/github-mcp-server/pkg/http/middleware"
|
||||
"github.com/github/github-mcp-server/pkg/http/oauth"
|
||||
"github.com/github/github-mcp-server/pkg/inventory"
|
||||
"github.com/github/github-mcp-server/pkg/scopes"
|
||||
"github.com/github/github-mcp-server/pkg/translations"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error)
|
||||
|
||||
// GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance.
|
||||
// middleware are applied AFTER the default GitHub MCP Server middlewares (like error context injection)
|
||||
type GitHubMCPServerFactoryFunc func(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error)
|
||||
|
||||
type Handler struct {
|
||||
ctx context.Context
|
||||
config *ServerConfig
|
||||
deps github.ToolDependencies
|
||||
logger *slog.Logger
|
||||
apiHosts utils.APIHostResolver
|
||||
t translations.TranslationHelperFunc
|
||||
githubMcpServerFactory GitHubMCPServerFactoryFunc
|
||||
inventoryFactoryFunc InventoryFactoryFunc
|
||||
oauthCfg *oauth.Config
|
||||
scopeFetcher scopes.FetcherInterface
|
||||
schemaCache *mcp.SchemaCache
|
||||
}
|
||||
|
||||
type HandlerOptions struct {
|
||||
GitHubMcpServerFactory GitHubMCPServerFactoryFunc
|
||||
InventoryFactory InventoryFactoryFunc
|
||||
OAuthConfig *oauth.Config
|
||||
ScopeFetcher scopes.FetcherInterface
|
||||
FeatureChecker inventory.FeatureFlagChecker
|
||||
}
|
||||
|
||||
type HandlerOption func(*HandlerOptions)
|
||||
|
||||
func WithScopeFetcher(f scopes.FetcherInterface) HandlerOption {
|
||||
return func(o *HandlerOptions) {
|
||||
o.ScopeFetcher = f
|
||||
}
|
||||
}
|
||||
|
||||
func WithGitHubMCPServerFactory(f GitHubMCPServerFactoryFunc) HandlerOption {
|
||||
return func(o *HandlerOptions) {
|
||||
o.GitHubMcpServerFactory = f
|
||||
}
|
||||
}
|
||||
|
||||
func WithInventoryFactory(f InventoryFactoryFunc) HandlerOption {
|
||||
return func(o *HandlerOptions) {
|
||||
o.InventoryFactory = f
|
||||
}
|
||||
}
|
||||
|
||||
func WithOAuthConfig(cfg *oauth.Config) HandlerOption {
|
||||
return func(o *HandlerOptions) {
|
||||
o.OAuthConfig = cfg
|
||||
}
|
||||
}
|
||||
|
||||
func WithFeatureChecker(checker inventory.FeatureFlagChecker) HandlerOption {
|
||||
return func(o *HandlerOptions) {
|
||||
o.FeatureChecker = checker
|
||||
}
|
||||
}
|
||||
|
||||
func NewHTTPMcpHandler(
|
||||
ctx context.Context,
|
||||
cfg *ServerConfig,
|
||||
deps github.ToolDependencies,
|
||||
t translations.TranslationHelperFunc,
|
||||
logger *slog.Logger,
|
||||
apiHost utils.APIHostResolver,
|
||||
options ...HandlerOption) *Handler {
|
||||
opts := &HandlerOptions{}
|
||||
for _, o := range options {
|
||||
o(opts)
|
||||
}
|
||||
|
||||
githubMcpServerFactory := opts.GitHubMcpServerFactory
|
||||
if githubMcpServerFactory == nil {
|
||||
githubMcpServerFactory = DefaultGitHubMCPServerFactory
|
||||
}
|
||||
|
||||
scopeFetcher := opts.ScopeFetcher
|
||||
if scopeFetcher == nil {
|
||||
scopeFetcher = scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
|
||||
}
|
||||
|
||||
inventoryFactory := opts.InventoryFactory
|
||||
if inventoryFactory == nil {
|
||||
inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker, scopeFetcher)
|
||||
}
|
||||
|
||||
// Create a shared schema cache to avoid repeated JSON schema reflection
|
||||
// when a new MCP Server is created per request in stateless mode.
|
||||
schemaCache := mcp.NewSchemaCache()
|
||||
|
||||
return &Handler{
|
||||
ctx: ctx,
|
||||
config: cfg,
|
||||
deps: deps,
|
||||
logger: logger,
|
||||
apiHosts: apiHost,
|
||||
t: t,
|
||||
githubMcpServerFactory: githubMcpServerFactory,
|
||||
inventoryFactoryFunc: inventoryFactory,
|
||||
oauthCfg: opts.OAuthConfig,
|
||||
scopeFetcher: scopeFetcher,
|
||||
schemaCache: schemaCache,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterMiddleware(r chi.Router) {
|
||||
r.Use(
|
||||
middleware.ExtractUserToken(h.oauthCfg),
|
||||
middleware.WithRequestConfig,
|
||||
middleware.WithMCPParse(),
|
||||
middleware.WithPATScopes(h.logger, h.scopeFetcher),
|
||||
)
|
||||
|
||||
if h.config.ScopeChallenge {
|
||||
r.Use(middleware.WithScopeChallenge(h.oauthCfg, h.scopeFetcher))
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers the routes for the MCP server
|
||||
// URL-based values take precedence over header-based values
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
// Base routes
|
||||
r.Mount("/", h)
|
||||
r.With(withReadonly).Mount("/readonly", h)
|
||||
r.With(withInsiders).Mount("/insiders", h)
|
||||
r.With(withReadonly, withInsiders).Mount("/readonly/insiders", h)
|
||||
|
||||
// Toolset routes
|
||||
r.With(withToolset).Mount("/x/{toolset}", h)
|
||||
r.With(withToolset, withReadonly).Mount("/x/{toolset}/readonly", h)
|
||||
r.With(withToolset, withInsiders).Mount("/x/{toolset}/insiders", h)
|
||||
r.With(withToolset, withReadonly, withInsiders).Mount("/x/{toolset}/readonly/insiders", h)
|
||||
}
|
||||
|
||||
// withReadonly is middleware that sets readonly mode in the request context
|
||||
func withReadonly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := ghcontext.WithReadonly(r.Context(), true)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// withToolset is middleware that extracts the toolset from the URL and sets it in the request context
|
||||
func withToolset(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
toolset := chi.URLParam(r, "toolset")
|
||||
ctx := ghcontext.WithToolsets(r.Context(), []string{toolset})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// withInsiders is middleware that sets insiders mode in the request context
|
||||
func withInsiders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := ghcontext.WithInsidersMode(r.Context(), true)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
inv, err := h.inventoryFactoryFunc(r)
|
||||
if err != nil {
|
||||
if errors.Is(err, inventory.ErrUnknownTools) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if _, writeErr := w.Write([]byte(err.Error())); writeErr != nil {
|
||||
h.logger.Error("failed to write response", "error", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
invToUse := inv
|
||||
if methodInfo, ok := ghcontext.MCPMethod(r.Context()); ok && methodInfo != nil {
|
||||
invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName)
|
||||
}
|
||||
|
||||
ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{
|
||||
Version: h.config.Version,
|
||||
Translator: h.t,
|
||||
ContentWindowSize: h.config.ContentWindowSize,
|
||||
Logger: h.logger,
|
||||
RepoAccessTTL: h.config.RepoAccessCacheTTL,
|
||||
// Explicitly set empty capabilities. inv.ForMCPRequest currently returns nothing for Initialize.
|
||||
ServerOptions: []github.MCPServerOption{
|
||||
func(so *mcp.ServerOptions) {
|
||||
so.Capabilities = &mcp.ServerCapabilities{
|
||||
Tools: &mcp.ToolCapabilities{},
|
||||
Resources: &mcp.ResourceCapabilities{},
|
||||
Prompts: &mcp.PromptCapabilities{},
|
||||
}
|
||||
so.SchemaCache = h.schemaCache
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Cross-origin protection is intentionally left unset: this server
|
||||
// authenticates via bearer tokens (not cookies), so Sec-Fetch-Site CSRF
|
||||
// checks are unnecessary and would block browser-based MCP clients. As of
|
||||
// go-sdk v1.6.0 a nil CrossOriginProtection disables the check by default;
|
||||
// see also PR #2359.
|
||||
mcpHandler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
|
||||
return ghServer
|
||||
}, &mcp.StreamableHTTPOptions{
|
||||
Stateless: true,
|
||||
})
|
||||
|
||||
mcpHandler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies, inventory *inventory.Inventory, cfg *github.MCPServerConfig) (*mcp.Server, error) {
|
||||
return github.NewMCPServer(r.Context(), cfg, deps, inventory)
|
||||
}
|
||||
|
||||
// DefaultInventoryFactory creates the default inventory factory for HTTP mode.
|
||||
// When the ServerConfig includes static flags (--toolsets, --read-only, etc.),
|
||||
// a static inventory is built once at factory creation to pre-filter the tool
|
||||
// universe. Per-request headers can only narrow within these bounds.
|
||||
func DefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc {
|
||||
// Build the static tool/resource/prompt universe from CLI flags.
|
||||
// This is done once at startup and captured in the closure.
|
||||
staticTools, staticResources, staticPrompts := buildStaticInventory(cfg, t)
|
||||
hasStaticFilters := hasStaticConfig(cfg)
|
||||
|
||||
// Pre-compute valid tool names for filtering per-request tool headers.
|
||||
// When a request asks for a tool by name that's been excluded from the
|
||||
// static universe, we silently drop it rather than returning an error.
|
||||
validToolNames := make(map[string]bool, len(staticTools))
|
||||
for i := range staticTools {
|
||||
validToolNames[staticTools[i].Tool.Name] = true
|
||||
}
|
||||
|
||||
return func(r *http.Request) (*inventory.Inventory, error) {
|
||||
b := inventory.NewBuilder().
|
||||
SetTools(staticTools).
|
||||
SetResources(staticResources).
|
||||
SetPrompts(staticPrompts).
|
||||
WithDeprecatedAliases(github.DeprecatedToolAliases).
|
||||
WithFeatureChecker(featureChecker)
|
||||
|
||||
// When static flags constrain the universe, default to showing
|
||||
// everything within those bounds (per-request filters narrow further).
|
||||
// When no static flags are set, preserve existing behavior where
|
||||
// the default toolsets apply.
|
||||
if hasStaticFilters {
|
||||
b = b.WithToolsets([]string{"all"})
|
||||
}
|
||||
|
||||
// Static read-only is an upper bound — enforce before request filters
|
||||
if cfg.ReadOnly {
|
||||
b = b.WithReadOnly(true)
|
||||
}
|
||||
|
||||
// Filter request tool names to only those in the static universe,
|
||||
// so requests for statically-excluded tools degrade gracefully.
|
||||
if hasStaticFilters {
|
||||
r = filterRequestTools(r, validToolNames)
|
||||
}
|
||||
|
||||
b = InventoryFiltersForRequest(r, b)
|
||||
b = PATScopeFilter(b, r, scopeFetcher)
|
||||
|
||||
b.WithServerInstructions()
|
||||
|
||||
return b.Build()
|
||||
}
|
||||
}
|
||||
|
||||
// filterRequestTools returns a shallow copy of the request with any per-request
|
||||
// tool names (from X-MCP-Tools header) filtered to only include tools that exist
|
||||
// in validNames. This ensures requests for statically-excluded tools are silently
|
||||
// ignored rather than causing build errors.
|
||||
func filterRequestTools(r *http.Request, validNames map[string]bool) *http.Request {
|
||||
reqTools := ghcontext.GetTools(r.Context())
|
||||
if len(reqTools) == 0 {
|
||||
return r
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(reqTools))
|
||||
for _, name := range reqTools {
|
||||
if validNames[name] {
|
||||
filtered = append(filtered, name)
|
||||
}
|
||||
}
|
||||
ctx := ghcontext.WithTools(r.Context(), filtered)
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
// hasStaticConfig returns true if any static filtering flags are set on the ServerConfig.
|
||||
func hasStaticConfig(cfg *ServerConfig) bool {
|
||||
return cfg.ReadOnly ||
|
||||
cfg.EnabledToolsets != nil ||
|
||||
cfg.EnabledTools != nil ||
|
||||
len(cfg.ExcludeTools) > 0
|
||||
}
|
||||
|
||||
// buildStaticInventory pre-filters the full tool/resource/prompt universe using
|
||||
// the static config (toolsets, read-only, --tools, --exclude-tools). It does
|
||||
// NOT install a feature checker: HTTP feature flags can come from per-request
|
||||
// context (/insiders, X-MCP-Features), so dual-name feature variants — for
|
||||
// example the granular issues/PRs tools that share a name with their
|
||||
// non-granular siblings — must be carried through to the per-request
|
||||
// inventory, which then installs a checker and resolves the flag before
|
||||
// registering tools with the MCP server.
|
||||
func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFunc) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) {
|
||||
if !hasStaticConfig(cfg) {
|
||||
return github.AllTools(t), github.AllResources(t), github.AllPrompts(t)
|
||||
}
|
||||
|
||||
b := github.NewInventory(t).
|
||||
WithReadOnly(cfg.ReadOnly).
|
||||
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools))
|
||||
|
||||
if len(cfg.EnabledTools) > 0 {
|
||||
b = b.WithTools(github.CleanTools(cfg.EnabledTools))
|
||||
}
|
||||
|
||||
if len(cfg.ExcludeTools) > 0 {
|
||||
b = b.WithExcludeTools(cfg.ExcludeTools)
|
||||
}
|
||||
|
||||
inv, err := b.Build()
|
||||
if err != nil {
|
||||
// Fall back to all tools if there's an error (e.g. unknown tool names).
|
||||
// The error will surface again at per-request time if relevant.
|
||||
return github.AllTools(t), github.AllResources(t), github.AllPrompts(t)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx)
|
||||
}
|
||||
|
||||
// InventoryFiltersForRequest applies filters to the inventory builder
|
||||
// based on the request context and headers.
|
||||
// MCP Apps UI metadata is handled by the builder via the feature checker —
|
||||
// no need to check headers here.
|
||||
func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder {
|
||||
ctx := r.Context()
|
||||
|
||||
if ghcontext.IsReadonly(ctx) {
|
||||
builder = builder.WithReadOnly(true)
|
||||
}
|
||||
|
||||
toolsets := ghcontext.GetToolsets(ctx)
|
||||
tools := ghcontext.GetTools(ctx)
|
||||
|
||||
if len(toolsets) > 0 {
|
||||
builder = builder.WithToolsets(github.ResolvedEnabledToolsets(toolsets, tools))
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
if len(toolsets) == 0 {
|
||||
builder = builder.WithToolsets([]string{})
|
||||
}
|
||||
builder = builder.WithTools(github.CleanTools(tools))
|
||||
}
|
||||
|
||||
if excluded := ghcontext.GetExcludeTools(ctx); len(excluded) > 0 {
|
||||
builder = builder.WithExcludeTools(excluded)
|
||||
}
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
func PATScopeFilter(b *inventory.Builder, r *http.Request, fetcher scopes.FetcherInterface) *inventory.Builder {
|
||||
ctx := r.Context()
|
||||
|
||||
tokenInfo, ok := ghcontext.GetTokenInfo(ctx)
|
||||
if !ok || tokenInfo == nil {
|
||||
return b
|
||||
}
|
||||
|
||||
// Scopes should have already been fetched by the WithPATScopes middleware.
|
||||
// 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 {
|
||||
// Check if scopes are already in context (should be set by WithPATScopes). If not, fetch them.
|
||||
existingScopes, ok := ghcontext.GetTokenScopes(ctx)
|
||||
if ok {
|
||||
return b.WithFilter(github.CreateToolScopeFilter(existingScopes))
|
||||
}
|
||||
|
||||
scopesList, err := fetcher.FetchTokenScopes(ctx, tokenInfo.Token)
|
||||
if err != nil {
|
||||
return b
|
||||
}
|
||||
|
||||
return b.WithFilter(github.CreateToolScopeFilter(scopesList))
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/github"
|
||||
"github.com/github/github-mcp-server/pkg/http/headers"
|
||||
"github.com/github/github-mcp-server/pkg/inventory"
|
||||
"github.com/github/github-mcp-server/pkg/scopes"
|
||||
"github.com/github/github-mcp-server/pkg/translations"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mockTool(name, toolsetID string, readOnly bool) inventory.ServerTool {
|
||||
return mockToolFull(name, toolsetID, readOnly, false)
|
||||
}
|
||||
|
||||
func mockToolFull(name, toolsetID string, readOnly bool, isDefault bool) inventory.ServerTool {
|
||||
return inventory.ServerTool{
|
||||
Tool: mcp.Tool{
|
||||
Name: name,
|
||||
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: readOnly},
|
||||
},
|
||||
Toolset: inventory.ToolsetMetadata{
|
||||
ID: inventory.ToolsetID(toolsetID),
|
||||
Description: "Test: " + toolsetID,
|
||||
Default: isDefault,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type allScopesFetcher struct{}
|
||||
|
||||
func (f allScopesFetcher) FetchTokenScopes(_ context.Context, _ string) ([]string, error) {
|
||||
return []string{
|
||||
string(scopes.Repo),
|
||||
string(scopes.WriteOrg),
|
||||
string(scopes.User),
|
||||
string(scopes.Gist),
|
||||
string(scopes.Notifications),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ scopes.FetcherInterface = allScopesFetcher{}
|
||||
|
||||
func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag string) inventory.ServerTool {
|
||||
tool := mockTool(name, toolsetID, readOnly)
|
||||
tool.FeatureFlagEnable = enableFlag
|
||||
if disableFlag != "" {
|
||||
tool.FeatureFlagDisable = []string{disableFlag}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func TestInventoryFiltersForRequest(t *testing.T) {
|
||||
tools := []inventory.ServerTool{
|
||||
mockTool("get_file_contents", "repos", true),
|
||||
mockTool("create_repository", "repos", false),
|
||||
mockTool("list_issues", "issues", true),
|
||||
mockTool("issue_write", "issues", false),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
contextSetup func(context.Context) context.Context
|
||||
expectedTools []string
|
||||
}{
|
||||
{
|
||||
name: "no filters applies defaults",
|
||||
contextSetup: func(ctx context.Context) context.Context { return ctx },
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "issue_write"},
|
||||
},
|
||||
{
|
||||
name: "readonly from context filters write tools",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
return ghcontext.WithReadonly(ctx, true)
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues"},
|
||||
},
|
||||
{
|
||||
name: "toolset from context filters to toolset",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
return ghcontext.WithToolsets(ctx, []string{"repos"})
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository"},
|
||||
},
|
||||
{
|
||||
name: "tools alone clears default toolsets",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
return ghcontext.WithTools(ctx, []string{"list_issues"})
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "tools are additive with toolsets",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
ctx = ghcontext.WithToolsets(ctx, []string{"repos"})
|
||||
ctx = ghcontext.WithTools(ctx, []string{"list_issues"})
|
||||
return ctx
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues"},
|
||||
},
|
||||
{
|
||||
name: "excluded tools removes specific tools",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
return ghcontext.WithExcludeTools(ctx, []string{"create_repository", "issue_write"})
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues"},
|
||||
},
|
||||
{
|
||||
name: "excluded tools overrides explicit tools",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
ctx = ghcontext.WithTools(ctx, []string{"list_issues", "create_repository"})
|
||||
ctx = ghcontext.WithExcludeTools(ctx, []string{"create_repository"})
|
||||
return ctx
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "excluded tools combines with readonly",
|
||||
contextSetup: func(ctx context.Context) context.Context {
|
||||
ctx = ghcontext.WithReadonly(ctx, true)
|
||||
ctx = ghcontext.WithExcludeTools(ctx, []string{"list_issues"})
|
||||
return ctx
|
||||
},
|
||||
expectedTools: []string{"get_file_contents"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(tt.contextSetup(req.Context()))
|
||||
|
||||
builder := inventory.NewBuilder().
|
||||
SetTools(tools).
|
||||
WithToolsets([]string{"all"})
|
||||
|
||||
builder = InventoryFiltersForRequest(req, builder)
|
||||
inv, err := builder.Build()
|
||||
require.NoError(t, err)
|
||||
|
||||
available := inv.AvailableTools(context.Background())
|
||||
toolNames := make([]string, len(available))
|
||||
for i, tool := range available {
|
||||
toolNames[i] = tool.Tool.Name
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, tt.expectedTools, toolNames)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testTools returns a set of mock tools across different toolsets with mixed read-only/write capabilities
|
||||
func testTools() []inventory.ServerTool {
|
||||
return []inventory.ServerTool{
|
||||
mockTool("get_file_contents", "repos", true),
|
||||
mockTool("create_repository", "repos", false),
|
||||
mockTool("list_issues", "issues", true),
|
||||
mockTool("create_issue", "issues", false),
|
||||
mockTool("list_pull_requests", "pull_requests", true),
|
||||
mockTool("create_pull_request", "pull_requests", false),
|
||||
// Feature-flagged tools for testing X-MCP-Features header
|
||||
mockToolWithFeatureFlag("needs_holdback", "repos", true, "mcp_holdback_consolidated_projects", ""),
|
||||
mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"),
|
||||
}
|
||||
}
|
||||
|
||||
// extractToolNames extracts tool names from an inventory
|
||||
func extractToolNames(ctx context.Context, inv *inventory.Inventory) []string {
|
||||
available := inv.AvailableTools(ctx)
|
||||
names := make([]string, len(available))
|
||||
for i, tool := range available {
|
||||
names[i] = tool.Tool.Name
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func TestHTTPHandlerRoutes(t *testing.T) {
|
||||
tools := testTools()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
headers map[string]string
|
||||
expectedTools []string
|
||||
}{
|
||||
{
|
||||
name: "root path returns all tools",
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "readonly path filters write tools",
|
||||
path: "/readonly",
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "toolset path filters to toolset",
|
||||
path: "/x/repos",
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "toolset path with issues",
|
||||
path: "/x/issues",
|
||||
expectedTools: []string{"list_issues", "create_issue"},
|
||||
},
|
||||
{
|
||||
name: "toolset readonly path filters to readonly tools in toolset",
|
||||
path: "/x/repos/readonly",
|
||||
expectedTools: []string{"get_file_contents", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "toolset readonly path with issues",
|
||||
path: "/x/issues/readonly",
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Tools header filters to specific tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "list_issues",
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Tools header with multiple tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "list_issues,get_file_contents",
|
||||
},
|
||||
expectedTools: []string{"list_issues", "get_file_contents"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Tools header does not expose extra tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "list_issues",
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Readonly header filters write tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPReadOnlyHeader: "true",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Toolsets header filters to toolset",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "repos",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "URL toolset takes precedence over header toolset",
|
||||
path: "/x/issues",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "repos",
|
||||
},
|
||||
expectedTools: []string{"list_issues", "create_issue"},
|
||||
},
|
||||
{
|
||||
name: "URL readonly takes precedence over header",
|
||||
path: "/readonly",
|
||||
headers: map[string]string{
|
||||
headers.MCPReadOnlyHeader: "false",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Features header enables flagged tool",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPFeaturesHeader: "mcp_holdback_consolidated_projects",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Features header with unknown flag is ignored",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPFeaturesHeader: "unknown_flag",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Exclude-Tools header removes specific tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPExcludeToolsHeader: "create_issue,create_pull_request",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Exclude-Tools with toolset header",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "issues",
|
||||
headers.MCPExcludeToolsHeader: "create_issue",
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Exclude-Tools overrides X-MCP-Tools",
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "list_issues,create_issue",
|
||||
headers.MCPExcludeToolsHeader: "create_issue",
|
||||
},
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "X-MCP-Exclude-Tools with readonly path",
|
||||
path: "/readonly",
|
||||
headers: map[string]string{
|
||||
headers.MCPExcludeToolsHeader: "list_issues",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedInventory *inventory.Inventory
|
||||
var capturedCtx context.Context
|
||||
|
||||
// Create feature checker that reads from context without whitelist validation
|
||||
// (the whitelist is tested separately; here we test the filtering logic)
|
||||
featureChecker := func(ctx context.Context, flag string) (bool, error) {
|
||||
return slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag), nil
|
||||
}
|
||||
|
||||
apiHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create inventory factory that captures the built inventory
|
||||
inventoryFactory := func(r *http.Request) (*inventory.Inventory, error) {
|
||||
capturedCtx = r.Context()
|
||||
builder := inventory.NewBuilder().
|
||||
SetTools(tools).
|
||||
WithToolsets([]string{"all"}).
|
||||
WithFeatureChecker(featureChecker)
|
||||
builder = InventoryFiltersForRequest(r, builder)
|
||||
inv, err := builder.Build()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
capturedInventory = inv
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// Create mock MCP server factory that just returns a minimal server
|
||||
mcpServerFactory := func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
|
||||
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
|
||||
}
|
||||
|
||||
allScopesFetcher := allScopesFetcher{}
|
||||
|
||||
// Create handler with our factories
|
||||
handler := NewHTTPMcpHandler(
|
||||
context.Background(),
|
||||
&ServerConfig{Version: "test"},
|
||||
nil, // deps not needed for this test
|
||||
translations.NullTranslationHelper,
|
||||
slog.Default(),
|
||||
apiHost,
|
||||
WithInventoryFactory(inventoryFactory),
|
||||
WithGitHubMCPServerFactory(mcpServerFactory),
|
||||
WithScopeFetcher(allScopesFetcher),
|
||||
)
|
||||
|
||||
// Create router and register routes
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterMiddleware(r)
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest(http.MethodPost, tt.path, nil)
|
||||
|
||||
// Ensure we're setting Authorization header for token context
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
|
||||
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// Execute request
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
// Verify the inventory was captured and has the expected tools
|
||||
require.NotNil(t, capturedInventory, "inventory should have been created")
|
||||
|
||||
toolNames := extractToolNames(capturedCtx, capturedInventory)
|
||||
expectedSorted := make([]string, len(tt.expectedTools))
|
||||
copy(expectedSorted, tt.expectedTools)
|
||||
sort.Strings(expectedSorted)
|
||||
|
||||
assert.Equal(t, expectedSorted, toolNames, "tools should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticConfigEnforcement(t *testing.T) {
|
||||
// Use default toolsets to match real-world behavior where repos/issues/pull_requests are defaults
|
||||
tools := []inventory.ServerTool{
|
||||
mockToolFull("get_file_contents", "repos", true, true),
|
||||
mockToolFull("create_repository", "repos", false, true),
|
||||
mockToolFull("list_issues", "issues", true, true),
|
||||
mockToolFull("create_issue", "issues", false, true),
|
||||
mockToolFull("list_pull_requests", "pull_requests", true, true),
|
||||
mockToolFull("create_pull_request", "pull_requests", false, true),
|
||||
mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *ServerConfig
|
||||
path string
|
||||
headers map[string]string
|
||||
expectedTools []string
|
||||
}{
|
||||
{
|
||||
name: "no static config preserves existing behavior",
|
||||
config: &ServerConfig{Version: "test"},
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static read-only filters write tools",
|
||||
config: &ServerConfig{Version: "test", ReadOnly: true},
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static read-only cannot be overridden by header",
|
||||
config: &ServerConfig{Version: "test", ReadOnly: true},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPReadOnlyHeader: "false",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static toolsets restricts available tools",
|
||||
config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos"}},
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static toolsets cannot be expanded by header",
|
||||
config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "issues",
|
||||
},
|
||||
// Header asks for "issues" but only "repos" tools exist in the static universe
|
||||
expectedTools: []string{},
|
||||
},
|
||||
{
|
||||
name: "per-request header can narrow within static toolset bounds",
|
||||
config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos", "issues"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "repos",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "create_repository", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static exclude-tools removes tools",
|
||||
config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository", "create_issue"}},
|
||||
path: "/",
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static exclude-tools cannot be re-included by header",
|
||||
config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "create_repository,list_issues",
|
||||
},
|
||||
// create_repository was excluded at static level, only list_issues available
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "static read-only combined with per-request toolset",
|
||||
config: &ServerConfig{Version: "test", ReadOnly: true},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsetsHeader: "repos",
|
||||
},
|
||||
expectedTools: []string{"get_file_contents", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static toolset with URL readonly",
|
||||
config: &ServerConfig{Version: "test", EnabledToolsets: []string{"repos", "issues"}},
|
||||
path: "/readonly",
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "hidden_by_holdback"},
|
||||
},
|
||||
{
|
||||
name: "static tools enables specific tools only",
|
||||
config: &ServerConfig{Version: "test", EnabledTools: []string{"list_issues", "get_file_contents"}},
|
||||
path: "/",
|
||||
expectedTools: []string{"list_issues", "get_file_contents"},
|
||||
},
|
||||
{
|
||||
name: "static tools cannot be expanded by header",
|
||||
config: &ServerConfig{Version: "test", EnabledTools: []string{"list_issues"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPToolsHeader: "create_repository",
|
||||
},
|
||||
// create_repository isn't in the static universe so it's silently dropped;
|
||||
// the empty filter shows all tools within static bounds
|
||||
expectedTools: []string{"list_issues"},
|
||||
},
|
||||
{
|
||||
name: "static exclude-tools combined with per-request exclude",
|
||||
config: &ServerConfig{Version: "test", ExcludeTools: []string{"create_repository"}},
|
||||
path: "/",
|
||||
headers: map[string]string{
|
||||
headers.MCPExcludeToolsHeader: "create_issue",
|
||||
},
|
||||
// Both static and per-request exclusions apply
|
||||
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedInventory *inventory.Inventory
|
||||
var capturedCtx context.Context
|
||||
|
||||
featureChecker := func(ctx context.Context, flag string) (bool, error) {
|
||||
return slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag), nil
|
||||
}
|
||||
|
||||
apiHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Build static tools the same way the production code does
|
||||
staticTools, staticResources, staticPrompts := buildStaticInventoryFromTools(tt.config, tools)
|
||||
hasStatic := hasStaticConfig(tt.config)
|
||||
|
||||
validToolNames := make(map[string]bool, len(staticTools))
|
||||
for _, tool := range staticTools {
|
||||
validToolNames[tool.Tool.Name] = true
|
||||
}
|
||||
|
||||
inventoryFactory := func(r *http.Request) (*inventory.Inventory, error) {
|
||||
capturedCtx = r.Context()
|
||||
builder := inventory.NewBuilder().
|
||||
SetTools(staticTools).
|
||||
SetResources(staticResources).
|
||||
SetPrompts(staticPrompts).
|
||||
WithDeprecatedAliases(github.DeprecatedToolAliases).
|
||||
WithFeatureChecker(featureChecker)
|
||||
|
||||
if hasStatic {
|
||||
builder = builder.WithToolsets([]string{"all"})
|
||||
}
|
||||
if tt.config.ReadOnly {
|
||||
builder = builder.WithReadOnly(true)
|
||||
}
|
||||
|
||||
if hasStatic {
|
||||
r = filterRequestTools(r, validToolNames)
|
||||
}
|
||||
|
||||
builder = InventoryFiltersForRequest(r, builder)
|
||||
inv, buildErr := builder.Build()
|
||||
if buildErr != nil {
|
||||
return nil, buildErr
|
||||
}
|
||||
capturedInventory = inv
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
mcpServerFactory := func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
|
||||
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
|
||||
}
|
||||
|
||||
handler := NewHTTPMcpHandler(
|
||||
context.Background(),
|
||||
tt.config,
|
||||
nil,
|
||||
translations.NullTranslationHelper,
|
||||
slog.Default(),
|
||||
apiHost,
|
||||
WithInventoryFactory(inventoryFactory),
|
||||
WithGitHubMCPServerFactory(mcpServerFactory),
|
||||
WithScopeFetcher(allScopesFetcher{}),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterMiddleware(r)
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, tt.path, nil)
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
|
||||
for k, v := range tt.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
require.NotNil(t, capturedInventory, "inventory should have been created")
|
||||
|
||||
toolNames := extractToolNames(capturedCtx, capturedInventory)
|
||||
expectedSorted := make([]string, len(tt.expectedTools))
|
||||
copy(expectedSorted, tt.expectedTools)
|
||||
sort.Strings(expectedSorted)
|
||||
|
||||
assert.Equal(t, expectedSorted, toolNames, "tools should match expected")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticInventoryPreservesPerRequestFeatureVariants(t *testing.T) {
|
||||
tools := []inventory.ServerTool{
|
||||
mockToolWithFeatureFlag("list_issues", "issues", true, "", github.FeatureFlagCSVOutput),
|
||||
mockToolWithFeatureFlag("list_issues", "issues", true, github.FeatureFlagCSVOutput, ""),
|
||||
}
|
||||
cfg := &ServerConfig{Version: "test", EnabledToolsets: []string{"issues"}}
|
||||
featureChecker := createHTTPFeatureChecker(nil, false)
|
||||
|
||||
staticTools, _, _ := buildStaticInventoryFromTools(cfg, tools)
|
||||
require.Len(t, staticTools, 2, "static upper bounds should preserve both feature variants")
|
||||
|
||||
inv, err := inventory.NewBuilder().
|
||||
SetTools(staticTools).
|
||||
WithFeatureChecker(featureChecker).
|
||||
WithToolsets([]string{"all"}).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := ghcontext.WithInsidersMode(context.Background(), true)
|
||||
available := inv.AvailableTools(ctx)
|
||||
require.Len(t, available, 1)
|
||||
assert.Equal(t, "list_issues", available[0].Tool.Name)
|
||||
assert.Equal(t, github.FeatureFlagCSVOutput, available[0].FeatureFlagEnable)
|
||||
}
|
||||
|
||||
// TestContentTypeHandling verifies that the MCP StreamableHTTP handler
|
||||
// accepts Content-Type values with additional parameters like charset=utf-8.
|
||||
// This is a regression test for https://github.com/github/github-mcp-server/issues/2333
|
||||
// where the Go SDK performs strict string matching against "application/json"
|
||||
// and rejects requests with "application/json; charset=utf-8".
|
||||
func TestContentTypeHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
expectUnsupportedMedia bool
|
||||
}{
|
||||
{
|
||||
name: "exact application/json is accepted",
|
||||
contentType: "application/json",
|
||||
expectUnsupportedMedia: false,
|
||||
},
|
||||
{
|
||||
name: "application/json with charset=utf-8 should be accepted",
|
||||
contentType: "application/json; charset=utf-8",
|
||||
expectUnsupportedMedia: false,
|
||||
},
|
||||
{
|
||||
name: "application/json with charset=UTF-8 should be accepted",
|
||||
contentType: "application/json; charset=UTF-8",
|
||||
expectUnsupportedMedia: false,
|
||||
},
|
||||
{
|
||||
name: "completely wrong content type is rejected",
|
||||
contentType: "text/plain",
|
||||
expectUnsupportedMedia: true,
|
||||
},
|
||||
{
|
||||
name: "empty content type is rejected",
|
||||
contentType: "",
|
||||
expectUnsupportedMedia: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create a minimal MCP server factory
|
||||
mcpServerFactory := func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
|
||||
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
|
||||
}
|
||||
|
||||
// Create a simple inventory factory
|
||||
inventoryFactory := func(_ *http.Request) (*inventory.Inventory, error) {
|
||||
return inventory.NewBuilder().
|
||||
SetTools(testTools()).
|
||||
WithToolsets([]string{"all"}).
|
||||
Build()
|
||||
}
|
||||
|
||||
apiHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := NewHTTPMcpHandler(
|
||||
context.Background(),
|
||||
&ServerConfig{Version: "test"},
|
||||
nil,
|
||||
translations.NullTranslationHelper,
|
||||
slog.Default(),
|
||||
apiHost,
|
||||
WithInventoryFactory(inventoryFactory),
|
||||
WithGitHubMCPServerFactory(mcpServerFactory),
|
||||
WithScopeFetcher(allScopesFetcher{}),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterMiddleware(r)
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
// Send an MCP initialize request as a POST with the given Content-Type
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer ghp_testtoken")
|
||||
req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", "))
|
||||
if tt.contentType != "" {
|
||||
req.Header.Set(headers.ContentTypeHeader, tt.contentType)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if tt.expectUnsupportedMedia {
|
||||
assert.Equal(t, http.StatusUnsupportedMediaType, rr.Code,
|
||||
"expected 415 Unsupported Media Type for Content-Type: %q", tt.contentType)
|
||||
} else {
|
||||
assert.NotEqual(t, http.StatusUnsupportedMediaType, rr.Code,
|
||||
"should not get 415 for Content-Type: %q, got status %d", tt.contentType, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildStaticInventoryFromTools is a test helper that mirrors buildStaticInventory
|
||||
// but uses the provided mock tools instead of calling github.AllTools.
|
||||
func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTool) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) {
|
||||
if !hasStaticConfig(cfg) {
|
||||
return tools, nil, nil
|
||||
}
|
||||
|
||||
b := inventory.NewBuilder().
|
||||
SetTools(tools).
|
||||
WithReadOnly(cfg.ReadOnly).
|
||||
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools))
|
||||
|
||||
if len(cfg.EnabledTools) > 0 {
|
||||
b = b.WithTools(github.CleanTools(cfg.EnabledTools))
|
||||
}
|
||||
|
||||
if len(cfg.ExcludeTools) > 0 {
|
||||
b = b.WithExcludeTools(cfg.ExcludeTools)
|
||||
}
|
||||
|
||||
inv, err := b.Build()
|
||||
if err != nil {
|
||||
return tools, nil, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx)
|
||||
}
|
||||
|
||||
func TestCrossOriginProtection(t *testing.T) {
|
||||
jsonRPCBody := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}`
|
||||
|
||||
apiHost, err := utils.NewAPIHost("https://api.githubcopilot.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := NewHTTPMcpHandler(
|
||||
context.Background(),
|
||||
&ServerConfig{
|
||||
Version: "test",
|
||||
},
|
||||
nil,
|
||||
translations.NullTranslationHelper,
|
||||
slog.Default(),
|
||||
apiHost,
|
||||
WithInventoryFactory(func(_ *http.Request) (*inventory.Inventory, error) {
|
||||
return inventory.NewBuilder().Build()
|
||||
}),
|
||||
WithGitHubMCPServerFactory(func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
|
||||
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
|
||||
}),
|
||||
WithScopeFetcher(allScopesFetcher{}),
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler.RegisterMiddleware(r)
|
||||
handler.RegisterRoutes(r)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
secFetchSite string
|
||||
origin string
|
||||
}{
|
||||
{
|
||||
name: "cross-site request with bearer token succeeds",
|
||||
secFetchSite: "cross-site",
|
||||
origin: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "same-origin request succeeds",
|
||||
secFetchSite: "same-origin",
|
||||
},
|
||||
{
|
||||
name: "native client without Sec-Fetch-Site succeeds",
|
||||
secFetchSite: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(jsonRPCBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer github_pat_xyz")
|
||||
if tt.secFetchSite != "" {
|
||||
req.Header.Set("Sec-Fetch-Site", tt.secFetchSite)
|
||||
}
|
||||
if tt.origin != "" {
|
||||
req.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rr.Code, "unexpected status code; body: %s", rr.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInsidersRoutePreservesUIMeta is a regression test for the bug where
|
||||
// _meta.ui was stripped from tools/list responses on the HTTP /insiders route.
|
||||
//
|
||||
// Before the fix:
|
||||
// - buildStaticInventory called Build() on a builder configured with the
|
||||
// HTTP feature checker (which reads insiders mode from the request ctx).
|
||||
// - Build() invoked checkFeatureFlag(context.Background()) — bg ctx has no
|
||||
// insiders mode, so the FF reported MCP Apps off, and stripMCPAppsMetadata
|
||||
// ran eagerly against the static tool slice at server startup.
|
||||
// - Per-request inventory factories then served pre-stripped tools regardless
|
||||
// of whether the request actually came in via /insiders.
|
||||
//
|
||||
// After the fix:
|
||||
// - Build() no longer touches MCP Apps metadata.
|
||||
// - RegisterTools applies the strip per-request, using the request context
|
||||
// where the HTTP feature checker correctly observes insiders mode.
|
||||
func TestInsidersRoutePreservesUIMeta(t *testing.T) {
|
||||
const uiURI = "ui://test/widget"
|
||||
uiTool := mockTool("with_ui", "repos", true)
|
||||
uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}}
|
||||
|
||||
checker := createHTTPFeatureChecker(nil, false)
|
||||
build := func() *inventory.Inventory {
|
||||
inv, err := inventory.NewBuilder().
|
||||
SetTools([]inventory.ServerTool{uiTool}).
|
||||
WithFeatureChecker(checker).
|
||||
WithToolsets([]string{"all"}).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
return inv
|
||||
}
|
||||
|
||||
// Simulate a /insiders request: ctx has insiders mode set.
|
||||
insidersCtx := ghcontext.WithInsidersMode(context.Background(), true)
|
||||
|
||||
// AvailableTools no longer strips _meta.ui (post-fix), regardless of ctx.
|
||||
// The strip lives in RegisterTools, gated on the per-request FF check.
|
||||
insidersTools := build().AvailableTools(insidersCtx)
|
||||
plainTools := build().AvailableTools(context.Background())
|
||||
|
||||
// On the /insiders path, the FF check returns true → no strip → _meta preserved.
|
||||
enabled, _ := checker(insidersCtx, "remote_mcp_ui_apps")
|
||||
require.True(t, enabled, "FF should be on for /insiders ctx")
|
||||
require.Len(t, insidersTools, 1)
|
||||
require.NotNil(t, insidersTools[0].Tool.Meta, "_meta should be present on /insiders")
|
||||
require.Equal(t, uiURI, insidersTools[0].Tool.Meta["ui"].(map[string]any)["resourceUri"])
|
||||
|
||||
// On the non-insiders path, RegisterTools strips _meta.ui.
|
||||
plainEnabled, _ := checker(context.Background(), "remote_mcp_ui_apps")
|
||||
require.False(t, plainEnabled, "FF should be off for non-insiders ctx")
|
||||
require.Len(t, plainTools, 1)
|
||||
}
|
||||
|
||||
// TestUIMetaStrippedWhenClientLacksCapability verifies that even on the
|
||||
// /insiders path (where the feature flag is on), UI metadata is stripped from
|
||||
// tools/list responses when the client did NOT advertise the
|
||||
// io.modelcontextprotocol/ui extension capability. Per the 2026-01-26 MCP
|
||||
// Apps spec, servers SHOULD check client capabilities before exposing
|
||||
// UI-enabled tools.
|
||||
func TestUIMetaStrippedWhenClientLacksCapability(t *testing.T) {
|
||||
const uiURI = "ui://test/widget"
|
||||
uiTool := mockTool("with_ui", "repos", true)
|
||||
uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}}
|
||||
|
||||
checker := createHTTPFeatureChecker(nil, false)
|
||||
build := func() *inventory.Inventory {
|
||||
inv, err := inventory.NewBuilder().
|
||||
SetTools([]inventory.ServerTool{uiTool}).
|
||||
WithFeatureChecker(checker).
|
||||
WithToolsets([]string{"all"}).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
return inv
|
||||
}
|
||||
|
||||
insidersCtx := ghcontext.WithInsidersMode(context.Background(), true)
|
||||
withoutUICap := ghcontext.WithUISupport(insidersCtx, false)
|
||||
withUICap := ghcontext.WithUISupport(insidersCtx, true)
|
||||
|
||||
stripped := build().ToolsForRegistration(withoutUICap)
|
||||
require.Len(t, stripped, 1)
|
||||
require.Nil(t, stripped[0].Tool.Meta["ui"], "_meta.ui should be stripped when client lacks UI capability")
|
||||
|
||||
preserved := build().ToolsForRegistration(withUICap)
|
||||
require.Len(t, preserved, 1)
|
||||
require.NotNil(t, preserved[0].Tool.Meta["ui"], "_meta.ui should be preserved when client advertises UI capability")
|
||||
require.Equal(t, uiURI, preserved[0].Tool.Meta["ui"].(map[string]any)["resourceUri"])
|
||||
|
||||
// Unknown capability falls through to the FF gate (insiders ctx → kept).
|
||||
unknown := build().ToolsForRegistration(insidersCtx)
|
||||
require.Len(t, unknown, 1)
|
||||
require.NotNil(t, unknown[0].Tool.Meta["ui"], "_meta.ui should be preserved when capability is unknown and FF is on")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package headers
|
||||
|
||||
const (
|
||||
// AuthorizationHeader is a standard HTTP Header.
|
||||
AuthorizationHeader = "Authorization"
|
||||
// ContentTypeHeader is a standard HTTP Header.
|
||||
ContentTypeHeader = "Content-Type"
|
||||
// AcceptHeader is a standard HTTP Header.
|
||||
AcceptHeader = "Accept"
|
||||
// UserAgentHeader is a standard HTTP Header.
|
||||
UserAgentHeader = "User-Agent"
|
||||
|
||||
// ContentTypeJSON is the standard MIME type for JSON.
|
||||
ContentTypeJSON = "application/json"
|
||||
// ContentTypeEventStream is the standard MIME type for Event Streams.
|
||||
ContentTypeEventStream = "text/event-stream"
|
||||
|
||||
// ForwardedForHeader is a standard HTTP Header used to forward the originating IP address of a client.
|
||||
ForwardedForHeader = "X-Forwarded-For"
|
||||
|
||||
// RealIPHeader is a standard HTTP Header used to indicate the real IP address of the client.
|
||||
RealIPHeader = "X-Real-IP"
|
||||
|
||||
// ForwardedHostHeader is a standard HTTP Header for preserving the original Host header when proxying.
|
||||
ForwardedHostHeader = "X-Forwarded-Host"
|
||||
// ForwardedProtoHeader is a standard HTTP Header for preserving the original protocol when proxying.
|
||||
ForwardedProtoHeader = "X-Forwarded-Proto"
|
||||
|
||||
// RequestHmacHeader is used to authenticate requests to the Raw API.
|
||||
RequestHmacHeader = "Request-Hmac"
|
||||
|
||||
// MCP-specific headers.
|
||||
|
||||
// MCPReadOnlyHeader indicates whether the MCP is in read-only mode.
|
||||
MCPReadOnlyHeader = "X-MCP-Readonly"
|
||||
// MCPToolsetsHeader is a comma-separated list of MCP toolsets that the request is for.
|
||||
MCPToolsetsHeader = "X-MCP-Toolsets"
|
||||
// MCPToolsHeader is a comma-separated list of MCP tools that the request is for.
|
||||
MCPToolsHeader = "X-MCP-Tools"
|
||||
// MCPLockdownHeader indicates whether lockdown mode is enabled.
|
||||
MCPLockdownHeader = "X-MCP-Lockdown"
|
||||
// MCPInsidersHeader indicates whether insiders mode is enabled for early access features.
|
||||
MCPInsidersHeader = "X-MCP-Insiders"
|
||||
// MCPExcludeToolsHeader is a comma-separated list of MCP tools that should be
|
||||
// disabled regardless of other settings or header values.
|
||||
MCPExcludeToolsHeader = "X-MCP-Exclude-Tools"
|
||||
// MCPFeaturesHeader is a comma-separated list of feature flags to enable.
|
||||
MCPFeaturesHeader = "X-MCP-Features"
|
||||
|
||||
// GitHub-specific headers.
|
||||
|
||||
// GraphQLFeaturesHeader is a comma-separated list of GraphQL feature flags to enable for GraphQL requests.
|
||||
GraphQLFeaturesHeader = "GraphQL-Features"
|
||||
// GitHubAPIVersionHeader is the header used to specify the GitHub API version.
|
||||
GitHubAPIVersionHeader = "X-GitHub-Api-Version"
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package headers
|
||||
|
||||
import "strings"
|
||||
|
||||
// ParseCommaSeparated splits a header value by comma, trims whitespace,
|
||||
// and filters out empty values
|
||||
func ParseCommaSeparated(value string) []string {
|
||||
if value == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package headers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseCommaSeparated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single value",
|
||||
input: "foo",
|
||||
expected: []string{"foo"},
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
input: "foo,bar,baz",
|
||||
expected: []string{"foo", "bar", "baz"},
|
||||
},
|
||||
{
|
||||
name: "whitespace trimmed",
|
||||
input: " foo , bar , baz ",
|
||||
expected: []string{"foo", "bar", "baz"},
|
||||
},
|
||||
{
|
||||
name: "empty values filtered",
|
||||
input: "foo,,bar,",
|
||||
expected: []string{"foo", "bar"},
|
||||
},
|
||||
{
|
||||
name: "only commas",
|
||||
input: ",,,",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "whitespace only values filtered",
|
||||
input: "foo, ,bar",
|
||||
expected: []string{"foo", "bar"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ParseCommaSeparated(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package mark provides a mechanism for tagging errors with a well-known error value.
|
||||
package mark
|
||||
|
||||
import "errors"
|
||||
|
||||
// This list of errors is not exhaustive, but is a good starting point for most
|
||||
// applications. Feel free to add more as needed, but don't go overboard.
|
||||
// Remember, the specific types of errors are only important so far as someone
|
||||
// calling your code might want to write logic to handle each type of error
|
||||
// differently.
|
||||
//
|
||||
// Do not add application-specific errors to this list. Instead, just define
|
||||
// your own package with your own application-specific errors, and use this
|
||||
// package to mark errors with them. The errors in this package are not special,
|
||||
// they're just plain old errors.
|
||||
//
|
||||
// Not all errors need to be marked. An error that is not marked should be
|
||||
// treated as an unexpected error that cannot be handled by calling code. This
|
||||
// is often the case for network errors or logic errors.
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAlreadyExists = errors.New("already exists")
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrCancelled = errors.New("request cancelled")
|
||||
ErrUnavailable = errors.New("unavailable")
|
||||
ErrTimedout = errors.New("request timed out")
|
||||
ErrTooLarge = errors.New("request is too large")
|
||||
ErrTooManyRequests = errors.New("too many requests")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
// With wraps err with another error that will return true from errors.Is and
|
||||
// errors.As for both err and markErr, and anything either may wrap.
|
||||
func With(err, markErr error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return marked{wrapped: err, mark: markErr}
|
||||
}
|
||||
|
||||
type marked struct {
|
||||
wrapped error
|
||||
mark error
|
||||
}
|
||||
|
||||
func (f marked) Is(target error) bool {
|
||||
// if this is false, errors.Is will call unwrap and retry on the wrapped
|
||||
// error.
|
||||
return errors.Is(f.mark, target)
|
||||
}
|
||||
|
||||
func (f marked) As(target any) bool {
|
||||
// if this is false, errors.As will call unwrap and retry on the wrapped
|
||||
// error.
|
||||
return errors.As(f.mark, target)
|
||||
}
|
||||
|
||||
func (f marked) Unwrap() error {
|
||||
return f.wrapped
|
||||
}
|
||||
|
||||
func (f marked) Error() string {
|
||||
return f.mark.Error() + ": " + f.wrapped.Error()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,763 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultAuthorizationServer = "https://github.com/login/oauth"
|
||||
)
|
||||
|
||||
func TestNewAuthHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dotcomHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectedAuthServer string
|
||||
expectedResourcePath string
|
||||
}{
|
||||
{
|
||||
name: "custom authorization server",
|
||||
cfg: &Config{
|
||||
AuthorizationServer: "https://custom.example.com/oauth",
|
||||
},
|
||||
expectedAuthServer: "https://custom.example.com/oauth",
|
||||
expectedResourcePath: "",
|
||||
},
|
||||
{
|
||||
name: "custom base URL and resource path",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://example.com",
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
expectedAuthServer: "",
|
||||
expectedResourcePath: "/mcp",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, err := NewAuthHandler(tc.cfg, dotcomHost)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, handler)
|
||||
|
||||
assert.Equal(t, tc.expectedAuthServer, handler.cfg.AuthorizationServer)
|
||||
assert.Equal(t, tc.expectedResourcePath, handler.cfg.ResourcePath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEffectiveHostAndScheme(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupRequest func() *http.Request
|
||||
cfg *Config
|
||||
expectedHost string
|
||||
expectedScheme string
|
||||
}{
|
||||
{
|
||||
name: "basic request without forwarding headers",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
return req
|
||||
},
|
||||
cfg: &Config{},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "http", // defaults to http
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Host ignored by default",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "attacker.example.com")
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "https")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{},
|
||||
expectedHost: "internal.example.com",
|
||||
expectedScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "request with X-Forwarded-Host header",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "public.example.com",
|
||||
expectedScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "request with X-Forwarded-Proto header",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "http")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "request with both forwarding headers",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "https")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "public.example.com",
|
||||
expectedScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "request with TLS",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
return req
|
||||
},
|
||||
cfg: &Config{},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "https",
|
||||
},
|
||||
{
|
||||
name: "X-Forwarded-Proto takes precedence over TLS",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "http")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "http",
|
||||
},
|
||||
{
|
||||
name: "scheme is lowercased",
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Host = "example.com"
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "HTTPS")
|
||||
return req
|
||||
},
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
expectedHost: "example.com",
|
||||
expectedScheme: "https",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := tc.setupRequest()
|
||||
host, scheme := GetEffectiveHostAndScheme(req, tc.cfg)
|
||||
|
||||
assert.Equal(t, tc.expectedHost, host)
|
||||
assert.Equal(t, tc.expectedScheme, scheme)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveResourcePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
setupRequest func() *http.Request
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
name: "no base path uses request path",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/x/repos", nil)
|
||||
},
|
||||
expectedPath: "/x/repos",
|
||||
},
|
||||
{
|
||||
name: "base path restored for root",
|
||||
cfg: &Config{
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
},
|
||||
expectedPath: "/mcp",
|
||||
},
|
||||
{
|
||||
name: "base path restored for nested",
|
||||
cfg: &Config{
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/readonly", nil)
|
||||
},
|
||||
expectedPath: "/mcp/readonly",
|
||||
},
|
||||
{
|
||||
name: "base path preserved when already present",
|
||||
cfg: &Config{
|
||||
ResourcePath: "/mcp",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/mcp/readonly/", nil)
|
||||
},
|
||||
expectedPath: "/mcp/readonly/",
|
||||
},
|
||||
{
|
||||
name: "custom base path restored",
|
||||
cfg: &Config{
|
||||
ResourcePath: "/api",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
return httptest.NewRequest(http.MethodGet, "/x/repos", nil)
|
||||
},
|
||||
expectedPath: "/api/x/repos",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := tc.setupRequest()
|
||||
path := ResolveResourcePath(req, tc.cfg)
|
||||
|
||||
assert.Equal(t, tc.expectedPath, path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResourceMetadataURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
setupRequest func() *http.Request
|
||||
resourcePath string
|
||||
expectedURL string
|
||||
}{
|
||||
{
|
||||
name: "root path",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "/",
|
||||
expectedURL: "http://api.example.com/.well-known/oauth-protected-resource",
|
||||
},
|
||||
{
|
||||
name: "resource path preserves trailing slash",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp/", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp/",
|
||||
expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp/",
|
||||
},
|
||||
{
|
||||
name: "with custom resource path",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp",
|
||||
expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
},
|
||||
{
|
||||
name: "with base URL config",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://custom.example.com",
|
||||
},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp",
|
||||
expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
},
|
||||
{
|
||||
name: "with forwarded headers ignored by default",
|
||||
cfg: &Config{},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "attacker.example.com")
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "https")
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp",
|
||||
expectedURL: "http://internal.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
},
|
||||
{
|
||||
name: "with forwarded headers",
|
||||
cfg: &Config{TrustProxyHeaders: true},
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/mcp", nil)
|
||||
req.Host = "internal.example.com"
|
||||
req.Header.Set(headers.ForwardedHostHeader, "public.example.com")
|
||||
req.Header.Set(headers.ForwardedProtoHeader, "https")
|
||||
return req
|
||||
},
|
||||
resourcePath: "/mcp",
|
||||
expectedURL: "https://public.example.com/.well-known/oauth-protected-resource/mcp",
|
||||
},
|
||||
{
|
||||
name: "nil config uses request host",
|
||||
cfg: nil,
|
||||
setupRequest: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "api.example.com"
|
||||
return req
|
||||
},
|
||||
resourcePath: "",
|
||||
expectedURL: "http://api.example.com/.well-known/oauth-protected-resource",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := tc.setupRequest()
|
||||
url := BuildResourceMetadataURL(req, tc.cfg, tc.resourcePath)
|
||||
|
||||
assert.Equal(t, tc.expectedURL, url)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProtectedResource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
path string
|
||||
host string
|
||||
method string
|
||||
expectedStatusCode int
|
||||
expectedScopes []string
|
||||
validateResponse func(t *testing.T, body map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "GET request returns protected resource metadata",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix,
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
expectedScopes: SupportedScopes,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "GitHub MCP Server", body["resource_name"])
|
||||
assert.Equal(t, "https://api.example.com/", body["resource"])
|
||||
|
||||
authServers, ok := body["authorization_servers"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, authServers, 1)
|
||||
assert.Equal(t, defaultAuthorizationServer, authServers[0])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request for CORS preflight",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix,
|
||||
host: "api.example.com",
|
||||
method: http.MethodOptions,
|
||||
expectedStatusCode: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "path with /mcp suffix",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix + "/mcp",
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "https://api.example.com/mcp", body["resource"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with /readonly suffix",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix + "/readonly",
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "https://api.example.com/readonly", body["resource"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "path with trailing slash",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix + "/mcp/",
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
assert.Equal(t, "https://api.example.com/mcp/", body["resource"])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom authorization server in response",
|
||||
cfg: &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
AuthorizationServer: "https://custom.auth.example.com/oauth",
|
||||
},
|
||||
path: OAuthProtectedResourcePrefix,
|
||||
host: "api.example.com",
|
||||
method: http.MethodGet,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
validateResponse: func(t *testing.T, body map[string]any) {
|
||||
t.Helper()
|
||||
authServers, ok := body["authorization_servers"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, authServers, 1)
|
||||
assert.Equal(t, "https://custom.auth.example.com/oauth", authServers[0])
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dotcomHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewAuthHandler(tc.cfg, dotcomHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := chi.NewRouter()
|
||||
handler.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
req.Host = tc.host
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, tc.expectedStatusCode, rec.Code)
|
||||
|
||||
// Check CORS headers
|
||||
assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "GET")
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "OPTIONS")
|
||||
|
||||
if tc.method == http.MethodGet && tc.validateResponse != nil {
|
||||
assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
|
||||
var body map[string]any
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
require.NoError(t, err)
|
||||
|
||||
tc.validateResponse(t, body)
|
||||
|
||||
// Verify scopes if expected
|
||||
if tc.expectedScopes != nil {
|
||||
scopes, ok := body["scopes_supported"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, scopes, len(tc.expectedScopes))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dotcomHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewAuthHandler(&Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
}, dotcomHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := chi.NewRouter()
|
||||
handler.RegisterRoutes(router)
|
||||
|
||||
// List of expected routes that should be registered
|
||||
expectedRoutes := []string{
|
||||
OAuthProtectedResourcePrefix,
|
||||
OAuthProtectedResourcePrefix + "/",
|
||||
OAuthProtectedResourcePrefix + "/mcp",
|
||||
OAuthProtectedResourcePrefix + "/mcp/",
|
||||
OAuthProtectedResourcePrefix + "/readonly",
|
||||
OAuthProtectedResourcePrefix + "/readonly/",
|
||||
OAuthProtectedResourcePrefix + "/mcp/readonly",
|
||||
OAuthProtectedResourcePrefix + "/mcp/readonly/",
|
||||
OAuthProtectedResourcePrefix + "/x/repos",
|
||||
OAuthProtectedResourcePrefix + "/mcp/x/repos",
|
||||
}
|
||||
|
||||
for _, route := range expectedRoutes {
|
||||
t.Run("route:"+route, func(t *testing.T) {
|
||||
// Test GET
|
||||
req := httptest.NewRequest(http.MethodGet, route, nil)
|
||||
req.Host = "api.example.com"
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "GET %s should return 200", route)
|
||||
|
||||
// Test OPTIONS (CORS preflight)
|
||||
req = httptest.NewRequest(http.MethodOptions, route, nil)
|
||||
req.Host = "api.example.com"
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code, "OPTIONS %s should return 204", route)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Verify all expected scopes are present
|
||||
expectedScopes := []string{
|
||||
"repo",
|
||||
"read:org",
|
||||
"read:user",
|
||||
"user:email",
|
||||
"read:packages",
|
||||
"write:packages",
|
||||
"read:project",
|
||||
"project",
|
||||
"gist",
|
||||
"notifications",
|
||||
"workflow",
|
||||
"codespace",
|
||||
}
|
||||
|
||||
assert.Equal(t, expectedScopes, SupportedScopes)
|
||||
}
|
||||
|
||||
func TestProtectedResourceResponseFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dotcomHost, err := utils.NewAPIHost("https://api.github.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
handler, err := NewAuthHandler(&Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
}, dotcomHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := chi.NewRouter()
|
||||
handler.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, OAuthProtectedResourcePrefix, nil)
|
||||
req.Host = "api.example.com"
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all required RFC 9728 fields are present
|
||||
assert.Contains(t, response, "resource")
|
||||
assert.Contains(t, response, "authorization_servers")
|
||||
assert.Contains(t, response, "bearer_methods_supported")
|
||||
assert.Contains(t, response, "scopes_supported")
|
||||
|
||||
// Verify resource name (optional but we include it)
|
||||
assert.Contains(t, response, "resource_name")
|
||||
assert.Equal(t, "GitHub MCP Server", response["resource_name"])
|
||||
|
||||
// Verify bearer_methods_supported contains "header"
|
||||
bearerMethods, ok := response["bearer_methods_supported"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, bearerMethods, "header")
|
||||
|
||||
// Verify authorization_servers is an array with GitHub OAuth
|
||||
authServers, ok := response["authorization_servers"].([]any)
|
||||
require.True(t, ok)
|
||||
assert.Len(t, authServers, 1)
|
||||
assert.Equal(t, defaultAuthorizationServer, authServers[0])
|
||||
}
|
||||
|
||||
func TestOAuthProtectedResourcePrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// RFC 9728 specifies this well-known path
|
||||
assert.Equal(t, "/.well-known/oauth-protected-resource", OAuthProtectedResourcePrefix)
|
||||
}
|
||||
|
||||
func TestDefaultAuthorizationServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "https://github.com/login/oauth", defaultAuthorizationServer)
|
||||
}
|
||||
|
||||
func TestAPIHostResolver_AuthorizationServerURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
oauthConfig *Config
|
||||
expectedURL string
|
||||
expectedError bool
|
||||
expectedStatusCode int
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "valid host returns authorization server URL",
|
||||
host: "https://github.com",
|
||||
expectedURL: "https://github.com/login/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "invalid host returns error",
|
||||
host: "://invalid-url",
|
||||
expectedURL: "",
|
||||
expectedError: true,
|
||||
errorContains: "could not parse host as URL",
|
||||
},
|
||||
{
|
||||
name: "host without scheme returns error",
|
||||
host: "github.com",
|
||||
expectedURL: "",
|
||||
expectedError: true,
|
||||
errorContains: "host must have a scheme",
|
||||
},
|
||||
{
|
||||
name: "GHEC host returns correct authorization server URL",
|
||||
host: "https://test.ghe.com",
|
||||
expectedURL: "https://test.ghe.com/login/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GHES host returns correct authorization server URL",
|
||||
host: "https://ghe.example.com",
|
||||
expectedURL: "https://ghe.example.com/login/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "GHES with http scheme returns the correct authorization server URL",
|
||||
host: "http://ghe.example.com",
|
||||
expectedURL: "http://ghe.example.com/login/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "custom authorization server in config takes precedence",
|
||||
host: "https://github.com",
|
||||
oauthConfig: &Config{
|
||||
AuthorizationServer: "https://custom.auth.example.com/oauth",
|
||||
},
|
||||
expectedURL: "https://custom.auth.example.com/oauth",
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
apiHost, err := utils.NewAPIHost(tc.host)
|
||||
if tc.expectedError {
|
||||
require.Error(t, err)
|
||||
if tc.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tc.errorContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
config := tc.oauthConfig
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
config.BaseURL = tc.host
|
||||
|
||||
handler, err := NewAuthHandler(config, apiHost)
|
||||
require.NoError(t, err)
|
||||
|
||||
router := chi.NewRouter()
|
||||
handler.RegisterRoutes(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, OAuthProtectedResourcePrefix, nil)
|
||||
req.Host = "api.example.com"
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response map[string]any
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "authorization_servers")
|
||||
if tc.expectedStatusCode != http.StatusOK {
|
||||
require.Equal(t, tc.expectedStatusCode, rec.Code)
|
||||
if tc.errorContains != "" {
|
||||
assert.Contains(t, rec.Body.String(), tc.errorContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
responseAuthServers, ok := response["authorization_servers"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, responseAuthServers, 1)
|
||||
assert.Equal(t, tc.expectedURL, responseAuthServers[0])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/github"
|
||||
"github.com/github/github-mcp-server/pkg/http/middleware"
|
||||
"github.com/github/github-mcp-server/pkg/http/oauth"
|
||||
"github.com/github/github-mcp-server/pkg/inventory"
|
||||
"github.com/github/github-mcp-server/pkg/lockdown"
|
||||
"github.com/github/github-mcp-server/pkg/observability"
|
||||
"github.com/github/github-mcp-server/pkg/observability/metrics"
|
||||
"github.com/github/github-mcp-server/pkg/scopes"
|
||||
"github.com/github/github-mcp-server/pkg/translations"
|
||||
"github.com/github/github-mcp-server/pkg/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
// Version of the server
|
||||
Version string
|
||||
|
||||
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
|
||||
Host string
|
||||
|
||||
// Port to listen on (default: 8082).
|
||||
Port int
|
||||
|
||||
// ListenHost is the host the HTTP server binds to (e.g. "127.0.0.1").
|
||||
// When empty, the server binds to all interfaces. Combined with Port.
|
||||
ListenHost string
|
||||
|
||||
// BaseURL is the publicly accessible URL of this server for OAuth resource metadata.
|
||||
// If not set, the server will derive the URL from incoming request headers.
|
||||
BaseURL string
|
||||
|
||||
// ResourcePath is the externally visible base path for this server (e.g., "/mcp").
|
||||
// This is used to restore the original path when a proxy strips a base path before forwarding.
|
||||
ResourcePath string
|
||||
|
||||
// TrustProxyHeaders indicates whether X-Forwarded-Host and X-Forwarded-Proto
|
||||
// should be honored when constructing OAuth resource metadata URLs. Only
|
||||
// enable this when the server is deployed behind a trusted proxy that sets
|
||||
// these headers. When BaseURL is set, it always wins and this setting has
|
||||
// no effect.
|
||||
TrustProxyHeaders bool
|
||||
|
||||
// ExportTranslations indicates if we should export translations
|
||||
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
|
||||
ExportTranslations bool
|
||||
|
||||
// EnableCommandLogging indicates if we should log commands
|
||||
EnableCommandLogging bool
|
||||
|
||||
// Path to the log file if not stderr
|
||||
LogFilePath string
|
||||
|
||||
// Content window size
|
||||
ContentWindowSize int
|
||||
|
||||
// LockdownMode indicates if we should enable lockdown mode
|
||||
LockdownMode bool
|
||||
|
||||
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
|
||||
RepoAccessCacheTTL *time.Duration
|
||||
|
||||
// ScopeChallenge indicates if we should return OAuth scope challenges, and if we should perform
|
||||
// tool filtering based on token scopes.
|
||||
ScopeChallenge bool
|
||||
|
||||
// ReadOnly indicates if we should only register read-only tools.
|
||||
// When set via CLI flag, this acts as an upper bound — per-request headers
|
||||
// cannot re-enable write tools.
|
||||
ReadOnly bool
|
||||
|
||||
// EnabledToolsets is a list of toolsets to enable.
|
||||
// When set via CLI flag, per-request headers can only narrow within these toolsets.
|
||||
EnabledToolsets []string
|
||||
|
||||
// EnabledTools is a list of specific tools to enable (additive to toolsets).
|
||||
EnabledTools []string
|
||||
|
||||
// ExcludeTools is a list of tool names to disable regardless of other settings.
|
||||
// When set via CLI flag, per-request headers cannot re-include these tools.
|
||||
ExcludeTools []string
|
||||
|
||||
// EnabledFeatures is a list of feature flags that are enabled.
|
||||
EnabledFeatures []string
|
||||
|
||||
// InsidersMode expands to the curated set of feature flags enabled for insiders.
|
||||
InsidersMode bool
|
||||
}
|
||||
|
||||
func RunHTTPServer(cfg ServerConfig) error {
|
||||
// Create app context
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
t, dumpTranslations := translations.TranslationHelper()
|
||||
|
||||
var slogHandler slog.Handler
|
||||
var logOutput io.Writer
|
||||
if cfg.LogFilePath != "" {
|
||||
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
logOutput = file
|
||||
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
} else {
|
||||
logOutput = os.Stderr
|
||||
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
|
||||
}
|
||||
logger := slog.New(slogHandler)
|
||||
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "lockdownEnabled", cfg.LockdownMode, "readOnly", cfg.ReadOnly, "insidersMode", cfg.InsidersMode)
|
||||
|
||||
apiHost, err := utils.NewAPIHost(cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse API host: %w", err)
|
||||
}
|
||||
|
||||
repoAccessOpts := []lockdown.RepoAccessOption{
|
||||
lockdown.WithLogger(logger.With("component", "lockdown")),
|
||||
}
|
||||
if cfg.RepoAccessCacheTTL != nil {
|
||||
repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessCacheTTL))
|
||||
}
|
||||
|
||||
featureChecker := createHTTPFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode)
|
||||
|
||||
obs, err := observability.NewExporters(logger, metrics.NewNoopMetrics())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create observability exporters: %w", err)
|
||||
}
|
||||
|
||||
deps := github.NewRequestDeps(
|
||||
apiHost,
|
||||
cfg.Version,
|
||||
cfg.LockdownMode,
|
||||
repoAccessOpts,
|
||||
t,
|
||||
cfg.ContentWindowSize,
|
||||
featureChecker,
|
||||
obs,
|
||||
)
|
||||
|
||||
// Initialize the global tool scope map
|
||||
err = initGlobalToolScopeMap(t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize tool scope map: %w", err)
|
||||
}
|
||||
|
||||
// Register OAuth protected resource metadata endpoints
|
||||
oauthCfg := &oauth.Config{
|
||||
BaseURL: cfg.BaseURL,
|
||||
ResourcePath: cfg.ResourcePath,
|
||||
TrustProxyHeaders: cfg.TrustProxyHeaders,
|
||||
}
|
||||
|
||||
serverOptions := []HandlerOption{}
|
||||
if cfg.ScopeChallenge {
|
||||
scopeFetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
|
||||
serverOptions = append(serverOptions, WithScopeFetcher(scopeFetcher))
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, apiHost, append(serverOptions, WithFeatureChecker(featureChecker), WithOAuthConfig(oauthCfg))...)
|
||||
oauthHandler, err := oauth.NewAuthHandler(oauthCfg, apiHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OAuth handler: %w", err)
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.SetCorsHeaders)
|
||||
|
||||
// Register Middleware First, needs to be before route registration
|
||||
handler.RegisterMiddleware(r)
|
||||
|
||||
// Register MCP server routes
|
||||
handler.RegisterRoutes(r)
|
||||
})
|
||||
logger.Info("MCP endpoints registered", "baseURL", cfg.BaseURL)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
// Register OAuth protected resource metadata endpoints
|
||||
oauthHandler.RegisterRoutes(r)
|
||||
})
|
||||
logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL)
|
||||
|
||||
addr := resolveListenAddress(cfg.ListenHost, cfg.Port)
|
||||
httpSvr := http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
logger.Info("shutting down server")
|
||||
if err := httpSvr.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("error during server shutdown", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if cfg.ExportTranslations {
|
||||
// Once server is initialized, all translations are loaded
|
||||
dumpTranslations()
|
||||
}
|
||||
|
||||
logger.Info("HTTP server listening", "addr", addr)
|
||||
if err := httpSvr.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return fmt.Errorf("HTTP server error: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("server stopped gracefully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveListenAddress returns the address string passed to http.Server.
|
||||
// When host is empty the server binds to all interfaces on the given port;
|
||||
// otherwise host and port are joined into a single address.
|
||||
func resolveListenAddress(host string, port int) string {
|
||||
if host == "" {
|
||||
return fmt.Sprintf(":%d", port)
|
||||
}
|
||||
return net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error {
|
||||
// Build inventory with all tools to extract scope information
|
||||
inv, err := inventory.NewBuilder().
|
||||
SetTools(github.AllTools(t)).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build inventory for tool scope map: %w", err)
|
||||
}
|
||||
|
||||
// Initialize the global scope map
|
||||
scopes.SetToolScopeMapFromInventory(inv)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createHTTPFeatureChecker creates a feature checker that resolves static CLI
|
||||
// features plus per-request header features and insiders mode.
|
||||
func createHTTPFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker {
|
||||
return func(ctx context.Context, flag string) (bool, error) {
|
||||
headerFeatures := ghcontext.GetHeaderFeatures(ctx)
|
||||
features := make([]string, 0, len(enabledFeatures)+len(headerFeatures))
|
||||
features = append(features, enabledFeatures...)
|
||||
features = append(features, headerFeatures...)
|
||||
|
||||
effective := github.ResolveFeatureFlags(features, insidersMode || ghcontext.IsInsidersMode(ctx))
|
||||
return effective[flag], nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/github"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateHTTPFeatureChecker(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
staticFeatures []string
|
||||
staticInsiders bool
|
||||
flagName string
|
||||
headerFeatures []string
|
||||
insidersMode bool
|
||||
wantEnabled bool
|
||||
}{
|
||||
{
|
||||
name: "allowed issues_granular flag accepted from header",
|
||||
flagName: github.FeatureFlagIssuesGranular,
|
||||
headerFeatures: []string{github.FeatureFlagIssuesGranular},
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "allowed pull_requests_granular flag accepted from header",
|
||||
flagName: github.FeatureFlagPullRequestsGranular,
|
||||
headerFeatures: []string{github.FeatureFlagPullRequestsGranular},
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "MCP Apps flag accepted from header",
|
||||
flagName: github.MCPAppsFeatureFlag,
|
||||
headerFeatures: []string{github.MCPAppsFeatureFlag},
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "unknown flag in header is ignored",
|
||||
flagName: "unknown_flag",
|
||||
headerFeatures: []string{"unknown_flag"},
|
||||
wantEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "allowed flag not in header returns false",
|
||||
flagName: github.FeatureFlagIssuesGranular,
|
||||
headerFeatures: nil,
|
||||
wantEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "allowed flag with different flag in header returns false",
|
||||
flagName: github.FeatureFlagIssuesGranular,
|
||||
headerFeatures: []string{github.FeatureFlagPullRequestsGranular},
|
||||
wantEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "multiple allowed flags in header",
|
||||
flagName: github.FeatureFlagIssuesGranular,
|
||||
headerFeatures: []string{github.FeatureFlagIssuesGranular, github.FeatureFlagPullRequestsGranular},
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "empty header features",
|
||||
flagName: github.FeatureFlagIssuesGranular,
|
||||
headerFeatures: []string{},
|
||||
wantEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "insiders mode enables MCP Apps without header",
|
||||
flagName: github.MCPAppsFeatureFlag,
|
||||
insidersMode: true,
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "static feature is enabled without header",
|
||||
staticFeatures: []string{github.FeatureFlagCSVOutput},
|
||||
flagName: github.FeatureFlagCSVOutput,
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "static features combine with header features",
|
||||
staticFeatures: []string{github.FeatureFlagCSVOutput},
|
||||
flagName: github.FeatureFlagIssuesGranular,
|
||||
headerFeatures: []string{github.FeatureFlagIssuesGranular},
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "static insiders enables insiders flags without route context",
|
||||
staticInsiders: true,
|
||||
flagName: github.FeatureFlagCSVOutput,
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "insiders mode does not auto-enable ifc labels",
|
||||
flagName: github.FeatureFlagIFCLabels,
|
||||
insidersMode: true,
|
||||
wantEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "insiders mode does not enable granular flags",
|
||||
flagName: github.FeatureFlagIssuesGranular,
|
||||
insidersMode: true,
|
||||
wantEnabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
checker := createHTTPFeatureChecker(tt.staticFeatures, tt.staticInsiders)
|
||||
ctx := context.Background()
|
||||
if len(tt.headerFeatures) > 0 {
|
||||
ctx = ghcontext.WithHeaderFeatures(ctx, tt.headerFeatures)
|
||||
}
|
||||
if tt.insidersMode {
|
||||
ctx = ghcontext.WithInsidersMode(ctx, true)
|
||||
}
|
||||
|
||||
enabled, err := checker(ctx, tt.flagName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantEnabled, enabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveListenAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty host falls back to :port",
|
||||
host: "",
|
||||
port: 8082,
|
||||
want: ":8082",
|
||||
},
|
||||
{
|
||||
name: "ipv4 host is joined with port",
|
||||
host: "127.0.0.1",
|
||||
port: 9090,
|
||||
want: "127.0.0.1:9090",
|
||||
},
|
||||
{
|
||||
name: "ipv6 host is bracketed and joined with port",
|
||||
host: "::1",
|
||||
port: 9090,
|
||||
want: "[::1]:9090",
|
||||
},
|
||||
{
|
||||
name: "hostname is joined with port",
|
||||
host: "localhost",
|
||||
port: 8082,
|
||||
want: "localhost:8082",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveListenAddress(tt.host, tt.port)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderAllowedFeatureFlagsMatchesAllowed(t *testing.T) {
|
||||
// Ensure HeaderAllowedFeatureFlags delegates to AllowedFeatureFlags
|
||||
allowed := github.HeaderAllowedFeatureFlags()
|
||||
assert.Equal(t, github.AllowedFeatureFlags, allowed,
|
||||
"HeaderAllowedFeatureFlags() should match AllowedFeatureFlags")
|
||||
assert.NotEmpty(t, allowed, "AllowedFeatureFlags should not be empty")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
headers "github.com/github/github-mcp-server/pkg/http/headers"
|
||||
)
|
||||
|
||||
type BearerAuthTransport struct {
|
||||
Transport http.RoundTripper
|
||||
Token string
|
||||
|
||||
// TokenProvider, when non-nil, supplies the bearer token for each request
|
||||
// and takes precedence over Token. It backs OAuth, where the token is
|
||||
// obtained after the client is built and is refreshed over the session's
|
||||
// lifetime. It may return an empty string before authorization completes.
|
||||
TokenProvider func() string
|
||||
}
|
||||
|
||||
func (t *BearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
token := t.Token
|
||||
if t.TokenProvider != nil {
|
||||
token = t.TokenProvider()
|
||||
}
|
||||
// Before OAuth authorization completes the token is empty; send an
|
||||
// unauthenticated request rather than an empty "Bearer " header.
|
||||
if token != "" {
|
||||
req.Header.Set(headers.AuthorizationHeader, "Bearer "+token)
|
||||
}
|
||||
|
||||
// Check for GraphQL-Features in context and add header if present
|
||||
if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 {
|
||||
req.Header.Set(headers.GraphQLFeaturesHeader, strings.Join(features, ", "))
|
||||
}
|
||||
|
||||
return t.Transport.RoundTrip(req)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBearerAuthTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
tokenProvider func() string
|
||||
wantAuth string
|
||||
}{
|
||||
{
|
||||
name: "static token",
|
||||
token: "static-token",
|
||||
wantAuth: "Bearer static-token",
|
||||
},
|
||||
{
|
||||
name: "token provider takes precedence over static token",
|
||||
token: "static-token",
|
||||
tokenProvider: func() string { return "provided-token" },
|
||||
wantAuth: "Bearer provided-token",
|
||||
},
|
||||
{
|
||||
name: "token provider with empty static token",
|
||||
tokenProvider: func() string { return "provided-token" },
|
||||
wantAuth: "Bearer provided-token",
|
||||
},
|
||||
{
|
||||
name: "token provider may return empty before authorization",
|
||||
tokenProvider: func() string { return "" },
|
||||
wantAuth: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get(headers.AuthorizationHeader)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &BearerAuthTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Token: tc.token,
|
||||
TokenProvider: tc.tokenProvider,
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, tc.wantAuth, gotAuth)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBearerAuthTransport_TokenProviderResolvedPerRequest verifies that the
|
||||
// token provider is consulted on every request, so a token that arrives (or is
|
||||
// refreshed) after the transport is constructed takes effect without rebuilding
|
||||
// the client. This is the property OAuth relies on.
|
||||
func TestBearerAuthTransport_TokenProviderResolvedPerRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get(headers.AuthorizationHeader)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
current := ""
|
||||
rt := &BearerAuthTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
TokenProvider: func() string { return current },
|
||||
}
|
||||
|
||||
do := func() {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
resp, err := rt.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
do()
|
||||
assert.Equal(t, "", gotAuth, "no auth header before authorization")
|
||||
|
||||
current = "first-token"
|
||||
do()
|
||||
assert.Equal(t, "Bearer first-token", gotAuth, "token picked up once available")
|
||||
|
||||
current = "refreshed-token"
|
||||
do()
|
||||
assert.Equal(t, "Bearer refreshed-token", gotAuth, "refreshed token picked up")
|
||||
}
|
||||
|
||||
func TestBearerAuthTransport_PassesGraphQLFeaturesHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotFeatures string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotFeatures = r.Header.Get(headers.GraphQLFeaturesHeader)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &BearerAuthTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Token: "token",
|
||||
}
|
||||
|
||||
ctx := ghcontext.WithGraphQLFeatures(context.Background(), "feature1", "feature2")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, "feature1, feature2", gotFeatures)
|
||||
}
|
||||
|
||||
func TestBearerAuthTransport_DoesNotMutateOriginalRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
rt := &BearerAuthTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Token: "token",
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := rt.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Empty(t, req.Header.Get(headers.AuthorizationHeader), "original request must not be mutated")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
||||
"github.com/github/github-mcp-server/pkg/http/headers"
|
||||
)
|
||||
|
||||
// GraphQLFeaturesTransport is an http.RoundTripper that adds GraphQL-Features
|
||||
// header to requests based on context values. This is required for using
|
||||
// non-GA GraphQL API features like the agent assignment API.
|
||||
//
|
||||
// This transport is used internally by the MCP server and is also exported
|
||||
// for library consumers who need to build their own HTTP clients with
|
||||
// GraphQL feature flag support.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import "github.com/github/github-mcp-server/pkg/http/transport"
|
||||
//
|
||||
// httpClient := &http.Client{
|
||||
// Transport: &transport.GraphQLFeaturesTransport{
|
||||
// Transport: http.DefaultTransport,
|
||||
// },
|
||||
// }
|
||||
// gqlClient := githubv4.NewClient(httpClient)
|
||||
//
|
||||
// Then use ghcontext.WithGraphQLFeatures(ctx, "feature_name") when calling GraphQL operations.
|
||||
type GraphQLFeaturesTransport struct {
|
||||
// Transport is the underlying HTTP transport. If nil, http.DefaultTransport is used.
|
||||
Transport http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *GraphQLFeaturesTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
transport := t.Transport
|
||||
if transport == nil {
|
||||
transport = http.DefaultTransport
|
||||
}
|
||||
|
||||
// Clone the request to avoid mutating the original
|
||||
req = req.Clone(req.Context())
|
||||
|
||||
// Check for GraphQL-Features in context and add header if present
|
||||
if features := ghcontext.GetGraphQLFeatures(req.Context()); len(features) > 0 {
|
||||
req.Header.Set(headers.GraphQLFeaturesHeader, strings.Join(features, ", "))
|
||||
}
|
||||
|
||||
return transport.RoundTrip(req)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGraphQLFeaturesTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
features []string
|
||||
expectedHeader string
|
||||
hasHeader bool
|
||||
}{
|
||||
{
|
||||
name: "no features in context",
|
||||
features: nil,
|
||||
expectedHeader: "",
|
||||
hasHeader: false,
|
||||
},
|
||||
{
|
||||
name: "single feature in context",
|
||||
features: []string{"issues_copilot_assignment_api_support"},
|
||||
expectedHeader: "issues_copilot_assignment_api_support",
|
||||
hasHeader: true,
|
||||
},
|
||||
{
|
||||
name: "multiple features in context",
|
||||
features: []string{"feature1", "feature2", "feature3"},
|
||||
expectedHeader: "feature1, feature2, feature3",
|
||||
hasHeader: true,
|
||||
},
|
||||
{
|
||||
name: "empty features slice",
|
||||
features: []string{},
|
||||
expectedHeader: "",
|
||||
hasHeader: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var capturedHeader string
|
||||
var headerExists bool
|
||||
|
||||
// Create a test server that captures the request header
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedHeader = r.Header.Get(headers.GraphQLFeaturesHeader)
|
||||
headerExists = r.Header.Get(headers.GraphQLFeaturesHeader) != ""
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create the transport
|
||||
transport := &GraphQLFeaturesTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
// Create a request
|
||||
ctx := context.Background()
|
||||
if tc.features != nil {
|
||||
ctx = ghcontext.WithGraphQLFeatures(ctx, tc.features...)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Execute the request
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify the header
|
||||
assert.Equal(t, tc.hasHeader, headerExists)
|
||||
if tc.hasHeader {
|
||||
assert.Equal(t, tc.expectedHeader, capturedHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphQLFeaturesTransport_NilTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var capturedHeader string
|
||||
|
||||
// Create a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedHeader = r.Header.Get(headers.GraphQLFeaturesHeader)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create the transport with nil Transport (should use DefaultTransport)
|
||||
transport := &GraphQLFeaturesTransport{
|
||||
Transport: nil,
|
||||
}
|
||||
|
||||
// Create a request with features
|
||||
ctx := ghcontext.WithGraphQLFeatures(context.Background(), "test_feature")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Execute the request
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify the header was added
|
||||
assert.Equal(t, "test_feature", capturedHeader)
|
||||
}
|
||||
|
||||
func TestGraphQLFeaturesTransport_DoesNotMutateOriginalRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a test server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Create the transport
|
||||
transport := &GraphQLFeaturesTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
// Create a request with features
|
||||
ctx := ghcontext.WithGraphQLFeatures(context.Background(), "test_feature")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store the original header value
|
||||
originalHeader := req.Header.Get(headers.GraphQLFeaturesHeader)
|
||||
|
||||
// Execute the request
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify the original request was not mutated
|
||||
assert.Equal(t, originalHeader, req.Header.Get(headers.GraphQLFeaturesHeader))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/http/headers"
|
||||
)
|
||||
|
||||
type UserAgentTransport struct {
|
||||
Transport http.RoundTripper
|
||||
Agent string
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(headers.UserAgentHeader, t.Agent)
|
||||
return t.Transport.RoundTrip(req)
|
||||
}
|
||||
Reference in New Issue
Block a user