Skip to content

Commit 62c62b6

Browse files
committed
inventory: expose stable hidden tool reason codes (#2197)
1 parent 1da41fa commit 62c62b6

File tree

2 files changed

+118
-18
lines changed

2 files changed

+118
-18
lines changed

pkg/inventory/filters.go

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@ import (
1313
// Returns (enabled, error). If error occurs, the caller should log and treat as false.
1414
type FeatureFlagChecker func(ctx context.Context, flagName string) (bool, error)
1515

16+
// HiddenToolReason is a stable machine-readable reason for tool filtering decisions.
17+
type HiddenToolReason string
18+
19+
const (
20+
HiddenToolReasonEnabledFalse HiddenToolReason = "enabled_false"
21+
HiddenToolReasonFeatureFlag HiddenToolReason = "feature_flag_blocked"
22+
HiddenToolReasonReadOnlyMode HiddenToolReason = "read_only_mode"
23+
HiddenToolReasonBuilderFilterFalse HiddenToolReason = "builder_filter_false"
24+
HiddenToolReasonBuilderFilterError HiddenToolReason = "builder_filter_error"
25+
HiddenToolReasonToolsetDisabled HiddenToolReason = "toolset_disabled"
26+
)
27+
28+
// HiddenTool describes a hidden tool name and its first matching hidden reason.
29+
type HiddenTool struct {
30+
Name string
31+
Reason HiddenToolReason
32+
}
33+
1634
// isToolsetEnabled checks if a toolset is enabled based on current filters.
1735
func (r *Inventory) isToolsetEnabled(toolsetID ToolsetID) bool {
1836
// Check enabled toolsets filter
@@ -51,53 +69,59 @@ func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disabl
5169
return true
5270
}
5371

54-
// isToolEnabled checks if a specific tool is enabled based on current filters.
55-
// Filter evaluation order:
56-
// 1. Tool.Enabled (tool self-filtering)
57-
// 2. FeatureFlagEnable/FeatureFlagDisable
58-
// 3. Read-only filter
59-
// 4. Builder filters (via WithFilter)
60-
// 5. Toolset/additional tools
61-
func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool {
72+
// toolEnabledReason evaluates the tool filter chain in order and returns hidden reason if excluded.
73+
func (r *Inventory) toolEnabledReason(ctx context.Context, tool *ServerTool) (bool, HiddenToolReason) {
6274
// 1. Check tool's own Enabled function first
6375
if tool.Enabled != nil {
6476
enabled, err := tool.Enabled(ctx)
6577
if err != nil {
6678
fmt.Fprintf(os.Stderr, "Tool.Enabled check error for %q: %v\n", tool.Tool.Name, err)
67-
return false
79+
return false, HiddenToolReasonEnabledFalse
6880
}
6981
if !enabled {
70-
return false
82+
return false, HiddenToolReasonEnabledFalse
7183
}
7284
}
7385
// 2. Check feature flags
7486
if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) {
75-
return false
87+
return false, HiddenToolReasonFeatureFlag
7688
}
7789
// 3. Check read-only filter (applies to all tools)
7890
if r.readOnly && !tool.IsReadOnly() {
79-
return false
91+
return false, HiddenToolReasonReadOnlyMode
8092
}
8193
// 4. Apply builder filters
8294
for _, filter := range r.filters {
8395
allowed, err := filter(ctx, tool)
8496
if err != nil {
8597
fmt.Fprintf(os.Stderr, "Builder filter error for tool %q: %v\n", tool.Tool.Name, err)
86-
return false
98+
return false, HiddenToolReasonBuilderFilterError
8799
}
88100
if !allowed {
89-
return false
101+
return false, HiddenToolReasonBuilderFilterFalse
90102
}
91103
}
92104
// 5. Check if tool is in additionalTools (bypasses toolset filter)
93105
if r.additionalTools != nil && r.additionalTools[tool.Tool.Name] {
94-
return true
106+
return true, ""
95107
}
96-
// 5. Check toolset filter
108+
// 6. Check toolset filter
97109
if !r.isToolsetEnabled(tool.Toolset.ID) {
98-
return false
110+
return false, HiddenToolReasonToolsetDisabled
99111
}
100-
return true
112+
return true, ""
113+
}
114+
115+
// isToolEnabled checks if a specific tool is enabled based on current filters.
116+
// Filter evaluation order:
117+
// 1. Tool.Enabled (tool self-filtering)
118+
// 2. FeatureFlagEnable/FeatureFlagDisable
119+
// 3. Read-only filter
120+
// 4. Builder filters (via WithFilter)
121+
// 5. Toolset/additional tools
122+
func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool {
123+
enabled, _ := r.toolEnabledReason(ctx, tool)
124+
return enabled
101125
}
102126

103127
// AvailableTools returns the tools that pass all current filters,
@@ -284,3 +308,32 @@ func (r *Inventory) EnabledToolsetIDs() []ToolsetID {
284308
func (r *Inventory) FilteredTools(ctx context.Context) ([]ServerTool, error) {
285309
return r.AvailableTools(ctx), nil
286310
}
311+
312+
// HiddenTools returns hidden tools and stable reason codes for why they were filtered out.
313+
// The first matching reason in the filter evaluation order is returned per tool name.
314+
func (r *Inventory) HiddenTools(ctx context.Context) []HiddenTool {
315+
seen := make(map[string]bool)
316+
result := make([]HiddenTool, 0)
317+
for i := range r.tools {
318+
tool := &r.tools[i]
319+
enabled, reason := r.toolEnabledReason(ctx, tool)
320+
if enabled {
321+
continue
322+
}
323+
if seen[tool.Tool.Name] {
324+
continue
325+
}
326+
seen[tool.Tool.Name] = true
327+
result = append(result, HiddenTool{
328+
Name: tool.Tool.Name,
329+
Reason: reason,
330+
})
331+
}
332+
sort.Slice(result, func(i, j int) bool {
333+
if result[i].Name != result[j].Name {
334+
return result[i].Name < result[j].Name
335+
}
336+
return result[i].Reason < result[j].Reason
337+
})
338+
return result
339+
}

pkg/inventory/registry_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2277,3 +2277,50 @@ func TestCreateExcludeToolsFilter(t *testing.T) {
22772277
require.NoError(t, err)
22782278
require.True(t, allowed, "allowed_tool should be included")
22792279
}
2280+
2281+
func TestHiddenTools_ReportsStableReasonCodes(t *testing.T) {
2282+
tools := []ServerTool{
2283+
mockTool("read_tool", "toolset1", true),
2284+
mockTool("write_tool", "toolset1", false),
2285+
mockTool("toolset_hidden", "toolset2", true),
2286+
mockToolWithFlags("flag_hidden", "toolset1", true, "feature_x", ""),
2287+
mockTool("builder_hidden", "toolset1", true),
2288+
}
2289+
filter := func(_ context.Context, tool *ServerTool) (bool, error) {
2290+
return tool.Tool.Name != "builder_hidden", nil
2291+
}
2292+
inv := mustBuild(t, NewBuilder().
2293+
SetTools(tools).
2294+
WithToolsets([]string{"toolset1"}).
2295+
WithReadOnly(true).
2296+
WithFeatureChecker(func(_ context.Context, _ string) (bool, error) { return false, nil }).
2297+
WithFilter(filter))
2298+
2299+
hidden := inv.HiddenTools(context.Background())
2300+
reasonsByName := make(map[string]HiddenToolReason, len(hidden))
2301+
for _, item := range hidden {
2302+
reasonsByName[item.Name] = item.Reason
2303+
}
2304+
2305+
require.Equal(t, HiddenToolReasonReadOnlyMode, reasonsByName["write_tool"])
2306+
require.Equal(t, HiddenToolReasonToolsetDisabled, reasonsByName["toolset_hidden"])
2307+
require.Equal(t, HiddenToolReasonFeatureFlag, reasonsByName["flag_hidden"])
2308+
require.Equal(t, HiddenToolReasonBuilderFilterFalse, reasonsByName["builder_hidden"])
2309+
_, hasReadTool := reasonsByName["read_tool"]
2310+
require.False(t, hasReadTool, "read_tool should not be hidden")
2311+
}
2312+
2313+
func TestHiddenTools_ReportsBuilderFilterErrorReason(t *testing.T) {
2314+
tool := mockTool("error_tool", "toolset1", true)
2315+
inv := mustBuild(t, NewBuilder().
2316+
SetTools([]ServerTool{tool}).
2317+
WithToolsets([]string{"all"}).
2318+
WithFilter(func(_ context.Context, _ *ServerTool) (bool, error) {
2319+
return false, fmt.Errorf("forced filter failure")
2320+
}))
2321+
2322+
hidden := inv.HiddenTools(context.Background())
2323+
require.Len(t, hidden, 1)
2324+
require.Equal(t, "error_tool", hidden[0].Name)
2325+
require.Equal(t, HiddenToolReasonBuilderFilterError, hidden[0].Reason)
2326+
}

0 commit comments

Comments
 (0)