Skip to content
Merged
6 changes: 5 additions & 1 deletion internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP))

// Create feature checker
featureChecker := createFeatureChecker(cfg.EnabledFeatures)

// Create dependencies for tool handlers
deps := github.NewBaseDeps(
clients.rest,
Expand All @@ -207,6 +210,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
cfg.Translator,
github.FeatureFlags{LockdownMode: cfg.LockdownMode},
cfg.ContentWindowSize,
featureChecker,
)

// Inject dependencies into context for all tool handlers
Expand All @@ -222,7 +226,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
WithReadOnly(cfg.ReadOnly).
WithToolsets(enabledToolsets).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures))
WithFeatureChecker(featureChecker)

// Apply token scope filtering if scopes are known (for PAT filtering)
if cfg.TokenScopes != nil {
Expand Down
31 changes: 31 additions & 0 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package github
import (
"context"
"errors"
"fmt"
"os"

"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
Expand Down Expand Up @@ -77,6 +79,9 @@ type ToolDependencies interface {

// GetContentWindowSize returns the content window size for log truncation
GetContentWindowSize() int

// IsFeatureEnabled checks if a feature flag is enabled.
IsFeatureEnabled(ctx context.Context, flagName string) bool
}

// BaseDeps is the standard implementation of ToolDependencies for the local server.
Expand All @@ -93,8 +98,14 @@ type BaseDeps struct {
T translations.TranslationHelperFunc
Flags FeatureFlags
ContentWindowSize int

// Feature flag checker for runtime checks
featureChecker inventory.FeatureFlagChecker
}

// Compile-time assertion to verify that BaseDeps implements the ToolDependencies interface.
var _ ToolDependencies = (*BaseDeps)(nil)

// NewBaseDeps creates a BaseDeps with the provided clients and configuration.
func NewBaseDeps(
client *gogithub.Client,
Expand All @@ -104,6 +115,7 @@ func NewBaseDeps(
t translations.TranslationHelperFunc,
flags FeatureFlags,
contentWindowSize int,
featureChecker inventory.FeatureFlagChecker,
) *BaseDeps {
return &BaseDeps{
Client: client,
Expand All @@ -113,6 +125,7 @@ func NewBaseDeps(
T: t,
Flags: flags,
ContentWindowSize: contentWindowSize,
featureChecker: featureChecker,
}
}

Expand Down Expand Up @@ -143,6 +156,24 @@ func (d BaseDeps) GetFlags() FeatureFlags { return d.Flags }
// GetContentWindowSize implements ToolDependencies.
func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize }

// IsFeatureEnabled checks if a feature flag is enabled.
// Returns false if the feature checker is nil, flag name is empty, or an error occurs.
// This allows tools to conditionally change behavior based on feature flags.
func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool {
if d.featureChecker == nil || flagName == "" {
return false
}

enabled, err := d.featureChecker(ctx, flagName)
if err != nil {
// Log error but don't fail the tool - treat as disabled
fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err)
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error logging uses fmt.Fprintf to os.Stderr directly instead of using the project's logging infrastructure (pkg/log). This is inconsistent with how errors are logged elsewhere in the codebase. Consider using the established logging package for consistent error handling and formatting.

Copilot uses AI. Check for mistakes.
return false
}

return enabled
}

// NewTool creates a ServerTool that retrieves ToolDependencies from context at call time.
// This avoids creating closures at registration time, which is important for performance
// in servers that create a new server instance per request (like the remote server).
Expand Down
108 changes: 108 additions & 0 deletions pkg/github/dependencies_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package github_test

import (
"context"
"errors"
"testing"

"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/stretchr/testify/assert"
)

func TestIsFeatureEnabled_WithEnabledFlag(t *testing.T) {
t.Parallel()

// Create a feature checker that returns true for "test_flag"
checker := func(_ context.Context, flagName string) (bool, error) {
return flagName == "test_flag", nil
}

// Create deps with the checker using NewBaseDeps
deps := github.NewBaseDeps(
nil, // client
nil, // gqlClient
nil, // rawClient
nil, // repoAccessCache
translations.NullTranslationHelper,
github.FeatureFlags{},
0, // contentWindowSize
checker, // featureChecker
)

// Test enabled flag
result := deps.IsFeatureEnabled(context.Background(), "test_flag")
assert.True(t, result, "Expected test_flag to be enabled")

// Test disabled flag
result = deps.IsFeatureEnabled(context.Background(), "other_flag")
assert.False(t, result, "Expected other_flag to be disabled")
}

func TestIsFeatureEnabled_WithoutChecker(t *testing.T) {
t.Parallel()

// Create deps without feature checker (nil)
deps := github.NewBaseDeps(
nil, // client
nil, // gqlClient
nil, // rawClient
nil, // repoAccessCache
translations.NullTranslationHelper,
github.FeatureFlags{},
0, // contentWindowSize
nil, // featureChecker (nil)
)

// Should return false when checker is nil
result := deps.IsFeatureEnabled(context.Background(), "any_flag")
assert.False(t, result, "Expected false when checker is nil")
}

func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) {
t.Parallel()

// Create a feature checker
checker := func(_ context.Context, _ string) (bool, error) {
return true, nil
}

deps := github.NewBaseDeps(
nil, // client
nil, // gqlClient
nil, // rawClient
nil, // repoAccessCache
translations.NullTranslationHelper,
github.FeatureFlags{},
0, // contentWindowSize
checker, // featureChecker
)

// Should return false for empty flag name
result := deps.IsFeatureEnabled(context.Background(), "")
assert.False(t, result, "Expected false for empty flag name")
}

func TestIsFeatureEnabled_CheckerError(t *testing.T) {
t.Parallel()

// Create a feature checker that returns an error
checker := func(_ context.Context, _ string) (bool, error) {
return false, errors.New("checker error")
}

deps := github.NewBaseDeps(
nil, // client
nil, // gqlClient
nil, // rawClient
nil, // repoAccessCache
translations.NullTranslationHelper,
github.FeatureFlags{},
0, // contentWindowSize
checker, // featureChecker
)

// Should return false and log error (not crash)
result := deps.IsFeatureEnabled(context.Background(), "error_flag")
assert.False(t, result, "Expected false when checker returns error")
}
2 changes: 1 addition & 1 deletion pkg/github/dynamic_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func TestDynamicTools_EnableToolset(t *testing.T) {
deps := DynamicToolDependencies{
Server: server,
Inventory: reg,
ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0),
ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, nil),
T: translations.NullTranslationHelper,
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ package github
type FeatureFlags struct {
LockdownMode bool
}

// RemoteMCPExperimental is a long-lived feature flag for experimental remote MCP features.
// This flag enables experimental behaviors in tools that are being tested for remote server deployment.
const RemoteMCPExperimental = "remote_mcp_experimental"
70 changes: 70 additions & 0 deletions pkg/github/hello.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package github

import (
"context"
"encoding/json"

"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

// HelloWorld returns a simple greeting tool that demonstrates feature flag conditional behavior.
// This tool is for testing and demonstration purposes only.
func HelloWorld(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataContext, // Use existing "context" toolset
mcp.Tool{
Name: "hello_world",
Description: t("TOOL_HELLO_WORLD_DESCRIPTION", "A simple greeting tool that demonstrates feature flag conditional behavior"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_HELLO_WORLD_TITLE", "Hello World"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: "Name to greet (optional, defaults to 'World')",
},
},
},
},
[]scopes.Scope{}, // No GitHub scopes required - purely demonstrative
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
// Extract name parameter (optional)
name := "World"
if nameArg, ok := args["name"].(string); ok && nameArg != "" {
name = nameArg
}

// Check feature flag to determine greeting style
var greeting string
if deps.IsFeatureEnabled(ctx, RemoteMCPExperimental) {
// Experimental: More enthusiastic greeting
greeting = "🚀 Hello, " + name + "! Welcome to the EXPERIMENTAL future of MCP! 🎉"
} else {
// Default: Simple greeting
greeting = "Hello, " + name + "!"
}

// Build response
response := map[string]any{
"greeting": greeting,
"experimental_mode": deps.IsFeatureEnabled(ctx, RemoteMCPExperimental),
"timestamp": "2026-01-12", // Static for demonstration
}

jsonBytes, err := json.Marshal(response)
if err != nil {
return utils.NewToolResultError("failed to marshal response"), nil, nil
}

return utils.NewToolResultText(string(jsonBytes)), nil, nil
},
)
}
Loading