Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/remote-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,15 @@ The Remote GitHub MCP server supports the following URL path patterns:
- `/` - Default toolset (see ["default" toolset](../README.md#default-toolset))
- `/readonly` - Default toolset in read-only mode
- `/insiders` - Default toolset with insiders mode enabled
- `/insiders/readonly` - Default toolset with insiders mode in read-only mode
- `/readonly/insiders` - Default toolset in read-only mode with insiders mode enabled
- `/x/all` - All available toolsets
- `/x/all/readonly` - All available toolsets in read-only mode
- `/x/all/insiders` - All available toolsets with insiders mode enabled
- `/x/all/readonly/insiders` - All available toolsets in read-only mode with insiders mode enabled
- `/x/{toolset}` - Single specific toolset
- `/x/{toolset}/readonly` - Single specific toolset in read-only mode
- `/x/{toolset}/insiders` - Single specific toolset with insiders mode enabled
- `/x/{toolset}/readonly/insiders` - Single specific toolset in read-only mode with insiders mode enabled

Note: `{toolset}` can only be a single toolset, not a comma-separated list. To combine multiple toolsets, use the `X-MCP-Toolsets` header instead. Path modifiers like `/readonly` and `/insiders` can be combined with the `X-MCP-Insiders` or `X-MCP-Readonly` headers.

Expand Down
4 changes: 2 additions & 2 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
WithReadOnly(cfg.ReadOnly).
WithToolsets(cfg.EnabledToolsets).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithServerInstructions()
// WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures))
WithServerInstructions().
WithFeatureChecker(featureChecker)

// Apply token scope filtering if scopes are known (for PAT filtering)
if cfg.TokenScopes != nil {
Expand Down
32 changes: 32 additions & 0 deletions pkg/context/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,35 @@ func IsLockdownMode(ctx context.Context) bool {
}
return false
}

// insidersCtxKey is a context key for insiders mode
type insidersCtxKey struct{}

// WithInsidersMode adds insiders mode state to the context
func WithInsidersMode(ctx context.Context, enabled bool) context.Context {
return context.WithValue(ctx, insidersCtxKey{}, enabled)
}

// IsInsidersMode retrieves the insiders mode state from the context
func IsInsidersMode(ctx context.Context) bool {
if enabled, ok := ctx.Value(insidersCtxKey{}).(bool); ok {
return enabled
}
return false
}

// headerFeaturesCtxKey is a context key for raw header feature flags
type headerFeaturesCtxKey struct{}

// WithHeaderFeatures stores the raw feature flags from the X-MCP-Features header into context
func WithHeaderFeatures(ctx context.Context, features []string) context.Context {
return context.WithValue(ctx, headerFeaturesCtxKey{}, features)
}

// GetHeaderFeatures retrieves the raw feature flags from context
func GetHeaderFeatures(ctx context.Context) []string {
if features, ok := ctx.Value(headerFeaturesCtxKey{}).([]string); ok {
return features
}
return nil
}
Comment thread
kerobbi marked this conversation as resolved.
2 changes: 1 addition & 1 deletion pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,6 @@ type RequestDeps struct {
lockdownMode bool
RepoAccessOpts []lockdown.RepoAccessOption
T translations.TranslationHelperFunc
Flags FeatureFlags
ContentWindowSize int

// Feature flag checker for runtime checks
Expand Down Expand Up @@ -380,6 +379,7 @@ func (d *RequestDeps) GetT() translations.TranslationHelperFunc { return d.T }
func (d *RequestDeps) GetFlags(ctx context.Context) FeatureFlags {
return FeatureFlags{
LockdownMode: d.lockdownMode && ghcontext.IsLockdownMode(ctx),
InsidersMode: ghcontext.IsInsidersMode(ctx),
}
}

Expand Down
42 changes: 0 additions & 42 deletions pkg/http/features.go

This file was deleted.

43 changes: 30 additions & 13 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (

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/http/middleware"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
Expand All @@ -31,6 +30,7 @@ type Handler struct {
type HandlerOptions struct {
GitHubMcpServerFactory GitHubMCPServerFactoryFunc
InventoryFactory InventoryFactoryFunc
FeatureChecker inventory.FeatureFlagChecker
}

type HandlerOption func(*HandlerOptions)
Expand All @@ -47,6 +47,12 @@ func WithInventoryFactory(f InventoryFactoryFunc) HandlerOption {
}
}

func WithFeatureChecker(checker inventory.FeatureFlagChecker) HandlerOption {
return func(o *HandlerOptions) {
o.FeatureChecker = checker
}
}

func NewHTTPMcpHandler(
ctx context.Context,
cfg *ServerConfig,
Expand All @@ -66,7 +72,7 @@ func NewHTTPMcpHandler(

inventoryFactory := opts.InventoryFactory
if inventoryFactory == nil {
inventoryFactory = DefaultInventoryFactory(cfg, t, nil)
inventoryFactory = DefaultInventoryFactory(cfg, t, opts.FeatureChecker)
}

return &Handler{
Expand All @@ -85,11 +91,17 @@ func NewHTTPMcpHandler(
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Use(middleware.WithRequestConfig)

// Base routes
r.Mount("/", h)
// Mount readonly and toolset routes
r.With(withToolset).Mount("/x/{toolset}", h)
r.With(withReadonly, withToolset).Mount("/x/{toolset}/readonly", 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)
Comment thread
kerobbi marked this conversation as resolved.
}

// withReadonly is middleware that sets readonly mode in the request context
Expand All @@ -109,6 +121,14 @@ func withToolset(next http.Handler) http.Handler {
})
}

// 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) {
inventory, err := h.inventoryFactoryFunc(r)
if err != nil {
Expand Down Expand Up @@ -141,15 +161,12 @@ func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies
return github.NewMCPServer(r.Context(), cfg, deps, inventory)
}

func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, staticChecker inventory.FeatureFlagChecker) InventoryFactoryFunc {
// DefaultInventoryFactory creates the default inventory factory for HTTP mode
func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker) InventoryFactoryFunc {
return func(r *http.Request) (*inventory.Inventory, error) {
b := github.NewInventory(t).WithDeprecatedAliases(github.DeprecatedToolAliases)

// Feature checker composition
headerFeatures := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader))
if checker := ComposeFeatureChecker(headerFeatures, staticChecker); checker != nil {
b = b.WithFeatureChecker(checker)
}
b := github.NewInventory(t).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithFeatureChecker(featureChecker)

b = InventoryFiltersForRequest(r, b)
b.WithServerInstructions()
Expand Down
2 changes: 2 additions & 0 deletions pkg/http/headers/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const (
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"
// MCPFeaturesHeader is a comma-separated list of feature flags to enable.
MCPFeaturesHeader = "X-MCP-Features"
)
17 changes: 16 additions & 1 deletion pkg/http/middleware/request_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,42 @@ import (
"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
// 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)
}

// 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))
})
}
Expand Down
33 changes: 31 additions & 2 deletions pkg/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,26 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"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/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/go-chi/chi/v5"
)

