Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
fix(features): preserve unavailable call errors
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13
  • Loading branch information
SamMorrowDrums committed Sep 3, 2026
commit 382a5349f8cb6011f598f416bb9fd6a8665cdd7c
2 changes: 2 additions & 0 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Rules are evaluated lazily after request narrowing and static availability
filters. 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`.
Unavailable named tool calls skip rule evaluation but remain registered so the
handler can return the specific client-availability error.

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
Expand Down
15 changes: 5 additions & 10 deletions pkg/inventory/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import (
"fmt"
"os"
"sort"

ghcontext "github.com/github/github-mcp-server/pkg/context"
)

// isToolsetEnabled checks if a toolset is enabled based on current filters.
Expand Down Expand Up @@ -60,14 +58,11 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool, feature
if (r.additionalTools == nil || !r.additionalTools[tool.Tool.Name]) && !r.isToolsetEnabled(tool.Toolset.ID) {
return false
}
if availability := tool.availability(); !availability.unrestricted() {
if info, ok := ghcontext.MCPMethod(ctx); ok && (info.Method == MCPMethodToolsList || info.Method == MCPMethodToolsCall) {
known := (availability.minimumProtocolVersion == "" || info.ProtocolVersion != "") &&
(availability.requiredElicitationMode == "" || info.ClientCapabilities != nil)
if known && !toolAvailable(info.ProtocolVersion, info.ClientCapabilities, availability) {
return false
}
}
switch featureDecisionForToolAvailability(ctx, tool.availability()) {
case excludeToolBeforeFeatureRule:
return false
case includeToolWithoutFeatureRule:
return true
}
// 4. Check feature availability.
if r.featureChecker != nil && !tool.FeatureRule.Enabled(featureAsBool) {
Expand Down
38 changes: 32 additions & 6 deletions pkg/inventory/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,14 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string {
func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool {
ctx = WithFeatureState(ctx, r.featureChecker)
tools := r.availableTools(ctx)
if r.usesMCPAppsMetadata() && shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) {
tools = stripMCPAppsMetadata(tools)
if present, checkFeature := mcpAppsMetadataStatus(ctx, tools); present {
featureEnabled := false
if checkFeature {
featureEnabled = r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)
}
if shouldStripMCPAppsMetadata(ctx, featureEnabled) {
tools = stripMCPAppsMetadata(tools)
}
}
return tools
}
Expand Down Expand Up @@ -224,10 +230,30 @@ func (r *Inventory) WithFeatureState(ctx context.Context) context.Context {

func (r *Inventory) usesMCPAppsMetadata() bool {
for i := range r.tools {
for _, key := range mcpAppsMetaKeys {
if _, ok := r.tools[i].Tool.Meta[key]; ok {
return true
}
if toolUsesMCPAppsMetadata(&r.tools[i]) {
return true
}
}
return false
}

func mcpAppsMetadataStatus(ctx context.Context, tools []ServerTool) (present, checkFeature bool) {
for i := range tools {
if !toolUsesMCPAppsMetadata(&tools[i]) {
continue
}
present = true
if featureDecisionForToolAvailability(ctx, tools[i].availability()) != includeToolWithoutFeatureRule {
return true, true
}
}
return present, false
}

func toolUsesMCPAppsMetadata(tool *ServerTool) bool {
for _, key := range mcpAppsMetaKeys {
if _, ok := tool.Tool.Meta[key]; ok {
return true
}
}
return false
Expand Down
7 changes: 6 additions & 1 deletion pkg/inventory/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1934,6 +1934,7 @@ func TestMCPAvailabilityRunsBeforeFeatureChecks(t *testing.T) {
t.Run(method, func(t *testing.T) {
calls := 0
checker := func(_ context.Context, _ string) (bool, error) {
time.Sleep(time.Millisecond)
calls++
return true, nil
}
Expand All @@ -1947,7 +1948,11 @@ func TestMCPAvailabilityRunsBeforeFeatureChecks(t *testing.T) {
ClientCapabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}},
})

require.Empty(t, inv.AvailableTools(ctx))
if method == MCPMethodToolsList {
require.Empty(t, inv.AvailableTools(ctx))
} else {
require.Len(t, inv.AvailableTools(ctx), 2)
}
require.Zero(t, calls)

ctx = ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{
Expand Down
43 changes: 43 additions & 0 deletions pkg/inventory/tool_availability.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

Expand All @@ -26,6 +27,14 @@ type toolAvailability struct {
requiredElicitationMode ElicitationMode
}

type toolFeatureDecision uint8

const (
evaluateToolFeatureRule toolFeatureDecision = iota
excludeToolBeforeFeatureRule
includeToolWithoutFeatureRule
)

func (st *ServerTool) availability() toolAvailability {
return toolAvailability{
minimumProtocolVersion: st.MinimumProtocolVersion,
Expand All @@ -37,6 +46,40 @@ func (a toolAvailability) unrestricted() bool {
return a.minimumProtocolVersion == "" && a.requiredElicitationMode == ""
}

func featureDecisionForToolAvailability(ctx context.Context, availability toolAvailability) toolFeatureDecision {
if availability.unrestricted() {
return evaluateToolFeatureRule
}
info, ok := ghcontext.MCPMethod(ctx)
if !ok || info == nil || (info.Method != MCPMethodToolsList && info.Method != MCPMethodToolsCall) {
return evaluateToolFeatureRule
}

available, known := knownToolAvailability(info.ProtocolVersion, info.ClientCapabilities, availability)
if !known || available {
return evaluateToolFeatureRule
}
if info.Method == MCPMethodToolsCall {
return includeToolWithoutFeatureRule
}
return excludeToolBeforeFeatureRule
}

func knownToolAvailability(protocolVersion string, capabilities *mcp.ClientCapabilities, availability toolAvailability) (bool, bool) {
protocolKnown := availability.minimumProtocolVersion == "" || protocolVersion != ""
if protocolKnown && !protocolVersionAllowed(protocolVersion, availability.minimumProtocolVersion) {
return false, true
}
capabilitiesKnown := availability.requiredElicitationMode == "" || capabilities != nil
if capabilitiesKnown && !elicitationModeSupported(capabilities, availability.requiredElicitationMode) {
return false, true
}
if !protocolKnown || !capabilitiesKnown {
return false, false
}
return true, true
}

func addToolAvailabilityMiddleware(server *mcp.Server, tools []ServerTool) {
availabilityByName := make(map[string]toolAvailability)
for _, tool := range tools {
Expand Down
142 changes: 142 additions & 0 deletions pkg/inventory/tool_availability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"encoding/json"
"errors"
"testing"
"time"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -140,6 +142,146 @@ func TestToolAvailability(t *testing.T) {
}
}

func TestToolCallAvailabilitySkipsFeatureChecksOnlyWhenUnavailable(t *testing.T) {
tests := []struct {
name string
minimumProtocolVersion string
requiredElicitationMode ElicitationMode
methodInfo *ghcontext.MCPMethodInfo
clientCapabilities *mcp.ClientCapabilities
legacyProtocol bool
wantCheckerCalls int
wantHandlerCalls int
wantError string
}{
{
name: "protocol unavailable",
minimumProtocolVersion: ProtocolVersionMultiRoundTrip,
methodInfo: &ghcontext.MCPMethodInfo{
Method: MCPMethodToolsCall,
ProtocolVersion: "2025-11-25",
ClientCapabilities: &mcp.ClientCapabilities{},
},
legacyProtocol: true,
wantError: `Tool "restricted" requires MCP protocol version 2026-07-28 or later.`,
wantCheckerCalls: 0,
},
{
name: "elicitation unavailable",
requiredElicitationMode: ElicitationModeForm,
methodInfo: &ghcontext.MCPMethodInfo{
Method: MCPMethodToolsCall,
ProtocolVersion: ProtocolVersionMultiRoundTrip,
ClientCapabilities: &mcp.ClientCapabilities{
Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}},
},
},
clientCapabilities: &mcp.ClientCapabilities{
Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}},
},
wantError: `Tool "restricted" requires client support for form elicitation.`,
wantCheckerCalls: 0,
},
{
name: "eligible",
minimumProtocolVersion: ProtocolVersionMultiRoundTrip,
requiredElicitationMode: ElicitationModeForm,
methodInfo: &ghcontext.MCPMethodInfo{
Method: MCPMethodToolsCall,
ProtocolVersion: ProtocolVersionMultiRoundTrip,
ClientCapabilities: &mcp.ClientCapabilities{
Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}},
},
},
clientCapabilities: &mcp.ClientCapabilities{
Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}},
},
wantCheckerCalls: 1,
wantHandlerCalls: 1,
},
{
name: "unknown metadata defers",
minimumProtocolVersion: ProtocolVersionMultiRoundTrip,
requiredElicitationMode: ElicitationModeForm,
methodInfo: &ghcontext.MCPMethodInfo{Method: MCPMethodToolsCall},
clientCapabilities: &mcp.ClientCapabilities{
Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}},
},
wantCheckerCalls: 1,
wantHandlerCalls: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
checkerCalls := 0
handlerCalls := 0
tool := availabilityTestTool(
"restricted",
tt.minimumProtocolVersion,
tt.requiredElicitationMode,
func() { handlerCalls++ },
)
if tt.wantError != "" {
tool.Tool.Meta = map[string]any{"ui": map[string]any{"resourceUri": "ui://restricted"}}
}
tool.FeatureRule = NewFeatureRule([]FeatureFlag{"feature"}, func(featureAsBool FeatureResolver) bool {
return featureAsBool("feature")
})
inv, err := NewBuilder().
SetTools([]ServerTool{tool}).
WithToolsets([]string{"all"}).
WithFeatureChecker(func(context.Context, string) (bool, error) {
time.Sleep(time.Millisecond)
checkerCalls++
return true, nil
}).
Build()
require.NoError(t, err)

ctx := ghcontext.WithMCPMethodInfo(context.Background(), tt.methodInfo)
server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil)
inv.ForMCPRequest(MCPMethodToolsCall, "restricted").RegisterTools(ctx, server, nil)
require.Equal(t, tt.wantCheckerCalls, checkerCalls)
if tt.legacyProtocol {
server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
if method == "server/discover" {
return nil, errors.New("legacy server does not support discovery")
}
return next(ctx, method, request)
}
})
}

serverTransport, clientTransport := mcp.NewInMemoryTransports()
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = serverSession.Close() })
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{
Capabilities: tt.clientCapabilities,
})
clientSession, err := client.Connect(context.Background(), clientTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = clientSession.Close() })

result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "restricted"})
require.NoError(t, err)
require.Equal(t, tt.wantCheckerCalls, checkerCalls)
require.Equal(t, tt.wantHandlerCalls, handlerCalls)
if tt.wantError == "" {
assert.False(t, result.IsError)
return
}
require.True(t, result.IsError)
require.Len(t, result.Content, 1)
content, ok := result.Content[0].(*mcp.TextContent)
require.True(t, ok)
assert.Equal(t, tt.wantError, content.Text)
})
}
}

func availabilityTestTool(name, minimumProtocolVersion string, requiredElicitationMode ElicitationMode, onCall func()) ServerTool {
return ServerTool{
Tool: mcp.Tool{
Expand Down
Loading