-
Notifications
You must be signed in to change notification settings - Fork 3.2k
refactor: immutable ToolsetGroup with self-describing tools and feature flags #1602
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: SamMorrowDrums/server-tool-refactor-projects
Are you sure you want to change the base?
refactor: immutable ToolsetGroup with self-describing tools and feature flags #1602
Conversation
Add CLI flag and config support for feature flags in the local server: - Add --features flag to main.go (StringSlice, comma-separated) - Add EnabledFeatures field to StdioServerConfig and MCPServerConfig - Create createFeatureChecker() that builds a set from enabled features - Wire WithFeatureChecker() into the toolset group filter chain This enables tools/resources/prompts that have FeatureFlagEnable set to a flag name that is passed via --features. The checker uses a simple set membership test for O(1) lookup. Usage: github-mcp-server stdio --features=my_feature,another_feature GITHUB_FEATURES=my_feature github-mcp-server stdio
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds feature flag support to the local GitHub MCP server, enabling runtime control over which tools, resources, and prompts are available based on enabled features. The implementation includes a new --features CLI flag, feature flag fields on ServerTool/ServerResource/ServerPrompt, and a filtering mechanism integrated into the toolset group's filter chain.
Key Changes:
- Added
--featuresCLI flag andGITHUB_FEATURESenvironment variable support for comma-separated feature flags - Implemented
FeatureFlagCheckerinterface andcreateFeatureChecker()for O(1) flag lookup - Added
FeatureFlagEnableandFeatureFlagDisablefields to ServerTool, ServerResourceTemplate, and ServerPrompt - Integrated feature flag filtering into
WithFeatureChecker()method with immutable filter chaining
Reviewed changes
Copilot reviewed 35 out of 35 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
pkg/toolsets/toolsets.go |
Core feature flag filtering logic: FeatureFlagChecker type, WithFeatureChecker() method, isFeatureFlagAllowed() logic integrated into Available* methods |
pkg/toolsets/toolsets_test.go |
Comprehensive test coverage for feature flags (enable/disable/both/errors) across tools, resources, and prompts |
pkg/toolsets/server_tool.go |
Added FeatureFlagEnable and FeatureFlagDisable string fields to ServerTool, ServerResourceTemplate, and ServerPrompt |
pkg/github/toolset_group.go |
New factory function for creating ToolsetGroup with all tools/resources/prompts |
internal/ghmcp/server.go |
Added EnabledFeatures config field, createFeatureChecker() implementation, wired into filter chain via WithFeatureChecker() |
cmd/github-mcp-server/main.go |
Added --features persistent flag with viper binding and GITHUB_FEATURES env var support |
Multiple pkg/github/*.go files |
Updated all tool/resource/prompt constructors to include toolset metadata parameter (structural refactor supporting feature flags) |
This commit adds comprehensive validation tests to ensure all MCP items have required metadata: - TestAllToolsHaveRequiredMetadata: Validates Toolset.ID and Annotations - TestAllToolsHaveValidToolsetID: Ensures toolsets are in AvailableToolsets() - TestAllResourcesHaveRequiredMetadata: Validates resource metadata - TestAllPromptsHaveRequiredMetadata: Validates prompt metadata - TestToolReadOnlyHintConsistency: Validates IsReadOnly() matches annotation - TestNoDuplicate*Names: Ensures unique names across tools/resources/prompts - TestAllToolsHaveHandlerFunc: Ensures all tools have handlers - TestDefaultToolsetsAreValid: Validates default toolset IDs - TestToolsetMetadataConsistency: Ensures consistent descriptions per toolset Also fixes a bug discovered by these tests: ToolsetMetadataGit was defined but not added to AvailableToolsets(), causing get_repository_tree to have an invalid toolset ID.
When no toolsets are specified and dynamic mode is disabled, the server should use the default toolsets. The bug was introduced when adding dynamic toolsets support: 1. CleanToolsets(nil) was converting nil to empty slice 2. Empty slice passed to WithToolsets means 'no toolsets' 3. This resulted in zero tools being registered Fix: Preserve nil for non-dynamic mode (nil = use defaults in WithToolsets) and only set empty slice when dynamic mode is enabled without explicit toolsets.
78c89a3 to
689a040
Compare
| } | ||
|
|
||
| // mockGetRawClient returns a mock raw client for documentation generation | ||
| func mockGetRawClient(_ context.Context) (*raw.Client, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am sure this can be removed too...
- Rename AddDeprecatedToolAliases to WithDeprecatedToolAliases for immutable filter chain consistency (returns new ToolsetGroup) - Remove unused mockGetRawClient from generate_docs.go (use nil instead) - Remove legacy ServerTool functions (NewServerToolLegacy and NewServerToolFromHandlerLegacy) - no usages - Add panic in Handler()/RegisterFunc() when HandlerFunc is nil - Add HasHandler() method for checking if tool has a handler - Add tests for HasHandler and nil handler panic behavior - Update all tests to use new WithDeprecatedToolAliases pattern
…lsetGroup This change applies the same HandlerFunc pattern used by tools to resources, allowing NewToolsetGroup to be fully stateless (only requiring translations). Key changes: - Add ResourceHandlerFunc type to toolsets package - Update ServerResourceTemplate to use HandlerFunc instead of direct Handler - Add HasHandler() and Handler(deps) methods to ServerResourceTemplate - Update RegisterResourceTemplates to take deps parameter - Refactor repository resource definitions to use HandlerFunc pattern - Make AllResources(t) stateless (only takes translations) - Make NewToolsetGroup(t) stateless (only takes translations) - Update generate_docs.go - no longer needs mock clients - Update tests to use new patterns This resolves the concern about mixed concerns in doc generation - the toolset metadata and resource templates can now be created without any runtime dependencies, while handlers are generated on-demand when deps are provided during registration.
- Replace slice joining with strings.Builder for all doc generation - Iterate AllTools() directly instead of ToolsetIDs()/ToolsForToolset() - Removes need for special 'dynamic' toolset handling (no tools = no output) - Context toolset still explicitly handled for custom description - Consistent pattern across generateToolsetsDoc, generateToolsDoc, generateRemoteToolsetsDoc, and generateDeprecatedAliasesTable
- Add AvailableToolsets() method that returns toolsets with actual tools - Support variadic exclude parameter for filtering out specific toolsets - Simplifies doc generation by removing manual skip logic - Naturally excludes empty toolsets (like 'dynamic') without special cases
9ef4da2 to
a536c15
Compare
- Add Default field to ToolsetMetadata and derive defaults from metadata - Move toolset validation into WithToolsets (trims whitespace, dedupes, tracks unrecognized) - Add UnrecognizedToolsets() method for warning about typos - Add DefaultToolsetIDs() method to derive defaults from metadata - Remove redundant functions: CleanToolsets, GetValidToolsetIDs, AvailableToolsets, GetDefaultToolsetIDs - Update DynamicTools to take ToolsetGroup for schema enum generation - Add stubTranslator for cases needing ToolsetGroup without translations This eliminates hardcoded toolset lists - everything is now derived from the actual registered tools and their metadata.
a536c15 to
6b6a874
Compare
| // copy creates a shallow copy of the Registry for immutable operations. | ||
| func (r *Registry) copy() *Registry { | ||
| newTG := &Registry{ | ||
| tools: r.tools, // slices are shared (immutable) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you mean by this comment? I think it's still possible to get a reference to particular ServerTool (for example by calling AvailableTools()), change fields and then tools slice will be changed as well. In order to make it truly immutable we need to make a deep copy
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The immutable copy is for the registry metadata only, the protection is that a function cannot edit the copy of the registry that you hold, and change the filters you think have applied.
We could call it out in comments, but we don't want the expense of deep copy, and it's nice that it always returns a Registry. We could do a builder version, that's an option too.
WDYT?
The only issue with the builder version is if you want Registry in an intermediate state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i.e. I'm not worried about people mutating tools too much and don't want to copy 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right though, the immutable is in wrong place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thx, that makes sense :)
| // When true, write tools are filtered out from Available* methods. | ||
| func (r *Registry) WithReadOnly(readOnly bool) *Registry { | ||
| newTG := r.copy() | ||
| newTG.readOnly = readOnly |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at the example from PR description the intended use of this function as as a builder pattern:
filtered := tsg.
WithReadOnly(true).
WithToolsets([]string{"repos", "issues"}).
WithTools([]string{"get_me"}). // additive, bypasses toolset filter
WithFeatureChecker(checker)
In current implementation every builder function is unnecessarily copying the registry. Each Registry.copy() is iterating over tool slices to copy them too.
Maybe we could introduce a struct like RegistryBuilder, then functions as WithReadOnly will just set one field in the builder. Once the user is done with constructing a new object she would call build() on the builder instance, which returns the copied registry object with some fields overridden.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will do the builder pattern. Agree we don't need intermediate state and we couldn't efficiently achieve full immutability anyway! ❤️ thank you.
Summary
Major refactor of the toolsets package to create an elegant, immutable filtering system with self-describing tools.
Key Changes
Self-Describing Tools:
ToolsetMetadata(ID and description)Tool.Annotations.ReadOnlyHintAddReadTools()/AddWriteTools()Simplified ToolsetGroup:
NewToolsetGroup(tools, resources, prompts)- takes lists directlyToolsetstruct withSetDependencies()RegisterAll(ctx, server, deps)Immutable Filter Chain:
Feature Flags:
FeatureFlagEnable- tool only available when flag is onFeatureFlagDisable- tool excluded when flag is on--featuresCLI flag for local serverDeterministic Ordering:
Available*()methods return items sorted by toolset ID, then nameNew Files:
pkg/github/toolset_group.go-NewToolsetGroup()factorypkg/github/prompts.go-AllPrompts()pkg/github/resources.go-AllResources()docs/deprecated-tool-aliases.md- alias documentationUsage
# Enable specific features github-mcp-server stdio --features=my_feature,another_feature GITHUB_FEATURES=my_feature github-mcp-server stdioStack: