|
8 | 8 | "net/http" |
9 | 9 | "os" |
10 | 10 | "os/signal" |
| 11 | + "sort" |
11 | 12 | "strings" |
12 | 13 | "syscall" |
13 | 14 | "time" |
@@ -248,20 +249,49 @@ func RunStdioServer(cfg StdioServerConfig) error { |
248 | 249 | logger := slog.New(slogHandler) |
249 | 250 | logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) |
250 | 251 |
|
251 | | - // Fetch token scopes for scope-based tool filtering (PAT tokens only) |
252 | | - // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. |
253 | | - // Fine-grained PATs and other token types don't support this, so we skip filtering. |
| 252 | + featureChecker := createFeatureChecker(cfg.EnabledFeatures) |
| 253 | + |
| 254 | + // Fetch token scopes for scope-based tool filtering and startup validation. |
| 255 | + // We currently fail closed for classic PAT and OAuth access tokens where scopes |
| 256 | + // can be resolved deterministically. |
254 | 257 | var tokenScopes []string |
255 | | - if strings.HasPrefix(cfg.Token, "ghp_") { |
| 258 | + if shouldValidateTokenScopesAtStartup(cfg.Token) { |
256 | 259 | fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) |
257 | 260 | if err != nil { |
258 | | - logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) |
259 | | - } else { |
260 | | - tokenScopes = fetchedScopes |
261 | | - logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) |
| 261 | + return fmt.Errorf("scope requirements check failed: unable to fetch token scopes: %w", err) |
262 | 262 | } |
| 263 | + tokenScopes = fetchedScopes |
| 264 | + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) |
263 | 265 | } else { |
264 | | - logger.Debug("skipping scope filtering for non-PAT token") |
| 266 | + logger.Debug("skipping startup scope validation for token type") |
| 267 | + } |
| 268 | + |
| 269 | + if shouldValidateTokenScopesAtStartup(cfg.Token) { |
| 270 | + startupInventory, err := github.NewInventory(t). |
| 271 | + WithDeprecatedAliases(github.DeprecatedToolAliases). |
| 272 | + WithReadOnly(cfg.ReadOnly). |
| 273 | + WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)). |
| 274 | + WithTools(github.CleanTools(cfg.EnabledTools)). |
| 275 | + WithExcludeTools(cfg.ExcludeTools). |
| 276 | + WithServerInstructions(). |
| 277 | + WithFeatureChecker(featureChecker). |
| 278 | + WithInsidersMode(cfg.InsidersMode). |
| 279 | + Build() |
| 280 | + if err != nil { |
| 281 | + return fmt.Errorf("failed to build inventory for scope validation: %w", err) |
| 282 | + } |
| 283 | + |
| 284 | + missingScopes, blockedTools, err := evaluateScopeRequirements(startupInventory.AllTools(), tokenScopes) |
| 285 | + if err != nil { |
| 286 | + return fmt.Errorf("failed to evaluate token scope requirements: %w", err) |
| 287 | + } |
| 288 | + if len(blockedTools) > 0 { |
| 289 | + return fmt.Errorf( |
| 290 | + "scope requirements unmet at startup: missing scopes [%s]; blocked tools [%s]", |
| 291 | + strings.Join(missingScopes, ", "), |
| 292 | + strings.Join(blockedTools, ", "), |
| 293 | + ) |
| 294 | + } |
265 | 295 | } |
266 | 296 |
|
267 | 297 | ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{ |
@@ -327,6 +357,43 @@ func RunStdioServer(cfg StdioServerConfig) error { |
327 | 357 | return nil |
328 | 358 | } |
329 | 359 |
|
| 360 | +func shouldValidateTokenScopesAtStartup(token string) bool { |
| 361 | + return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "gho_") |
| 362 | +} |
| 363 | + |
| 364 | +func evaluateScopeRequirements(tools []inventory.ServerTool, tokenScopes []string) ([]string, []string, error) { |
| 365 | + filter := github.CreateToolScopeFilter(tokenScopes) |
| 366 | + missingScopeSet := make(map[string]struct{}) |
| 367 | + blockedTools := make([]string, 0) |
| 368 | + |
| 369 | + for i := range tools { |
| 370 | + allowed, err := filter(context.Background(), &tools[i]) |
| 371 | + if err != nil { |
| 372 | + return nil, nil, err |
| 373 | + } |
| 374 | + if allowed { |
| 375 | + continue |
| 376 | + } |
| 377 | + |
| 378 | + blockedTools = append(blockedTools, tools[i].Tool.Name) |
| 379 | + for _, required := range tools[i].RequiredScopes { |
| 380 | + if required == "" { |
| 381 | + continue |
| 382 | + } |
| 383 | + missingScopeSet[required] = struct{}{} |
| 384 | + } |
| 385 | + } |
| 386 | + |
| 387 | + missingScopes := make([]string, 0, len(missingScopeSet)) |
| 388 | + for scope := range missingScopeSet { |
| 389 | + missingScopes = append(missingScopes, scope) |
| 390 | + } |
| 391 | + sort.Strings(missingScopes) |
| 392 | + sort.Strings(blockedTools) |
| 393 | + |
| 394 | + return missingScopes, blockedTools, nil |
| 395 | +} |
| 396 | + |
330 | 397 | // createFeatureChecker returns a FeatureFlagChecker that checks if a flag name |
331 | 398 | // is present in the provided list of enabled features. For the local server, |
332 | 399 | // this is populated from the --features CLI flag. |
|
0 commit comments