How to Add Custom Tool Filters for Runtime Tool Registration in the GitHub MCP Server
You can add custom tool filters by implementing the ToolFilter function signature and attaching it to the inventory.Builder using WithFilter before calling Build(), ensuring that both static and runtime tool registrations respect your filtering logic.
The GitHub MCP Server manages tool discovery and registration through a centralized inventory system defined in pkg/inventory/builder.go. Adding custom tool filters for runtime tool registration allows you to dynamically control which tools are exposed based on environment variables, feature flags, or tenant-specific policies.
Understanding the Tool Filtering Architecture
The inventory system uses a builder pattern to construct an Inventory instance that contains the final set of available tools. Filtering occurs during the build phase, not during registration.
A ToolFilter is defined as:
type ToolFilter func(context.Context, *ServerTool) (bool, error)
In pkg/inventory/builder.go, the Builder struct collects filters in a slice. When you call Build(), the method iterates through every discovered tool and applies all registered filters. Only tools that receive true from every filter are added to the AvailableTools slice.
This design means that RegisterAll (static registration) and EnableToolset (dynamic registration) both automatically respect the filters because they operate on the pre-filtered AvailableTools collection.
Creating a Custom Tool Filter
You can implement custom filtering logic by writing a function that matches the ToolFilter signature. Below are three common patterns used in production deployments.
Name-Prefix Filter
Use this pattern to expose only tools related to specific GitHub domains (e.g., only repository tools).
package main
import (
"context"
"strings"
"github.com/github/github-mcp-server/pkg/inventory"
)
// keepOnlyPrefixed returns a filter that allows only tools with names
// starting with the specified prefix.
func keepOnlyPrefixed(prefix string) inventory.ToolFilter {
return func(_ context.Context, st *inventory.ServerTool) (bool, error) {
return strings.HasPrefix(st.Tool.Name, prefix), nil
}
}
Feature-Flag Filter
This pattern checks tool annotations to determine if a tool should be enabled based on runtime configuration.
package main
import (
"context"
"github.com/github/github-mcp-server/pkg/inventory"
)
// makeFeatureFlagFilter creates a filter that keeps tools only if their
// FeatureFlagEnable annotation is empty or present in the enabled map.
func makeFeatureFlagFilter(enabled map[string]bool) inventory.ToolFilter {
return func(_ context.Context, st *inventory.ServerTool) (bool, error) {
ann := st.Tool.Annotations
if ann == nil || ann.FeatureFlagEnable == "" {
// No flag required, always allow
return true, nil
}
return enabled[ann.FeatureFlagEnable], nil
}
}
Tenant-Based Filter
For multi-tenant deployments, you might restrict tools based on the specific tenant configuration.
func buildInventoryForTenant(tenantID string, translator *github.Translator) (*inventory.Inventory, error) {
// Define allowed tools per tenant
allowedTools := map[string]map[string]bool{
"tenantA": {"repo_list": true, "issue_create": true},
"tenantB": {"repo_get": true},
}[tenantID]
tenantFilter := func(_ context.Context, st *inventory.ServerTool) (bool, error) {
_, ok := allowedTools[st.Tool.Name]
return ok, nil
}
builder := github.NewInventory(translator).
WithFilter(tenantFilter).
WithReadOnly(false)
return builder.Build()
}
Registering Filters with the Inventory Builder
Attach your custom filters using the WithFilter method before calling Build(). In internal/ghmcp/server.go, the server construction demonstrates this pattern with the built-in scope filter:
inventoryBuilder := github.NewInventory(translator).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(...)).
WithTools(github.CleanTools(...)).
WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) // Custom filter added here
inv, err := inventoryBuilder.Build()
if err != nil {
return nil, err
}
// Register only the filtered tools
inv.RegisterAll(ctx, mcpServer, deps)
The WithFilter method in pkg/inventory/builder.go appends your function to the internal filters slice:
func (b *Builder) WithFilter(filter ToolFilter) *Builder {
b.filters = append(b.filters, filter)
return b
}
How Runtime Registration Respects Filters
Dynamic tool registration occurs through the enable_toolset tool implemented in pkg/github/dynamic_tools.go. When a client calls this tool, the server invokes deps.Inventory.ToolsForToolset(toolsetName), which returns tools from the pre-filtered AvailableTools collection.
Because filtering happens during Build() in pkg/inventory/builder.go (lines 71-96), the ServerTool instances stored in the inventory have already passed all custom filters. Consequently, when EnableToolset iterates through the toolset and calls st.RegisterFunc(s, deps) for each tool, it only registers tools that satisfy your custom logic.
This architecture ensures consistency between static registration (RegisterAll in pkg/inventory/registry.go) and dynamic registration, as both consume the same filtered AvailableTools slice.
Advanced: Post-Build Filtering
If you cannot rebuild the inventory but need to apply additional constraints, you can manually filter during registration by iterating over AvailableTools and applying a secondary predicate:
func registerWithExtraFilter(inv *inventory.Inventory, srv *mcp.Server, deps any,
extra func(*inventory.ServerTool) bool) {
for _, st := range inv.AvailableTools(context.Background()) {
if extra(st) {
st.RegisterFunc(srv, deps)
}
}
}
Use this approach only when the builder is inaccessible. The recommended pattern is always to add filters via WithFilter before Build() to ensure that ToolsForToolset and other inventory methods return consistent results.
Summary
- ToolFilter signature: Implement
func(context.Context, *inventory.ServerTool) (bool, error)to define custom logic that determines tool availability. - Attach early: Use
Builder.WithFilter()inpkg/inventory/builder.gobefore callingBuild()to ensure filters apply to both static and dynamic registration paths. - Build-time execution: Filters run once during
Inventory.Build(); only tools passing all filters are stored inAvailableTools. - Runtime consistency: Dynamic registration via
enable_toolsetinpkg/github/dynamic_tools.gousesToolsForToolset(), which returns pre-filtered tools, ensuring custom constraints are respected at runtime. - Reference implementation: Study
pkg/github/scope_filter.gofor a production example of filtering based on GitHub token scopes.
Frequently Asked Questions
When are custom tool filters executed during the server lifecycle?
Custom tool filters execute once during the Inventory.Build() phase, which occurs before the MCP server begins accepting connections. The builder iterates through every discovered tool, applies all registered filters in sequence, and only adds tools to the AvailableTools collection if every filter returns true. This build-time filtering ensures that both static registration (RegisterAll) and dynamic runtime registration (EnableToolset) operate on the same pre-validated set of tools.
Can I apply different filters for different toolsets or tenants without restarting the server?
The standard inventory builder applies filters permanently during construction, but you can achieve per-tenant or per-toolset filtering by rebuilding the inventory for each context. Create a function that instantiates a new Builder with context-specific filters (such as the tenant-based example shown above), call Build(), and use the resulting inventory for that specific request or connection. While you cannot modify filters on a running inventory instance, the lightweight builder pattern makes reconstruction efficient for multi-tenant deployments.
How do I debug which tools were filtered out during startup?
To audit filtering decisions, implement a logging wrapper around your custom filter that records the tool name and the boolean result before returning. Because Inventory.Build() in pkg/inventory/builder.go iterates through tools sequentially, you can also temporarily add debug prints inside the build loop or inspect the AvailableTools slice after Build() completes to compare against the full tool list. For production debugging, consider exposing a metrics endpoint that counts filtered versus available tools by category.
Do custom filters affect the enable_toolset dynamic registration feature?
Yes, custom filters automatically affect dynamic registration because filtering occurs at inventory build time, not at registration time. When a client invokes the enable_toolset tool (implemented in pkg/github/dynamic_tools.go), the server calls ToolsForToolset() on the inventory, which returns tools from the pre-filtered AvailableTools collection. Consequently, if your custom filter excluded a tool during Build(), that tool will not appear in any toolset and cannot be registered dynamically, ensuring consistent security and feature-gating across both static and runtime registration paths.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →