Skip to content

Commit ac8e6bb

Browse files
committed
move resolveEnabledToolsets to builder
1 parent 15e66b3 commit ac8e6bb

File tree

4 files changed

+218
-100
lines changed

4 files changed

+218
-100
lines changed

internal/ghmcp/server.go

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -131,33 +131,6 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients,
131131
}, nil
132132
}
133133

134-
// resolveEnabledToolsets determines which toolsets should be enabled based on config.
135-
// Returns nil for "use defaults", empty slice for "none", or explicit list.
136-
func resolveEnabledToolsets(cfg MCPServerConfig) []string {
137-
enabledToolsets := cfg.EnabledToolsets
138-
139-
// In dynamic mode, remove "all" and "default" since users enable toolsets on demand
140-
if cfg.DynamicToolsets && enabledToolsets != nil {
141-
enabledToolsets = github.RemoveToolset(enabledToolsets, string(github.ToolsetMetadataAll.ID))
142-
enabledToolsets = github.RemoveToolset(enabledToolsets, string(github.ToolsetMetadataDefault.ID))
143-
}
144-
145-
if enabledToolsets != nil {
146-
return enabledToolsets
147-
}
148-
if cfg.DynamicToolsets {
149-
// Dynamic mode with no toolsets specified: start empty so users enable on demand
150-
return []string{}
151-
}
152-
if len(cfg.EnabledTools) > 0 {
153-
// When specific tools are requested but no toolsets, don't use default toolsets
154-
// This matches the original behavior: --tools=X alone registers only X
155-
return []string{}
156-
}
157-
// nil means "use defaults" in WithToolsets
158-
return nil
159-
}
160-
161134
func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
162135
apiHost, err := parseAPIHost(cfg.Host)
163136
if err != nil {
@@ -169,17 +142,16 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
169142
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
170143
}
171144

172-
enabledToolsets := resolveEnabledToolsets(cfg)
173-
174145
// Create feature checker
175146
featureChecker := createFeatureChecker(cfg.EnabledFeatures)
176147

177148
// Build and register the tool/resource/prompt inventory
178149
inventoryBuilder := github.NewInventory(cfg.Translator).
179150
WithDeprecatedAliases(github.DeprecatedToolAliases).
180151
WithReadOnly(cfg.ReadOnly).
181-
WithToolsets(enabledToolsets).
152+
WithToolsets(cfg.EnabledToolsets).
182153
WithTools(cfg.EnabledTools).
154+
WithDynamicMode(cfg.DynamicToolsets).
183155
WithFeatureChecker(featureChecker).
184156
WithServerInstructions()
185157

internal/ghmcp/server_test.go

Lines changed: 9 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"testing"
55

66
"github.com/github/github-mcp-server/pkg/translations"
7-
"github.com/stretchr/testify/assert"
87
"github.com/stretchr/testify/require"
98
)
109

@@ -42,72 +41,12 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) {
4241
// is already tested in pkg/github/*_test.go.
4342
}
4443

