Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/github-mcp-server/feature_flag_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ func flaggedToolDiff(t translations.TranslationHelperFunc, flag string, defaultT
// the given flags as enabled and every other flag as disabled. Passing nil
// produces the default-flagged inventory.
func buildInventoryWithFlags(t translations.TranslationHelperFunc, enabled map[string]bool) *inventory.Inventory {
checker := func(_ context.Context, flag string) (bool, error) {
return enabled[flag], nil
checker := func(_ context.Context, flag inventory.FeatureFlag) (bool, error) {
return enabled[string(flag)], nil
}
inv, _ := github.NewInventory(t).
WithToolsets([]string{"all"}).
Expand Down
7 changes: 3 additions & 4 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func init() {

// noFeatureFlagsChecker reports every feature flag as disabled. It models the
// default user experience used by the generated documentation.
func noFeatureFlagsChecker(_ context.Context, _ string) (bool, error) {
func noFeatureFlagsChecker(_ context.Context, _ inventory.FeatureFlag) (bool, error) {
return false, nil
}

Expand Down Expand Up @@ -61,9 +61,8 @@ func generateReadmeDocs(readmePath string) error {

// The README documents the default user experience: tools that are
// enabled with no special flags set. Installing a checker that reports
// every flag as disabled excludes tools gated by FeatureFlagEnable and
// keeps the legacy variants of tools gated by FeatureFlagDisable, so
// flag-gated duplicates don't appear twice.
// every flag as disabled keeps the default variants selected by functional
// feature rules, so flag-gated duplicates don't appear twice.
// Build() can only fail if WithTools specifies invalid tools - not used here
r, _ := github.NewInventory(t).
WithToolsets([]string{"all"}).
Expand Down
31 changes: 25 additions & 6 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,34 @@ Only flags listed in
[`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by
end users. Insiders-only flags are not user-toggleable.

## Declaring tool availability

Tools, resources, and prompts use `inventory.NewFeatureRule` when feature flags
change whether they are available. Each rule declares the flags it references
and evaluates them with a fail-closed `FeatureResolver`, so normal Go boolean
expressions can represent AND, OR, NOT, and mixed conditions:

```go
tool.FeatureRule = inventory.NewFeatureRule(
[]inventory.FeatureFlag{x, y},
func(featureAsBool inventory.FeatureResolver) bool {
return !(featureAsBool(x) && featureAsBool(y))
},
)
```

The service deduplicates the declared flags, resolves each one at most once for
the request, and shares those values with tool dependencies. Feature checks
inside handlers continue to use `deps.IsFeatureEnabled`.

---

## Tools affected by each flag

The list below is regenerated from the Go source. For each user-controllable
feature flag, it lists every tool whose **inventory or input schema** differs
from the default — either because the flag introduces a new tool, or because
it selects a flag-aware variant of an existing tool. Flags that only affect
runtime behavior (such as output formatting) won't appear here.
The list below is regenerated by comparing the default tool surface with each
user-controllable flag enabled individually. Complex multi-flag rules may
require separate documentation. Flags that only affect runtime behavior (such
as output formatting) won't appear here.

<!-- START AUTOMATED FEATURE FLAG TOOLS -->

Expand Down Expand Up @@ -357,7 +376,7 @@ runtime behavior (such as output formatting) won't appear here.
### `thread_resolution_reason`

- **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews
- **Required OAuth Scopes**: `repo`
- **OAuth Challenge Scopes**: `repo`
- `body`: Review comment text (string, optional)
- `commitID`: SHA of commit to review (string, optional)
- `event`: Review action to perform. (string, optional)
Expand Down
12 changes: 10 additions & 2 deletions docs/insiders-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for
3. **Insiders expansion.** If insiders mode is on (`--insiders`, `/insiders` route, or `X-MCP-Insiders: true`), every flag in [`InsidersFeatureFlags`](../pkg/github/feature_flags.go) is unioned in. The insiders expansion is **not** re-validated against the allowlist — insiders is a server-controlled switch that can reach internal-only flags.
4. **Server-side fallback (remote server only).** Any flag not yet decided falls back to the remote server's feature manager, which can roll a feature out independently of user input or insiders membership.

For tool availability, each functional feature rule statically declares the
flags it reads. The service deduplicates those declarations, resolves every
relevant flag once into request-owned state, and then evaluates all rules as
in-memory boolean expressions. The same state backs
`deps.IsFeatureEnabled`, so checks made inside a tool call reuse resolved values
and lazily cache any handler-only flag using the live tool-call context.

`AllowedFeatureFlags` and `InsidersFeatureFlags` are deliberately independent sets:

- A flag in **`AllowedFeatureFlags` only** is a regular opt-in: users can turn it on, but insiders does not auto-enable it. Granular issues/PRs flags work this way.
Expand All @@ -216,5 +223,6 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for
1. Add a constant in `pkg/github/feature_flags.go`.
2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via `--features` / `X-MCP-Features`.
3. Add it to `InsidersFeatureFlags` if insiders mode should turn it on automatically.
4. Gate the behavior on the concrete flag (`deps.IsFeatureEnabled(ctx, FeatureFlagX)`), never on `cfg.InsidersMode`. There is a `TestGitHubPackageDoesNotReadInsidersMode` guard test that fails if `pkg/github` reads `InsidersMode` directly.
5. The MCP-diff CI workflow picks up new entries in `AllowedFeatureFlags` automatically — see `.github/workflows/mcp-diff.yml`.
4. For tool availability, attach an `inventory.NewFeatureRule` that declares every flag used by its predicate. For behavior inside a handler, use `deps.IsFeatureEnabled(ctx, FeatureFlagX)`.
5. Gate on concrete flags, never on `cfg.InsidersMode`. There is a `TestGitHubPackageDoesNotReadInsidersMode` guard test that fails if `pkg/github` reads `InsidersMode` directly.
6. The MCP-diff CI workflow picks up new entries in `AllowedFeatureFlags` automatically — see `.github/workflows/mcp-diff.yml`.
6 changes: 3 additions & 3 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ type StdioServerConfig struct {
EnabledTools []string

// EnabledFeatures is a list of feature flags that are enabled
// Items with FeatureFlagEnable matching an entry in this list will be available
// Tool feature rules evaluate entries in this list.
EnabledFeatures []string

// ReadOnly indicates if we should only register read-only tools
Expand Down Expand Up @@ -436,8 +436,8 @@ func RunStdioServer(cfg StdioServerConfig) error {
// features are resolved once at startup from --features CLI flag and insiders mode.
func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker {
featureSet := github.ResolveFeatureFlags(enabledFeatures, insidersMode)
return func(_ context.Context, flagName string) (bool, error) {
return featureSet[flagName], nil
return func(_ context.Context, flagName inventory.FeatureFlag) (bool, error) {
return featureSet[string(flagName)], nil
}
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/github/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ func Test_ActionsGetJobLogs(t *testing.T) {
// Note: consolidated ActionsGetJobLogs has same tool name "get_job_logs" as the individual tool
// but with different descriptions. We skip toolsnap validation here since the individual
// tool's toolsnap already exists and is tested in Test_GetJobLogs.
// The consolidated tool has FeatureFlagEnable set, so only one will be active at a time.
// The functional feature rules ensure only one variant is active at a time.
assert.Equal(t, "get_job_logs", toolDef.Tool.Name)
assert.NotEmpty(t, toolDef.Tool.Description)
inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema)
Expand Down
3 changes: 2 additions & 1 deletion pkg/github/context_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v89/github"
"github.com/modelcontextprotocol/go-sdk/mcp"
Expand Down Expand Up @@ -189,7 +190,7 @@ func Test_GetMe_IFC_FeatureFlag(t *testing.T) {
translations.NullTranslationHelper,
FeatureFlags{},
0,
func(_ context.Context, flagName string) (bool, error) {
func(_ context.Context, flagName inventory.FeatureFlag) (bool, error) {
return flagName == FeatureFlagIFCLabels && enabled, nil
},
stubExporters(),
Expand Down
12 changes: 5 additions & 7 deletions pkg/github/csv_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,18 @@ func TestCSVOutputAppliedToDefaultListTools(t *testing.T) {
require.Len(t, available, 2)

listing := requireToolByName(t, available, "list_things")
assert.Empty(t, listing.FeatureFlagEnable)
assert.Empty(t, listing.FeatureFlagDisable)
assert.True(t, listing.FeatureRule.IsZero())

getting := requireToolByName(t, available, "get_thing")
assert.Empty(t, getting.FeatureFlagEnable)
assert.Empty(t, getting.FeatureFlagDisable)
assert.True(t, getting.FeatureRule.IsZero())
}
}

func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) {
enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`)
enabledOnly.FeatureFlagEnable = FeatureFlagFileBlame
enabledOnly.FeatureRule = featureEnabledRule(FeatureFlagFileBlame)
disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`)
disabledOnly.FeatureFlagDisable = []string{FeatureFlagFileBlame}
disabledOnly.FeatureRule = featureDisabledRule(FeatureFlagFileBlame)

tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly})
require.Len(t, tools, 2)
Expand Down Expand Up @@ -368,7 +366,7 @@ type csvOutputTestDeps struct {
csvOn bool
}

func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag string) bool {
func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag inventory.FeatureFlag) bool {
return flag == FeatureFlagCSVOutput && d.csvOn
}

Expand Down
33 changes: 5 additions & 28 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"fmt"
"log/slog"
"net/http"
"os"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/http/transport"
Expand Down Expand Up @@ -95,7 +94,7 @@ type ToolDependencies interface {
GetContentWindowSize() int

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

// Logger returns the structured logger, optionally enriched with
// request-scoped data from ctx. Integrators provide their own slog.Handler
Expand Down Expand Up @@ -207,19 +206,8 @@ func (d BaseDeps) GetRequestStateSealer() RequestStateSealer { return d.StateSea
// 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)
return false
}

return enabled
func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flag inventory.FeatureFlag) bool {
return inventory.ResolveFeature(ctx, d.featureChecker, flag)
}

// NewTool creates a ServerTool that retrieves ToolDependencies from context at call time.
Expand Down Expand Up @@ -496,17 +484,6 @@ func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics {
}

// IsFeatureEnabled checks if a feature flag is enabled.
func (d *RequestDeps) 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)
return false
}

return enabled
func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flag inventory.FeatureFlag) bool {
return inventory.ResolveFeature(ctx, d.featureChecker, flag)
}
7 changes: 4 additions & 3 deletions pkg/github/dependencies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/headers"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/observability"
"github.com/github/github-mcp-server/pkg/observability/metrics"
"github.com/github/github-mcp-server/pkg/translations"
Expand Down Expand Up @@ -202,7 +203,7 @@ 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) {
checker := func(_ context.Context, flagName inventory.FeatureFlag) (bool, error) {
return flagName == "test_flag", nil
}

Expand Down Expand Up @@ -253,7 +254,7 @@ func TestIsFeatureEnabled_EmptyFlagName(t *testing.T) {
t.Parallel()

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

Expand Down Expand Up @@ -388,7 +389,7 @@ func TestIsFeatureEnabled_CheckerError(t *testing.T) {
t.Parallel()

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

Expand Down
39 changes: 35 additions & 4 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package github

import "slices"
import (
"slices"

"github.com/github/github-mcp-server/pkg/inventory"
)

// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms).
const MCPAppsFeatureFlag = "remote_mcp_ui_apps"
Expand Down Expand Up @@ -70,6 +74,33 @@ type FeatureFlags struct {
LockdownMode bool
}

func featureEnabledRule(feature string) inventory.FeatureRule {
flag := inventory.FeatureFlag(feature)
return inventory.NewFeatureRule(
[]inventory.FeatureFlag{flag},
func(featureAsBool inventory.FeatureResolver) bool {
return featureAsBool(flag)
},
)
}

func featureDisabledRule(feature string) inventory.FeatureRule {
flag := inventory.FeatureFlag(feature)
return inventory.NewFeatureRule(
[]inventory.FeatureFlag{flag},
func(featureAsBool inventory.FeatureResolver) bool {
return !featureAsBool(flag)
},
)
}

var (
issuesGranularFeatureRule = featureEnabledRule(FeatureFlagIssuesGranular)
issuesConsolidatedFeatureRule = featureDisabledRule(FeatureFlagIssuesGranular)
pullRequestsGranularFeatureRule = featureEnabledRule(FeatureFlagPullRequestsGranular)
pullRequestsConsolidatedRule = featureDisabledRule(FeatureFlagPullRequestsGranular)
)

// ResolveFeatureFlags computes the effective set of enabled feature flags by:
// 1. Taking the user-supplied flags (from --features or X-MCP-Features) and
// keeping only those present in AllowedFeatureFlags. Unknown or unsafe
Expand All @@ -87,9 +118,9 @@ type FeatureFlags struct {
// Returns a set (map) for O(1) lookup by the feature checker.
func ResolveFeatureFlags(enabledFeatures []string, insidersMode bool) map[string]bool {
effective := make(map[string]bool)
for _, f := range enabledFeatures {
if slices.Contains(AllowedFeatureFlags, f) {
effective[f] = true
for _, feature := range enabledFeatures {
if slices.Contains(AllowedFeatureFlags, feature) {
effective[feature] = true
}
}
if insidersMode {
Expand Down
Loading
Loading