Skip to content

Commit 90a7201

Browse files
committed
refactor: centralize feature flag resolution and improve feature checker logic
1 parent 3bdaf75 commit 90a7201

File tree

7 files changed

+150
-78
lines changed

7 files changed

+150
-78
lines changed

internal/ghmcp/server.go

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"net/http"
99
"os"
1010
"os/signal"
11-
"slices"
1211
"strings"
1312
"syscall"
1413
"time"
@@ -115,18 +114,8 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
115114
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
116115
}
117116

118-
// Create feature checker — insiders mode expands InsidersFeatureFlags
119-
enabledFeatures := cfg.EnabledFeatures
120-
if cfg.InsidersMode {
121-
enabledFeatures = slices.Clone(enabledFeatures)
122-
for _, flag := range github.InsidersFeatureFlags {
123-
if !slices.Contains(enabledFeatures, flag) {
124-
enabledFeatures = append(enabledFeatures, flag)
125-
}
126-
}
127-
}
128-
featureChecker := createFeatureChecker(enabledFeatures)
129-
mcpAppsEnabled := slices.Contains(enabledFeatures, github.MCPAppsFeatureFlag)
117+
// Create feature checker — resolves explicit features + insiders expansion
118+
featureChecker := createFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode)
130119

131120
// Create dependencies for tool handlers
132121
obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics())
@@ -155,8 +144,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
155144
WithTools(github.CleanTools(cfg.EnabledTools)).
156145
WithExcludeTools(cfg.ExcludeTools).
157146
WithServerInstructions().
158-
WithFeatureChecker(featureChecker).
159-
WithMCPApps(mcpAppsEnabled)
147+
WithFeatureChecker(featureChecker)
160148