45-
// TestResolveEnabledToolsets verifies the toolset resolution logic.
46-
func TestResolveEnabledToolsets(t *testing.T) {
47-
t.Parallel()
48-
49-
tests := []struct {
50-
name string
51-
cfg MCPServerConfig
52-
expectedResult []string
53-
}{
54-
{
55-
name: "nil toolsets without dynamic mode and no tools - use defaults",
56-
cfg: MCPServerConfig{
57-
EnabledToolsets: nil,
58-
DynamicToolsets: false,
59-
EnabledTools: nil,
60-
},
61-
expectedResult: nil, // nil means "use defaults"
62-
},
63-
{
64-
name: "nil toolsets with dynamic mode - start empty",
65-
cfg: MCPServerConfig{
66-
EnabledToolsets: nil,
67-
DynamicToolsets: true,
68-
EnabledTools: nil,
69-
},
70-
expectedResult: []string{}, // empty slice means no toolsets
71-
},
72-
{
73-
name: "explicit toolsets",
74-
cfg: MCPServerConfig{
75-
EnabledToolsets: []string{"repos", "issues"},
76-
DynamicToolsets: false,
77-
},
78-
expectedResult: []string{"repos", "issues"},
79-
},
80-
{
81-
name: "empty toolsets - disable all",
82-
cfg: MCPServerConfig{
83-
EnabledToolsets: []string{},
84-
DynamicToolsets: false,
85-
},
86-
expectedResult: []string{}, // empty slice means no toolsets
87-
},
88-
{
89-
name: "specific tools without toolsets - no default toolsets",
90-
cfg: MCPServerConfig{
91-
EnabledToolsets: nil,
92-
DynamicToolsets: false,
93-
EnabledTools: []string{"get_me"},
94-
},
95-
expectedResult: []string{}, // empty slice when tools specified but no toolsets
96-
},
97-
{
98-
name: "dynamic mode with explicit toolsets removes all and default",
99-
cfg: MCPServerConfig{
100-
EnabledToolsets: []string{"all", "repos"},
101-
DynamicToolsets: true,
102-
},
103-
expectedResult: []string{"repos"}, // "all" is removed in dynamic mode
104-
},
105-
}
106-
107-
for _, tc := range tests {
108-
t.Run(tc.name, func(t *testing.T) {
109-
result := resolveEnabledToolsets(tc.cfg)
110-
assert.Equal(t, tc.expectedResult, result)
111-
})
112-
}
113-
}
44+
// Note: TestResolveEnabledToolsets was removed because the resolveEnabledToolsets
45+
// function was moved to the Inventory Builder (pkg/inventory/builder.go).
46+
// The same functionality is now tested in pkg/inventory/registry_test.go via:
47+
// - TestWithDynamicMode_NilToolsets
48+
// - TestWithDynamicMode_RemovesAllKeyword
49+
// - TestWithDynamicMode_RemovesDefaultKeyword
50+
// - TestWithDynamicMode_ExplicitToolsetsPreserved
51+
// - TestWithDynamicMode_WithAdditionalTools
52+
// - TestWithTools_NilToolsets_UsesEmptyToolsets

pkg/inventory/builder.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ type Builder struct {
4141
featureChecker FeatureFlagChecker
4242
filters []ToolFilter // filters to apply to all tools
4343
generateInstructions bool
44+
dynamicMode bool // when true, "all" and "default" are stripped, empty toolsets is valid
4445
}
4546

4647
// NewBuilder creates a new Builder.
@@ -116,6 +117,17 @@ func (b *Builder) WithTools(toolNames []string) *Builder {
116117
return b
117118
}
118119

120+
// WithDynamicMode enables dynamic toolset mode.
121+
// In this mode:
122+
// - "all" and "default" keywords are removed from toolsetIDs
123+
// - If no toolsets specified, starts with empty set (users enable on demand)
124+
//
125+
// Returns self for chaining.
126+
func (b *Builder) WithDynamicMode(enabled bool) *Builder {
127+
b.dynamicMode = enabled
128+
return b
129+
}
130+
119131
// WithFeatureChecker sets the feature flag checker function.
120132
// The checker receives a context (for actor extraction) and feature flag name,
121133
// returns (enabled, error). If error occurs, it will be logged and treated as false.
@@ -135,6 +147,48 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder {
135147
return b
136148
}
137149