// knownFeatureFlags are the feature flags that can be enabled via X-MCP-Features header.
// Only these flags are accepted from headers.
var knownFeatureFlags = []string{
github.FeatureFlagHoldbackConsolidatedProjects,
github.FeatureFlagHoldbackConsolidatedActions,
}

type ServerConfig struct {
// Version of the server
Version string
Expand Down Expand Up @@ -83,19 +93,21 @@ func RunHTTPServer(cfg ServerConfig) error {
repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessCacheTTL))
}

featureChecker := createHTTPFeatureChecker()

deps := github.NewRequestDeps(
apiHost,
cfg.Version,
cfg.LockdownMode,
repoAccessOpts,
t,
cfg.ContentWindowSize,
nil,
featureChecker,
)

r := chi.NewRouter()

handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger)
handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, WithFeatureChecker(featureChecker))
handler.RegisterRoutes(r)

addr := fmt.Sprintf(":%d", cfg.Port)
Expand Down Expand Up @@ -128,3 +140,20 @@ func RunHTTPServer(cfg ServerConfig) error {
logger.Info("server stopped gracefully")
return nil
}

// createHTTPFeatureChecker creates a feature checker that reads header features from context
// and validates them against the knownFeatureFlags whitelist
func createHTTPFeatureChecker() inventory.FeatureFlagChecker {
// Pre-compute whitelist as set for O(1) lookup
knownSet := make(map[string]bool, len(knownFeatureFlags))
for _, f := range knownFeatureFlags {
knownSet[f] = true
}

return func(ctx context.Context, flag string) (bool, error) {
if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) {
Comment thread
kerobbi marked this conversation as resolved.
return true, nil
}
return false, nil
}
}
Loading