161149
// Apply token scope filtering if scopes are known (for PAT filtering)
162150
if cfg.TokenScopes != nil {
@@ -177,6 +165,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
177165
// and UI assets are available (requires running script/build-ui).
178166
// We check availability to allow the feature flag to be enabled without
179167
// requiring a UI build (graceful degradation).
168+
mcpAppsEnabled, _ := featureChecker(context.Background(), github.MCPAppsFeatureFlag)
180169
if mcpAppsEnabled && github.UIAssetsAvailable() {
181170
github.RegisterUIResources(ghServer)
182171
}
@@ -346,15 +335,11 @@ func RunStdioServer(cfg StdioServerConfig) error {
346335
return nil
347336
}
348337

349-
// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name
350-
// is present in the provided list of enabled features. For the local server,
351-
// this is populated from the --features CLI flag.
352-
func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker {
353-
// Build a set for O(1) lookup
354-
featureSet := make(map[string]bool, len(enabledFeatures))
355-
for _, f := range enabledFeatures {
356-
featureSet[f] = true
357-
}
338+
// createFeatureChecker returns a FeatureFlagChecker that resolves features
339+
// using the centralized ResolveFeatureFlags function. For the local server,
340+
// features are resolved once at startup from --features CLI flag + insiders mode.
341+
func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker {
342+
featureSet := github.ResolveFeatureFlags(enabledFeatures, insidersMode)
358343
return func(_ context.Context, flagName string) (bool, error) {
359344
return featureSet[flagName], nil
360345
}

pkg/github/feature_flags.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ package github
33
// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms).
44
const MCPAppsFeatureFlag = "remote_mcp_ui_apps"
55

6+
// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
7+
// by users via --features CLI flag or X-MCP-Features HTTP header.
8+
// Only flags in this list are accepted; unknown flags are silently ignored.
9+
// This is the single source of truth for which flags are user-controllable.
10+
var AllowedFeatureFlags = []string{
11+
MCPAppsFeatureFlag,
12+
}
13+
614
// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
715
// When insiders mode is active, all flags in this list are treated as enabled.
816
// This is the single source of truth for what "insiders" means in terms of
@@ -16,3 +24,31 @@ type FeatureFlags struct {
1624
LockdownMode bool
1725
InsidersMode bool
1826
}
27+
28+
// ResolveFeatureFlags computes the effective set of enabled feature flags by:
29+
// 1. Taking explicitly enabled features (from CLI flags or HTTP headers)
30+
// 2. Adding insiders-expanded features when insiders mode is active
31+
// 3. Validating all features against the AllowedFeatureFlags allowlist
32+
//
33+
// Returns a set (map) for O(1) lookup by the feature checker.
34+
func ResolveFeatureFlags(enabledFeatures []string, insidersMode bool) map[string]bool {
35+
allowed := make(map[string]bool, len(AllowedFeatureFlags))
36+
for _, f := range AllowedFeatureFlags {
37+
allowed[f] = true
38+
}
39+
40+
effective := make(map[string]bool)
41+
for _, f := range enabledFeatures {
42+
if allowed[f] {
43+
effective[f] = true
44+
}
45+
}
46+
if insidersMode {
47+
for _, f := range InsidersFeatureFlags {
48+
if allowed[f] {
49+
effective[f] = true
50+
}
51+
}
52+
}
53+
return effective
54+
}

pkg/github/feature_flags_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,70 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) {
136136
}
137137
}
138138

139+
func TestResolveFeatureFlags(t *testing.T) {
140+
t.Parallel()
141+
142+
tests := []struct {
143+
name string
144+
enabledFeatures []string
145+
insidersMode bool
146+
expectedFlags []string
147+
unexpectedFlags []string
148+
}{
149+
{
150+
name: "no features, no insiders",
151+
enabledFeatures: nil,
152+
insidersMode: false,
153+
expectedFlags: nil,
154+
unexpectedFlags: []string{MCPAppsFeatureFlag},
155+
},
156+
{
157+
name: "explicit feature enabled",
158+
enabledFeatures: []string{MCPAppsFeatureFlag},
159+
insidersMode: false,
160+
expectedFlags: []string{MCPAppsFeatureFlag},
161+
},
162+
{
163+
name: "insiders mode enables insiders flags",
164+
enabledFeatures: nil,
165+
insidersMode: true,
166+
expectedFlags: InsidersFeatureFlags,
167+
},
168+
{
169+
name: "unknown flags are filtered out",
170+
enabledFeatures: []string{"unknown_flag", "another_unknown"},
171+
insidersMode: false,
172+
unexpectedFlags: []string{"unknown_flag", "another_unknown"},
173+
},
174+
{
175+
name: "mix of known and unknown flags",
176+
enabledFeatures: []string{MCPAppsFeatureFlag, "unknown_flag"},
177+
insidersMode: false,
178+
expectedFlags: []string{MCPAppsFeatureFlag},
179+
unexpectedFlags: []string{"unknown_flag"},
180+
},
181+
{
182+
name: "explicit plus insiders deduplicates",
183+
enabledFeatures: []string{MCPAppsFeatureFlag},
184+
insidersMode: true,
185+
expectedFlags: []string{MCPAppsFeatureFlag},
186+
},
187+
}
188+
189+
for _, tt := range tests {
190+
t.Run(tt.name, func(t *testing.T) {
191+
t.Parallel()
192+
result := ResolveFeatureFlags(tt.enabledFeatures, tt.insidersMode)
193+
for _, flag := range tt.expectedFlags {
194+
assert.True(t, result[flag], "expected flag %q to be enabled", flag)
195+
}
196+
for _, flag := range tt.unexpectedFlags {
197+
assert.False(t, result[flag], "expected flag %q to not be enabled", flag)
198+
}
199+
})
200+
}
201+
}
202+
139203
func TestHelloWorld_ConditionalBehavior_Config(t *testing.T) {
140204
t.Parallel()
141205

pkg/http/handler.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"errors"
66
"log/slog"
77
"net/http"
8-
"slices"
98

109
ghcontext "github.com/github/github-mcp-server/pkg/context"
1110
"github.com/github/github-mcp-server/pkg/github"
@@ -254,22 +253,16 @@ func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFu
254253
}
255254

256255
// InventoryFiltersForRequest applies filters to the inventory builder
257-
// based on the request context and headers
256+
// based on the request context and headers.
257+
// MCP Apps UI metadata is handled by the builder via the feature checker —
258+
// no need to check headers here.
258259
func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder {
259260
ctx := r.Context()
260261

261262
if ghcontext.IsReadonly(ctx) {
262263
builder = builder.WithReadOnly(true)
263264
}
264265

265-
// Enable MCP Apps if the feature flag is present in the request headers
266-
// or if insiders mode is active (insiders expands InsidersFeatureFlags).
267-
headerFeatures := ghcontext.GetHeaderFeatures(ctx)
268-
mcpApps := slices.Contains(headerFeatures, github.MCPAppsFeatureFlag) || ghcontext.IsInsidersMode(ctx)
269-
if mcpApps {
270-
builder = builder.WithMCPApps(true)
271-
}
272-
273266
toolsets := ghcontext.GetToolsets(ctx)
274267
tools := ghcontext.GetTools(ctx)
275268

pkg/http/server.go

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"net/http"
99
"os"
1010
"os/signal"
11-
"slices"
1211
"syscall"
1312
"time"
1413

@@ -25,12 +24,6 @@ import (
2524
"github.com/go-chi/chi/v5"
2625
)
2726

28-
// knownFeatureFlags are the feature flags that can be enabled via X-MCP-Features header.
29-
// Only these flags are accepted from headers.
30-
var knownFeatureFlags = []string{
31-
github.MCPAppsFeatureFlag,
32-
}
33-
3427
type ServerConfig struct {
3528
// Version of the server
3629
Version string
@@ -213,30 +206,14 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error {
213206
return nil
214207
}
215208

216-
// createHTTPFeatureChecker creates a feature checker that reads header features from context
217-
// and validates them against the knownFeatureFlags whitelist.
218-
// When insiders mode is active, InsidersFeatureFlags are also treated as enabled.
209+
// createHTTPFeatureChecker creates a feature checker that resolves features
210+
// per-request by reading header features and insiders mode from context,
211+
// then validating against the centralized AllowedFeatureFlags allowlist.
219212
func createHTTPFeatureChecker() inventory.FeatureFlagChecker {
220-
// Pre-compute whitelist as set for O(1) lookup
221-
knownSet := make(map[string]bool, len(knownFeatureFlags))
222-
for _, f := range knownFeatureFlags {
223-
knownSet[f] = true
224-
}
225-
226-
// Pre-compute insiders flags as set for O(1) lookup
227-
insidersSet := make(map[string]bool, len(github.InsidersFeatureFlags))
228-
for _, f := range github.InsidersFeatureFlags {
229-
insidersSet[f] = true
230-
}
231-
232213
return func(ctx context.Context, flag string) (bool, error) {
233-
if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) {
234-
return true, nil
235-
}
236-
// Insiders mode enables all InsidersFeatureFlags
237-
if insidersSet[flag] && ghcontext.IsInsidersMode(ctx) {
238-
return true, nil
239-
}
240-
return false, nil
214+
headerFeatures := ghcontext.GetHeaderFeatures(ctx)
215+
insidersMode := ghcontext.IsInsidersMode(ctx)
216+
effective := github.ResolveFeatureFlags(headerFeatures, insidersMode)
217+
return effective[flag], nil
241218
}
242219
}

pkg/inventory/builder.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ var (
1414
ErrUnknownTools = errors.New("unknown tools specified in WithTools")
1515
)
1616

17+
// mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata.
18+
// This is defined here to avoid importing pkg/github (which imports pkg/inventory).
19+
// The value must match github.MCPAppsFeatureFlag.
20+
const mcpAppsFeatureFlag = "remote_mcp_ui_apps"
21+
1722
// ToolFilter is a function that determines if a tool should be included.
1823
// Returns true if the tool should be included, false to exclude it.
1924
type ToolFilter func(ctx context.Context, tool *ServerTool) (bool, error)
@@ -48,7 +53,6 @@ type Builder struct {
4853
featureChecker FeatureFlagChecker
4954
filters []ToolFilter // filters to apply to all tools
5055
generateInstructions bool
51-
mcpApps bool
5256
}
5357

5458
// NewBuilder creates a new Builder.
@@ -154,15 +158,6 @@ func (b *Builder) WithExcludeTools(toolNames []string) *Builder {
154158
return b
155159
}
156160

157-
// WithMCPApps enables or disables MCP Apps UI features.
158-
// When disabled (default), the "ui" Meta key is stripped from tools
159-
// so clients won't attempt to load UI resources.
160-
// Returns self for chaining.
161-
func (b *Builder) WithMCPApps(enabled bool) *Builder {
162-
b.mcpApps = enabled
163-
return b
164-
}
165-
166161
// CreateExcludeToolsFilter creates a ToolFilter that excludes tools by name.
167162
// Any tool whose name appears in the excluded list will be filtered out.
168163
// The input slice should already be cleaned (trimmed, deduplicated).
@@ -195,6 +190,19 @@ func cleanTools(tools []string) []string {
195190
return cleaned
196191
}
197192

193+
// checkFeatureFlag checks a feature flag at build time using the builder's feature checker.
194+
// Returns false if no checker is configured or the flag is not enabled.
195+
func (b *Builder) checkFeatureFlag(flag string) bool {
196+
if b.featureChecker == nil {
197+
return false
198+
}
199+
enabled, err := b.featureChecker(context.Background(), flag)
200+
if err != nil {
201+
return false
202+
}
203+
return enabled
204+
}
205+
198206
// Build creates the final Inventory with all configuration applied.
199207
// This processes toolset filtering, tool name resolution, and sets up
200208
// the inventory for use. The returned Inventory is ready for use with
@@ -206,8 +214,10 @@ func cleanTools(tools []string) []string {
206214
func (b *Builder) Build() (*Inventory, error) {
207215
tools := b.tools
208216

209-
// When MCP Apps is disabled, strip UI metadata from tools
210-
if !b.mcpApps {
217+
// When MCP Apps feature flag is not enabled, strip UI metadata from tools
218+
// so clients won't attempt to load UI resources.
219+
// The feature checker is the single source of truth for flag evaluation.
220+
if !b.checkFeatureFlag(mcpAppsFeatureFlag) {
211221
tools = stripMCPAppsMetadata(tools)
212222
}
213223

pkg/inventory/registry_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,6 +1723,10 @@ func TestFilteringOrder(t *testing.T) {
17231723
WithFeatureChecker(checker).
17241724
WithFilter(filter))
17251725

1726+
// Reset call order — Build() may call the checker for MCP Apps metadata.
1727+
// We're testing the AvailableTools filter order here.
1728+
callOrder = callOrder[:0]
1729+
17261730
_ = reg.AvailableTools(context.Background())
17271731

17281732
// Expected order: Enabled, FeatureFlag, ReadOnly (stops here because it's write tool)
@@ -1881,11 +1885,14 @@ func TestWithMCPApps_EnabledPreservesUIMetadata(t *testing.T) {
18811885
"description": "kept",
18821886
})
18831887

1884-
// MCP Apps enabled - UI meta should be preserved
1888+
// Feature checker enables MCP Apps - UI meta should be preserved
1889+
mcpAppsChecker := func(_ context.Context, flag string) (bool, error) {
1890+
return flag == mcpAppsFeatureFlag, nil
1891+
}
18851892
reg := mustBuild(t, NewBuilder().
18861893
SetTools([]ServerTool{toolWithUI}).
18871894
WithToolsets([]string{"all"}).
1888-
WithMCPApps(true))
1895+
WithFeatureChecker(mcpAppsChecker))
18891896
available := reg.AvailableTools(context.Background())
18901897

18911898
require.Len(t, available, 1)

0 commit comments

Comments
 (0)