150+
// removeFromSlice removes all occurrences of value from slice.
151+
func removeFromSlice(slice []string, value string) []string {
152+
result := make([]string, 0, len(slice))
153+
for _, s := range slice {
154+
if s != value {
155+
result = append(result, s)
156+
}
157+
}
158+
return result
159+
}
160+
161+
// applyDynamicModePreprocessing modifies toolsetIDs based on dynamicMode and additionalTools.
162+
func (b *Builder) applyDynamicModePreprocessing() {
163+
if b.dynamicMode && b.toolsetIDs != nil {
164+
// In dynamic mode, remove "all" and "default" since users enable toolsets on demand
165+
b.toolsetIDs = removeFromSlice(b.toolsetIDs, "all")
166+
b.toolsetIDs = removeFromSlice(b.toolsetIDs, "default")
167+
}
168+
169+
if b.toolsetIDs != nil {
170+
// Explicit toolsets were provided (possibly modified by dynamic mode)
171+
return
172+
}
173+
174+
if b.dynamicMode {
175+
// Dynamic mode with no toolsets specified: start empty so users enable on demand
176+
b.toolsetIDs = []string{}
177+
b.toolsetIDsIsNil = false
178+
return
179+
}
180+
181+
if len(b.additionalTools) > 0 {
182+
// When specific tools are requested but no toolsets, don't use default toolsets
183+
// --tools=X alone registers only X
184+
b.toolsetIDs = []string{}
185+
b.toolsetIDsIsNil = false
186+
return
187+
}
188+
189+
// Leave as nil (toolsetIDsIsNil = true) to use defaults in processToolsets()
190+
}
191+
138192
// cleanTools trims whitespace and removes duplicates from tool names.
139193
// Empty strings after trimming are excluded.
140194
func cleanTools(tools []string) []string {
@@ -162,6 +216,9 @@ func cleanTools(tools []string) []string {
162216
// (i.e., they don't exist in the tool set and are not deprecated aliases).
163217
// This ensures invalid tool configurations fail fast at build time.
164218
func (b *Builder) Build() (*Inventory, error) {
219+
// Apply dynamic mode preprocessing before processing toolsets
220+
b.applyDynamicModePreprocessing()
221+
165222
r := &Inventory{
166223
tools: b.tools,
167224
resourceTemplates: b.resourceTemplates,

pkg/inventory/registry_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1832,3 +1832,153 @@ func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) {
18321832
t.Errorf("Flag ON: Expected new_tool (via alias), got %s", availableOn[0].Tool.Name)
18331833
}
18341834
}
1835+
1836+
// Tests for WithDynamicMode
1837+
func TestWithDynamicMode_NilToolsets(t *testing.T) {
1838+
tools := []ServerTool{
1839+
mockToolWithDefault("tool1", "default_toolset", true, true),
1840+
mockToolWithDefault("tool2", "other_toolset", true, false),
1841+
}
1842+
1843+
// Dynamic mode with nil toolsets should start with empty set
1844+
reg := mustBuild(t, NewBuilder().
1845+
SetTools(tools).
1846+
WithDynamicMode(true))
1847+
1848+
available := reg.AvailableTools(context.Background())
1849+
if len(available) != 0 {
1850+
t.Errorf("Dynamic mode with nil toolsets should start empty, got %d tools", len(available))
1851+
}
1852+
}
1853+
1854+
func TestWithDynamicMode_RemovesAllKeyword(t *testing.T) {
1855+
tools := []ServerTool{
1856+
mockTool("tool1", "toolset1", true),
1857+
mockTool("tool2", "toolset2", true),
1858+
}
1859+
1860+
// Dynamic mode should remove "all" from toolsets
1861+
reg := mustBuild(t, NewBuilder().
1862+
SetTools(tools).
1863+
WithToolsets([]string{"all", "toolset1"}).
1864+
WithDynamicMode(true))
1865+
1866+
available := reg.AvailableTools(context.Background())
1867+
if len(available) != 1 {
1868+
t.Errorf("Dynamic mode should have removed 'all', expected 1 tool, got %d", len(available))
1869+
}
1870+
if available[0].Tool.Name != "tool1" {
1871+
t.Errorf("Expected tool1, got %s", available[0].Tool.Name)
1872+
}
1873+
}
1874+
1875+
func TestWithDynamicMode_RemovesDefaultKeyword(t *testing.T) {
1876+
tools := []ServerTool{
1877+
mockToolWithDefault("tool1", "default_toolset", true, true),
1878+
mockTool("tool2", "explicit_toolset", true),
1879+
}
1880+
1881+
// Dynamic mode should remove "default" from toolsets
1882+
reg := mustBuild(t, NewBuilder().
1883+
SetTools(tools).
1884+
WithToolsets([]string{"default", "explicit_toolset"}).
1885+
WithDynamicMode(true))
1886+
1887+
available := reg.AvailableTools(context.Background())
1888+
if len(available) != 1 {
1889+
t.Errorf("Dynamic mode should have removed 'default', expected 1 tool, got %d", len(available))
1890+
}
1891+
if available[0].Tool.Name != "tool2" {
1892+
t.Errorf("Expected tool2, got %s", available[0].Tool.Name)
1893+
}
1894+
}
1895+
1896+
func TestWithDynamicMode_ExplicitToolsetsPreserved(t *testing.T) {
1897+
tools := []ServerTool{
1898+
mockTool("tool1", "toolset1", true),
1899+
mockTool("tool2", "toolset2", true),
1900+
mockTool("tool3", "toolset3", true),
1901+
}
1902+
1903+
// Dynamic mode should preserve explicit toolsets (not "all" or "default")
1904+
reg := mustBuild(t, NewBuilder().
1905+
SetTools(tools).
1906+
WithToolsets([]string{"toolset1", "toolset3"}).
1907+
WithDynamicMode(true))
1908+
1909+
available := reg.AvailableTools(context.Background())
1910+
if len(available) != 2 {
1911+
t.Fatalf("Expected 2 tools, got %d", len(available))
1912+
}
1913+
1914+
toolNames := make(map[string]bool)
1915+
for _, tool := range available {
1916+
toolNames[tool.Tool.Name] = true
1917+
}
1918+
if !toolNames["tool1"] || !toolNames["tool3"] {
1919+
t.Errorf("Expected tool1 and tool3, got %v", toolNames)
1920+
}
1921+
}
1922+
1923+
func TestWithDynamicMode_False(t *testing.T) {
1924+
tools := []ServerTool{
1925+
mockToolWithDefault("tool1", "default_toolset", true, true),
1926+
mockTool("tool2", "other_toolset", true),
1927+
}
1928+
1929+
// Dynamic mode false should use default behavior (nil -> use defaults)
1930+
reg := mustBuild(t, NewBuilder().
1931+
SetTools(tools).
1932+
WithDynamicMode(false))
1933+
1934+
available := reg.AvailableTools(context.Background())
1935+
// Should use defaults, which includes default_toolset
1936+
if len(available) != 1 {
1937+
t.Errorf("Expected 1 tool from default toolset, got %d", len(available))
1938+
}
1939+
if available[0].Tool.Name != "tool1" {
1940+
t.Errorf("Expected tool1 from default toolset, got %s", available[0].Tool.Name)
1941+
}
1942+
}
1943+
1944+
func TestWithDynamicMode_WithAdditionalTools(t *testing.T) {
1945+
tools := []ServerTool{
1946+
mockTool("tool1", "toolset1", true),
1947+
mockTool("tool2", "toolset2", true),
1948+
}
1949+
1950+
// Dynamic mode with additional tools should allow those tools
1951+
reg := mustBuild(t, NewBuilder().
1952+
SetTools(tools).
1953+
WithDynamicMode(true).
1954+
WithTools([]string{"tool2"}))
1955+
1956+
available := reg.AvailableTools(context.Background())
1957+
if len(available) != 1 {
1958+
t.Fatalf("Expected 1 additional tool, got %d", len(available))
1959+
}
1960+
if available[0].Tool.Name != "tool2" {
1961+
t.Errorf("Expected tool2, got %s", available[0].Tool.Name)
1962+
}
1963+
}
1964+
1965+
func TestWithTools_NilToolsets_UsesEmptyToolsets(t *testing.T) {
1966+
tools := []ServerTool{
1967+
mockToolWithDefault("tool1", "default_toolset", true, true),
1968+
mockTool("tool2", "other_toolset", true),
1969+
}
1970+
1971+
// When specific tools are requested but no toolsets, should not use defaults
1972+
reg := mustBuild(t, NewBuilder().
1973+
SetTools(tools).
1974+
WithTools([]string{"tool2"}))
1975+
1976+
available := reg.AvailableTools(context.Background())
1977+
// Only the explicitly requested tool, not the default toolset
1978+
if len(available) != 1 {
1979+
t.Errorf("Expected 1 tool (only explicitly requested), got %d", len(available))
1980+
}
1981+
if available[0].Tool.Name != "tool2" {
1982+
t.Errorf("Expected tool2, got %s", available[0].Tool.Name)
1983+
}
1984+
}

0 commit comments

Comments
 (0)