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
5 changes: 2 additions & 3 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
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
47 changes: 42 additions & 5 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,52 @@ 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))
},
)
```

Library consumers migrating existing inventory declarations should replace
`FeatureFlagEnable`, `FeatureFlagEnableAll`, and `FeatureFlagDisable` on
`ServerTool`, `ServerResourceTemplate`, and `ServerPrompt` with `FeatureRule`.
`FeatureFlagChecker` and `ToolDependencies.IsFeatureEnabled` continue to accept
string flag names.

Rules are evaluated lazily after request narrowing. Normal Go short-circuiting
avoids checks that cannot affect the result, while one request-owned memo ensures
each flag actually reached is resolved at most once across tools, resources,
prompts, and `deps.IsFeatureEnabled`.

Feature predicates are pure and may depend only on their resolver. Construction
validates every combination of up to 16 declared flags, so an undeclared lookup
fails immediately even when ordinary evaluation would short-circuit that
branch.

The inventory's string-based checker owns request feature state. Once installed,
that state is authoritative; a checker stored on tool dependencies is used only
as a fallback when handlers are invoked directly without request state.
Feature checkers must not call `ResolveFeature`; nested resolution fails the
owning check closed.

---

## 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
10 changes: 8 additions & 2 deletions docs/insiders-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ 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, functional rules declare the flags they may read and are
evaluated lazily after request narrowing. Short-circuiting skips unnecessary
checks, and request-owned state memoizes each flag that is reached. The same
state backs `deps.IsFeatureEnabled`.

`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 @@ -219,5 +224,6 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for
2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via
`--features`, `X-MCP-Features`, or the `features` URL query parameter.
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`.
2 changes: 1 addition & 1 deletion 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
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
10 changes: 4 additions & 6 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
43 changes: 11 additions & 32 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 string) bool

// Logger returns the structured logger, optionally enriched with
// request-scoped data from ctx. Integrators provide their own slog.Handler
Expand Down Expand Up @@ -204,22 +203,11 @@ func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics {
// GetRequestStateSealer implements RequestStateSealerProvider.
func (d BaseDeps) GetRequestStateSealer() RequestStateSealer { return d.StateSealer }

// 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
// IsFeatureEnabled checks if a feature flag is enabled. Request feature state
// is authoritative when present; the dependency checker is a fallback for
// direct handler invocation. Empty names and checker errors resolve false.
func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flag string) bool {
return inventory.ResolveFeature(ctx, d.featureChecker, inventory.FeatureFlag(flag))
}

// NewTool creates a ServerTool that retrieves ToolDependencies from context at call time.
Expand Down Expand Up @@ -495,18 +483,9 @@ func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics {
return d.obsv.Metrics(ctx)
}

// 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
// IsFeatureEnabled checks if a feature flag is enabled. Request feature state
// is authoritative when present; the dependency checker is a fallback for
// direct handler invocation.
func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flag string) bool {
return inventory.ResolveFeature(ctx, d.featureChecker, inventory.FeatureFlag(flag))
}
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 @@ -71,6 +75,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 HTTP request
// configuration) and
Expand All @@ -89,9 +120,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