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
85 changes: 76 additions & 9 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -248,20 +249,49 @@ func RunStdioServer(cfg StdioServerConfig) error {
logger := slog.New(slogHandler)
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)

// Fetch token scopes for scope-based tool filtering (PAT tokens only)
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
// Fine-grained PATs and other token types don't support this, so we skip filtering.
featureChecker := createFeatureChecker(cfg.EnabledFeatures)

// Fetch token scopes for scope-based tool filtering and startup validation.
// We currently fail closed for classic PAT and OAuth access tokens where scopes
// can be resolved deterministically.
var tokenScopes []string
if strings.HasPrefix(cfg.Token, "ghp_") {
if shouldValidateTokenScopesAtStartup(cfg.Token) {
fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host)
if err != nil {
logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err)
} else {
tokenScopes = fetchedScopes
logger.Info("token scopes fetched for filtering", "scopes", tokenScopes)
return fmt.Errorf("scope requirements check failed: unable to fetch token scopes: %w", err)
}
tokenScopes = fetchedScopes
logger.Info("token scopes fetched for filtering", "scopes", tokenScopes)
} else {
logger.Debug("skipping scope filtering for non-PAT token")
logger.Debug("skipping startup scope validation for token type")
}

if shouldValidateTokenScopesAtStartup(cfg.Token) {
startupInventory, err := github.NewInventory(t).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
WithFeatureChecker(featureChecker).
WithInsidersMode(cfg.InsidersMode).
Build()
if err != nil {
return fmt.Errorf("failed to build inventory for scope validation: %w", err)
}

missingScopes, blockedTools, err := evaluateScopeRequirements(startupInventory.AllTools(), tokenScopes)
if err != nil {
return fmt.Errorf("failed to evaluate token scope requirements: %w", err)
}
if len(blockedTools) > 0 {
return fmt.Errorf(
"scope requirements unmet at startup: missing scopes [%s]; blocked tools [%s]",
strings.Join(missingScopes, ", "),
strings.Join(blockedTools, ", "),
)
}
}

ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{
Expand Down Expand Up @@ -327,6 +357,43 @@ func RunStdioServer(cfg StdioServerConfig) error {
return nil
}

func shouldValidateTokenScopesAtStartup(token string) bool {
return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "gho_")
}

func evaluateScopeRequirements(tools []inventory.ServerTool, tokenScopes []string) ([]string, []string, error) {
filter := github.CreateToolScopeFilter(tokenScopes)
missingScopeSet := make(map[string]struct{})
blockedTools := make([]string, 0)

for i := range tools {
allowed, err := filter(context.Background(), &tools[i])
if err != nil {
return nil, nil, err
}
if allowed {
continue
}

blockedTools = append(blockedTools, tools[i].Tool.Name)
for _, required := range tools[i].RequiredScopes {
if required == "" {
continue
}
missingScopeSet[required] = struct{}{}
}
}

missingScopes := make([]string, 0, len(missingScopeSet))
for scope := range missingScopeSet {
missingScopes = append(missingScopes, scope)
}
sort.Strings(missingScopes)
sort.Strings(blockedTools)

return missingScopes, blockedTools, nil
}

// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name
// is present in the provided list of enabled features. For the local server,
// this is populated from the --features CLI flag.
Expand Down
88 changes: 88 additions & 0 deletions internal/ghmcp/server_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package ghmcp

import (
"testing"

"github.com/github/github-mcp-server/pkg/inventory"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/require"
)

func makeScopeTestTool(
name string,
readOnly bool,
requiredScopes []string,
acceptedScopes []string,
) inventory.ServerTool {
return inventory.ServerTool{
Tool: mcp.Tool{
Name: name,
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: readOnly,
},
},
RequiredScopes: requiredScopes,
AcceptedScopes: acceptedScopes,
}
}

func TestShouldValidateTokenScopesAtStartup(t *testing.T) {
require.True(t, shouldValidateTokenScopesAtStartup("ghp_test"))
require.True(t, shouldValidateTokenScopesAtStartup("gho_test"))
require.False(t, shouldValidateTokenScopesAtStartup("ghs_test"))
require.False(t, shouldValidateTokenScopesAtStartup("github_pat_test"))
}

func TestEvaluateScopeRequirementsReportsMissingScopesAndBlockedTools(t *testing.T) {
tools := []inventory.ServerTool{
makeScopeTestTool(
"repo_write",
false,
[]string{"repo"},
[]string{"repo"},
),
}

missingScopes, blockedTools, err := evaluateScopeRequirements(tools, []string{})
require.NoError(t, err)
require.Equal(t, []string{"repo"}, missingScopes)
require.Equal(t, []string{"repo_write"}, blockedTools)
}

func TestEvaluateScopeRequirementsAllowsReadOnlyRepoToolsWithoutScopes(t *testing.T) {
tools := []inventory.ServerTool{
makeScopeTestTool(
"repo_read_only",
true,
[]string{"repo"},
[]string{"repo", "public_repo"},
),
}

missingScopes, blockedTools, err := evaluateScopeRequirements(tools, []string{})
require.NoError(t, err)
require.Empty(t, missingScopes)
require.Empty(t, blockedTools)
}

func TestEvaluateScopeRequirementsSortsOutputDeterministically(t *testing.T) {
tools := []inventory.ServerTool{
makeScopeTestTool(
"z_tool",
false,
[]string{"admin:org"},
[]string{"admin:org"},
),
makeScopeTestTool(
"a_tool",
false,
[]string{"repo"},
[]string{"repo"},
),
}

missingScopes, blockedTools, err := evaluateScopeRequirements(tools, []string{})
require.NoError(t, err)
require.Equal(t, []string{"admin:org", "repo"}, missingScopes)
require.Equal(t, []string{"a_tool", "z_tool"}, blockedTools)
}