Files
github--github-mcp-server/pkg/scopes/scopes.go
Sam Morrow 46b8cb63ac Add PAT scope filtering for stdio server
Add the ability to filter tools based on token scopes for PAT users.
This uses an HTTP HEAD request to GitHub's API to discover token scopes.

New components:
- pkg/scopes/filter.go: HasRequiredScopes checks if scopes satisfy tool requirements
- pkg/scopes/fetcher.go: FetchTokenScopes gets scopes via HTTP HEAD to GitHub API
- pkg/github/scope_filter.go: CreateScopeFilter creates inventory.ToolFilter

Integration:
- Add --filter-by-scope flag to stdio command (disabled by default)
- When enabled, fetches token scopes on startup
- Tools requiring unavailable scopes are hidden from tool list
- Gracefully continues without filtering if scope fetch fails (logs warning)

This allows the OSS server to have similar scope-based tool visibility
as the remote server, and the filter logic can be reused by remote server.
2026-01-05 16:05:24 +00:00

195 lines
5.6 KiB
Go

package scopes
import "sort"
// Scope represents a GitHub OAuth scope.
// These constants define all OAuth scopes used by the GitHub MCP server tools.
// See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps
type Scope string
const (
// NoScope indicates no scope is required (public access).
NoScope Scope = ""
// Repo grants full control of private repositories
Repo Scope = "repo"
// PublicRepo grants access to public repositories
PublicRepo Scope = "public_repo"
// ReadOrg grants read-only access to organization membership, teams, and projects
ReadOrg Scope = "read:org"
// WriteOrg grants write access to organization membership and teams
WriteOrg Scope = "write:org"
// AdminOrg grants full control of organizations and teams
AdminOrg Scope = "admin:org"
// Gist grants write access to gists
Gist Scope = "gist"
// Notifications grants access to notifications
Notifications Scope = "notifications"
// ReadProject grants read-only access to projects
ReadProject Scope = "read:project"
// Project grants full control of projects
Project Scope = "project"
// SecurityEvents grants read and write access to security events
SecurityEvents Scope = "security_events"
// User grants read/write access to profile info
User Scope = "user"
// ReadUser grants read-only access to profile info
ReadUser Scope = "read:user"
// UserEmail grants read access to user email addresses
UserEmail Scope = "user:email"
// ReadPackages grants read access to packages
ReadPackages Scope = "read:packages"
// WritePackages grants write access to packages
WritePackages Scope = "write:packages"
)
// ScopeHierarchy defines parent-child relationships between scopes.
// A parent scope implicitly grants access to all child scopes.
// For example, "repo" grants access to "public_repo" and "security_events".
var ScopeHierarchy = map[Scope][]Scope{
Repo: {PublicRepo, SecurityEvents},
AdminOrg: {WriteOrg, ReadOrg},
WriteOrg: {ReadOrg},
Project: {ReadProject},
WritePackages: {ReadPackages},
User: {ReadUser, UserEmail},
}
// ScopeSet represents a set of OAuth scopes.
type ScopeSet map[Scope]bool
// NewScopeSet creates a new ScopeSet from the given scopes.
func NewScopeSet(scopes ...Scope) ScopeSet {
set := make(ScopeSet)
for _, scope := range scopes {
set[scope] = true
}
return set
}
// ToSlice converts a ScopeSet to a slice of Scope values.
func (s ScopeSet) ToSlice() []Scope {
scopes := make([]Scope, 0, len(s))
for scope := range s {
scopes = append(scopes, scope)
}
// Sort for deterministic output
sort.Slice(scopes, func(i, j int) bool {
return scopes[i] < scopes[j]
})
return scopes
}
// ToStringSlice converts a ScopeSet to a slice of string values.
// The returned slice is sorted for deterministic output.
func (s ScopeSet) ToStringSlice() []string {
scopes := make([]string, 0, len(s))
for scope := range s {
scopes = append(scopes, string(scope))
}
sort.Strings(scopes)
return scopes
}
// ToStringSlice converts a slice of Scopes to a slice of strings.
func ToStringSlice(scopes ...Scope) []string {
result := make([]string, len(scopes))
for i, scope := range scopes {
result[i] = string(scope)
}
return result
}
// ExpandScopes takes a list of required scopes and returns all accepted scopes
// including parent scopes from the hierarchy.
// For example, if "public_repo" is required, "repo" is also accepted since
// having the "repo" scope grants access to "public_repo".
// The returned slice is sorted for deterministic output.
func ExpandScopes(required ...Scope) []string {
if len(required) == 0 {
return nil
}
accepted := make(map[string]bool)
// Add required scopes
for _, scope := range required {
accepted[string(scope)] = true
}
// Add parent scopes that grant access to required scopes
for parent, children := range ScopeHierarchy {
for _, child := range children {
if accepted[string(child)] {
accepted[string(parent)] = true
}
}
}
// Convert to slice and sort for deterministic output
result := make([]string, 0, len(accepted))
for scope := range accepted {
result = append(result, scope)
}
sort.Strings(result)
return result
}
// expandScopeSet returns a set of all scopes granted by the given scopes,
// including child scopes from the hierarchy.
// For example, if "repo" is provided, the result includes "repo", "public_repo",
// and "security_events" since "repo" grants access to those child scopes.
func expandScopeSet(scopes []string) map[string]bool {
expanded := make(map[string]bool, len(scopes))
for _, scope := range scopes {
expanded[scope] = true
// Add child scopes granted by this scope
if children, ok := ScopeHierarchy[Scope(scope)]; ok {
for _, child := range children {
expanded[string(child)] = true
}
}
}
return expanded
}
// HasRequiredScopes checks if tokenScopes satisfy the acceptedScopes requirement.
// A tool's acceptedScopes includes both the required scopes AND parent scopes
// that implicitly grant the required permissions (via ExpandScopes).
//
// For PAT filtering: if ANY of the acceptedScopes are granted by the token
// (directly or via scope hierarchy), the tool should be visible.
//
// Returns true if the tool should be visible to the token holder.
func HasRequiredScopes(tokenScopes []string, acceptedScopes []string) bool {
// No scopes required = always allowed
if len(acceptedScopes) == 0 {
return true
}
// Expand token scopes to include child scopes they grant
grantedScopes := expandScopeSet(tokenScopes)
// Check if any accepted scope is granted by the token
for _, accepted := range acceptedScopes {
if grantedScopes[accepted] {
return true
}
}
return false
}