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() in pkg/inventory/builder.go before calling Build() 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 in AvailableTools.
  • Runtime consistency: Dynamic registration via enable_toolset in pkg/github/dynamic_tools.go uses ToolsForToolset(), which returns pre-filtered tools, ensuring custom constraints are respected at runtime.
  • Reference implementation: Study pkg/github/scope_filter.go for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →