Skip to content

Commit be14b88

Browse files
refactor(inventory): make feature-flag gating a regular ToolFilter
Move tool feature-flag evaluation out of isToolEnabled and into a ToolFilter installed at the head of the pipeline by Build() when WithFeatureChecker received a non-nil checker. The 'no checker = no filtering' contract is now expressed structurally (the filter isn't installed) instead of by a runtime nil check inside the helper. Resources and prompts have no filter pipeline, so they call the now-pure featureFlagAllowed helper behind an explicit r.featureChecker != nil guard at the iteration site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 48f08a8 commit be14b88

3 files changed

Lines changed: 85 additions & 45 deletions

File tree

pkg/inventory/builder.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,20 @@ func (b *Builder) WithTools(toolNames []string) *Builder {
127127

128128
// WithFeatureChecker sets the feature flag checker function.
129129
// The checker receives a context (for actor extraction) and feature flag name,
130-
// returns (enabled, error). If error occurs, it will be logged and treated as false.
131-
// If checker is nil, feature flag filtering is skipped entirely — the inventory
132-
// then returns the full upper bound, including dual-name variants. The
133-
// per-request inventory must always install a checker so MCP registration
134-
// (which only serves one tool per name) sees a deduplicated set.
130+
// and returns (enabled, error). Errors are logged and treated as "not enabled".
131+
//
132+
// When the checker is non-nil, Build() installs a feature-flag ToolFilter
133+
// at the head of the filter pipeline so that tools annotated with
134+
// FeatureFlagEnable / FeatureFlagDisable are gated accordingly. Resources
135+
// and prompts use the same checker via an explicit guard at their iteration
136+
// site.
137+
//
138+
// When the checker is nil, no feature-flag filter is installed; tools,
139+
// resources, and prompts pass through feature-flag gating unchanged. The
140+
// per-request inventory in HTTP mode must always install a checker so that
141+
// MCP registration (which can only serve a given tool name once) sees a
142+
// deduplicated set of dual-name variants.
143+
//
135144
// Returns self for chaining.
136145
func (b *Builder) WithFeatureChecker(checker FeatureFlagChecker) *Builder {
137146
b.featureChecker = checker
@@ -203,14 +212,24 @@ func cleanTools(tools []string) []string {
203212
func (b *Builder) Build() (*Inventory, error) {
204213
tools := b.tools
205214

215+
// Install the feature-flag filter at the head of the pipeline so that
216+
// flag-gated tools are excluded before any user-supplied WithFilter sees
217+
// them. Doing this in Build() (rather than inside WithFeatureChecker)
218+
// keeps the install idempotent — repeated WithFeatureChecker calls
219+
// replace the checker without stacking duplicate filters.
220+
filters := b.filters
221+
if b.featureChecker != nil {
222+
filters = append([]ToolFilter{createFeatureFlagFilter(b.featureChecker)}, filters...)
223+
}
224+
206225
r := &Inventory{
207226
tools: tools,
208227
resourceTemplates: b.resourceTemplates,
209228
prompts: b.prompts,
210229
deprecatedAliases: b.deprecatedAliases,
211230
readOnly: b.readOnly,
212231
featureChecker: b.featureChecker,
213-
filters: b.filters,
232+
filters: filters,
214233
}
215234

216235
// Process toolsets and pre-compute metadata in a single pass

pkg/inventory/filters.go

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -35,37 +35,57 @@ func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool
3535
return enabled
3636
}
3737

38-
// isFeatureFlagAllowed checks if an item passes feature flag filtering.
39-
// - If no feature checker is configured, feature flags are not evaluated and
40-
// the item passes through. This gives the unfiltered upper bound used by
41-
// HTTP mode at startup, before per-request feature checkers exist. The
42-
// per-request inventory always installs a checker, so any dual-name
43-
// variants are resolved before MCP registration (which can only serve a
44-
// given tool name once).
38+
// featureFlagAllowed reports whether an item with the given enable/disable
39+
// flag pair is permitted under the supplied checker. The checker must be
40+
// non-nil — callers that don't want feature filtering should not call this at
41+
// all (this is also the contract for createFeatureFlagFilter, which is only
42+
// installed when WithFeatureChecker received a non-nil checker).
43+
//
4544
// - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled.
4645
// - If FeatureFlagDisable is set, the item is excluded if the flag is enabled.
47-
func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool {
48-
if r.featureChecker == nil {
49-
return true
50-
}
51-
// Check enable flag - item requires this flag to be on
52-
if enableFlag != "" && !r.checkFeatureFlag(ctx, enableFlag) {
53-
return false
46+
func featureFlagAllowed(ctx context.Context, checker FeatureFlagChecker, enableFlag, disableFlag string) bool {
47+
if enableFlag != "" {
48+
enabled, err := checker(ctx, enableFlag)
49+
if err != nil {
50+
fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", enableFlag, err)
51+
return false
52+
}
53+
if !enabled {
54+
return false
55+
}
5456
}
55-
// Check disable flag - item is excluded if this flag is on
56-
if disableFlag != "" && r.checkFeatureFlag(ctx, disableFlag) {
57-
return false
57+
if disableFlag != "" {
58+
enabled, err := checker(ctx, disableFlag)
59+
if err != nil {
60+
fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", disableFlag, err)
61+
return false
62+
}
63+
if enabled {
64+
return false
65+
}
5866
}
5967
return true
6068
}
6169

70+
// createFeatureFlagFilter returns a ToolFilter that gates tools on their
71+
// FeatureFlagEnable / FeatureFlagDisable annotations using the given checker.
72+
// Builder.Build() installs this filter exactly once when WithFeatureChecker
73+
// has been called with a non-nil checker, so "no feature filtering" is
74+
// expressed structurally — by the absence of the filter — rather than by a
75+
// runtime nil check inside the filter itself.
76+
func createFeatureFlagFilter(checker FeatureFlagChecker) ToolFilter {
77+
return func(ctx context.Context, tool *ServerTool) (bool, error) {
78+
return featureFlagAllowed(ctx, checker, tool.FeatureFlagEnable, tool.FeatureFlagDisable), nil
79+
}
80+
}
81+
6282
// isToolEnabled checks if a specific tool is enabled based on current filters.
6383
// Filter evaluation order:
6484
// 1. Tool.Enabled (tool self-filtering)
65-
// 2. FeatureFlagEnable/FeatureFlagDisable
66-
// 3. Read-only filter
67-
// 4. Builder filters (via WithFilter)
68-
// 5. Toolset/additional tools
85+
// 2. Read-only filter
86+
// 3. Builder filters (via WithFilter; the feature-flag filter, when
87+
// installed via WithFeatureChecker, runs as part of this step)
88+
// 4. Toolset/additional tools
6989
func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool {
7090
// 1. Check tool's own Enabled function first
7191
if tool.Enabled != nil {
@@ -78,15 +98,11 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool {
7898
return false
7999
}
80100
}
81-
// 2. Check feature flags
82-
if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) {
83-
return false
84-
}
85-
// 3. Check read-only filter (applies to all tools)
101+
// 2. Check read-only filter (applies to all tools)
86102
if r.readOnly && !tool.IsReadOnly() {
87103
return false
88104
}
89-
// 4. Apply builder filters
105+
// 3. Apply builder filters (includes the feature-flag filter when set)
90106
for _, filter := range r.filters {
91107
allowed, err := filter(ctx, tool)
92108
if err != nil {
@@ -97,11 +113,11 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool {
97113
return false
98114
}
99115
}
100-
// 5. Check if tool is in additionalTools (bypasses toolset filter)
116+
// 4. Check if tool is in additionalTools (bypasses toolset filter)
101117
if r.additionalTools != nil && r.additionalTools[tool.Tool.Name] {
102118
return true
103119
}
104-
// 5. Check toolset filter
120+
// 4. Check toolset filter
105121
if !r.isToolsetEnabled(tool.Toolset.ID) {
106122
return false
107123
}
@@ -160,8 +176,11 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso
160176
var result []ServerResourceTemplate
161177
for i := range r.resourceTemplates {
162178
res := &r.resourceTemplates[i]
163-
// Check feature flags
164-
if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) {
179+
// Resources have no filter pipeline, so feature gating runs inline.
180+
// The featureChecker != nil guard mirrors the structural "no checker
181+
// = no filtering" contract used for tools (where the absence of a
182+
// pipeline step expresses the same thing).
183+
if r.featureChecker != nil && !featureFlagAllowed(ctx, r.featureChecker, res.FeatureFlagEnable, res.FeatureFlagDisable) {
165184
continue
166185
}
167186
if r.isToolsetEnabled(res.Toolset.ID) {
@@ -189,8 +208,9 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt {
189208
var result []ServerPrompt
190209
for i := range r.prompts {
191210
prompt := &r.prompts[i]
192-
// Check feature flags
193-
if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) {
211+
// Prompts have no filter pipeline; see AvailableResourceTemplates for
212+
// the rationale behind the explicit nil guard.
213+
if r.featureChecker != nil && !featureFlagAllowed(ctx, r.featureChecker, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) {
194214
continue
195215
}
196216
if r.isToolsetEnabled(prompt.Toolset.ID) {

pkg/inventory/registry_test.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1652,10 +1652,10 @@ func TestFilteredToolsMatchesAvailableTools(t *testing.T) {
16521652
func TestFilteringOrder(t *testing.T) {
16531653
// Test that filters are applied in the correct order:
16541654
// 1. Tool.Enabled
1655-
// 2. Feature flags
1656-
// 3. Read-only
1657-
// 4. Builder filters
1658-
// 5. Toolset/additional tools
1655+
// 2. Read-only
1656+
// 3. Builder filters (feature-flag filter is at the head of this list
1657+
// when WithFeatureChecker is set)
1658+
// 4. Toolset/additional tools
16591659

16601660
callOrder := []string{}
16611661

@@ -1688,8 +1688,9 @@ func TestFilteringOrder(t *testing.T) {
16881688

16891689
_ = reg.AvailableTools(context.Background())
16901690

1691-
// Expected order: Enabled, FeatureFlag, ReadOnly (stops here because it's write tool)
1692-
expectedOrder := []string{"Enabled", "FeatureFlag"}
1691+
// Expected order: Enabled, then Read-only stops (write tool, read-only mode);
1692+
// neither the feature-flag filter nor the user filter is reached.
1693+
expectedOrder := []string{"Enabled"}
16931694
if len(callOrder) != len(expectedOrder) {
16941695
t.Errorf("Expected %d checks, got %d: %v", len(expectedOrder), len(callOrder), callOrder)
16951696
}

0 commit comments

Comments
 (0)