From 4ec3576d86197de8e0e790451149d176818d36d1 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 11 Jun 2026 22:54:00 +0200 Subject: [PATCH] feat: add declarative fine-grained permission (FGP) subsystem Mirror the existing pkg/scopes subsystem with a new pkg/permissions package so this repo is the public source of truth for the fine-grained permission each MCP tool requires. - pkg/permissions: typed Permission/Level/Scope, Requirement combinators (Require/AllOf/AnyOf/And, SatisfiedBy, Permissions), and a generated catalog (catalog_generated.go) produced from the PUBLIC github/rest-api-description app-permissions schema via gen.go. - inventory.ServerTool gains RequiredPermissions + chainable WithPermissions; zero value means "no gate" (tool always shown). - pkg/github/permission_filter.go: inventory bridge helpers and a fail-open CreateToolPermissionFilter (dormant in OSS; no granted source). - Seed hand-authored requirements for a high-signal subset of tools. - generate-docs emits a "Required Permissions (fine-grained)" line and a generated table in new docs/permissions-filtering.md; README + server configuration link it. - New list-permissions CLI + script/list-permissions. Catalog data is exclusively public (names + levels as in the REST docs and the X-Accepted-GitHub-Permissions header); enterprise permissions excluded. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 27 ++ cmd/github-mcp-server/generate_docs.go | 65 +++++ cmd/github-mcp-server/list_permissions.go | 252 ++++++++++++++++++ docs/feature-flags.md | 5 + docs/insiders-features.md | 4 + docs/permissions-filtering.md | 83 ++++++ docs/server-configuration.md | 6 + pkg/github/actions.go | 9 +- pkg/github/code_scanning.go | 5 +- pkg/github/dependabot.go | 5 +- pkg/github/issues.go | 11 +- pkg/github/issues_granular.go | 3 +- pkg/github/permission_filter.go | 57 ++++ pkg/github/permission_filter_test.go | 75 ++++++ pkg/github/pullrequests.go | 7 +- pkg/github/repositories.go | 15 +- pkg/github/secret_scanning.go | 5 +- pkg/inventory/server_tool.go | 15 ++ pkg/permissions/catalog.go | 74 ++++++ pkg/permissions/catalog_generated.go | 125 +++++++++ pkg/permissions/catalog_test.go | 88 +++++++ pkg/permissions/gen.go | 230 ++++++++++++++++ pkg/permissions/map.go | 51 ++++ pkg/permissions/permissions.go | 308 ++++++++++++++++++++++ pkg/permissions/permissions_test.go | 143 ++++++++++ script/list-permissions | 24 ++ 26 files changed, 1666 insertions(+), 26 deletions(-) create mode 100644 cmd/github-mcp-server/list_permissions.go create mode 100644 docs/permissions-filtering.md create mode 100644 pkg/github/permission_filter.go create mode 100644 pkg/github/permission_filter_test.go create mode 100644 pkg/permissions/catalog.go create mode 100644 pkg/permissions/catalog_generated.go create mode 100644 pkg/permissions/catalog_test.go create mode 100644 pkg/permissions/gen.go create mode 100644 pkg/permissions/map.go create mode 100644 pkg/permissions/permissions.go create mode 100644 pkg/permissions/permissions_test.go create mode 100755 script/list-permissions diff --git a/README.md b/README.md index dc063f22ce..7fcb13e245 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,10 @@ docker run -it --rm ghcr.io/github/github-mcp-server tool-search "issue" --max-r github-mcp-server tool-search "issue" --max-results 5 ``` +- `github-mcp-server list-scopes` lists the OAuth scopes each enabled tool requires. See [PAT Scope Filtering](./docs/scope-filtering.md). +- `github-mcp-server list-permissions` lists the fine-grained permission each enabled tool requires. See [Fine-Grained Permission Filtering](./docs/permissions-filtering.md). + + ## Tool Configuration The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--toolsets` flag. This allows you to control which GitHub API capabilities are available to your AI tools. Enabling only the toolsets that you need can help the LLM with tool choice and reduce the context size. @@ -591,6 +595,7 @@ The following sets of tools are available: - **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `actions:read` - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -603,6 +608,7 @@ The following sets of tools are available: - **actions_list** - List GitHub Actions workflows in a repository - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `actions:read` - `method`: The action to perform (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (default: 1) (number, optional) @@ -618,6 +624,7 @@ The following sets of tools are available: - **actions_run_trigger** - Trigger GitHub Actions workflow actions - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `actions:write` - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) @@ -628,6 +635,7 @@ The following sets of tools are available: - **get_job_logs** - Get GitHub Actions workflow job logs - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `actions:read` - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) - `owner`: Repository owner (string, required) @@ -645,6 +653,7 @@ The following sets of tools are available: - **get_code_scanning_alert** - Get code scanning alert - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` + - **Required Permissions (fine-grained)**: `security_events:read` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -652,6 +661,7 @@ The following sets of tools are available: - **list_code_scanning_alerts** - List code scanning alerts - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` + - **Required Permissions (fine-grained)**: `security_events:read` - `owner`: The owner of the repository. (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -710,6 +720,7 @@ The following sets of tools are available: - **get_dependabot_alert** - Get dependabot alert - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` + - **Required Permissions (fine-grained)**: `vulnerability_alerts:read` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -717,6 +728,7 @@ The following sets of tools are available: - **list_dependabot_alerts** - List dependabot alerts - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` + - **Required Permissions (fine-grained)**: `vulnerability_alerts:read` - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `owner`: The owner of the repository. (string, required) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -841,6 +853,7 @@ The following sets of tools are available: - **issue_read** - Get issue details - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:read` - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. Options are: @@ -856,6 +869,7 @@ The following sets of tools are available: - **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:write` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) @@ -881,6 +895,7 @@ The following sets of tools are available: - **list_issues** - List issues - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:read` - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `labels`: Filter by labels (string[], optional) @@ -1083,6 +1098,7 @@ The following sets of tools are available: - **create_pull_request** - Open new pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `pull_requests:write` - `base`: Branch to merge into (string, required) - `body`: PR description (string, optional) - `draft`: Create as draft PR (boolean, optional) @@ -1094,6 +1110,7 @@ The following sets of tools are available: - **list_pull_requests** - List pull requests - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `pull_requests:read` - `base`: Filter by base branch (string, optional) - `direction`: Sort direction (string, optional) - `head`: Filter by head user/org and branch (string, optional) @@ -1115,6 +1132,7 @@ The following sets of tools are available: - **pull_request_read** - Get details for a single pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `pull_requests:read` - `after`: Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page. (string, optional) - `method`: Action to specify what pull request data needs to be retrieved from GitHub. Possible options: @@ -1183,6 +1201,7 @@ The following sets of tools are available: - **create_branch** - Create branch - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `contents:write` - `branch`: Name for new branch (string, required) - `from_branch`: Source branch (defaults to repo default) (string, optional) - `owner`: Repository owner (string, required) @@ -1190,6 +1209,7 @@ The following sets of tools are available: - **create_or_update_file** - Create or update file - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `contents:write` - `branch`: Branch to create/update the file in (string, required) - `content`: Content of the file (string, required) - `message`: Commit message (string, required) @@ -1231,6 +1251,7 @@ The following sets of tools are available: - **get_file_contents** - Get file or directory contents - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `contents:read` - `owner`: Repository owner (username or organization) (string, required) - `path`: Path to file/directory (string, optional) - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) @@ -1256,6 +1277,7 @@ The following sets of tools are available: - **list_branches** - List branches - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `contents:read` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1263,6 +1285,7 @@ The following sets of tools are available: - **list_commits** - List commits - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `contents:read` - `author`: Author username or email address to filter commits by (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1290,6 +1313,7 @@ The following sets of tools are available: - **list_tags** - List tags - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `contents:read` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1297,6 +1321,7 @@ The following sets of tools are available: - **push_files** - Push files to repository - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `contents:write` - `branch`: Branch to push to (string, required) - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required) - `message`: Commit message (string, required) @@ -1337,6 +1362,7 @@ The following sets of tools are available: - **get_secret_scanning_alert** - Get secret scanning alert - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` + - **Required Permissions (fine-grained)**: `secret_scanning_alerts:read` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -1344,6 +1370,7 @@ The following sets of tools are available: - **list_secret_scanning_alerts** - List secret scanning alerts - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` + - **Required Permissions (fine-grained)**: `secret_scanning_alerts:read` - `owner`: The owner of the repository. (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 78ed8361a8..21092d6d4b 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -46,6 +46,7 @@ func generateAllDocs() error { {"docs/insiders-features.md", generateInsidersFeaturesDocs}, {"docs/feature-flags.md", generateFeatureFlagsDocs}, {"docs/tool-renaming.md", generateDeprecatedAliasesDocs}, + {"docs/permissions-filtering.md", generatePermissionsDocs}, } { if err := doc.fn(doc.path); err != nil { return fmt.Errorf("failed to generate docs for %s: %w", doc.path, err) @@ -229,6 +230,11 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { } } + // Fine-grained permission requirement if present + if !tool.RequiredPermissions.IsZero() { + fmt.Fprintf(buf, " - **Required Permissions (fine-grained)**: `%s`\n", tool.RequiredPermissions.String()) + } + // MCP App UI metadata (only rendered when the remote_mcp_ui_apps flag // applied to the inventory; for the no-flags README this section is // stripped by inventory.ToolsForRegistration before rendering). @@ -499,3 +505,62 @@ func generateDeprecatedAliasesTable() string { return buf.String() } + +func generatePermissionsDocs(docsPath string) error { + // Read the current file + content, err := os.ReadFile(docsPath) //#nosec G304 + if err != nil { + return fmt.Errorf("failed to read docs file: %w", err) + } + + // Replace content between markers + updatedContent, err := replaceSection(string(content), "START AUTOMATED PERMISSIONS", "END AUTOMATED PERMISSIONS", generatePermissionsTable()) + if err != nil { + return err + } + + // Write back to file + if err := os.WriteFile(docsPath, []byte(updatedContent), 0600); err != nil { + return fmt.Errorf("failed to write permissions docs: %w", err) + } + + return nil +} + +// generatePermissionsTable renders the tool -> fine-grained permission requirement +// table for the default (no-flags) tool inventory. Only tools with a non-zero +// RequiredPermissions are listed; everything else is ungated and always shown. +func generatePermissionsTable() string { + t, _ := translations.TranslationHelper() + + // Mirror generateReadmeDocs: document the default, no-flags experience. + r, _ := github.NewInventory(t). + WithToolsets([]string{"all"}). + WithFeatureChecker(noFeatureFlagsChecker). + Build() + + var buf strings.Builder + buf.WriteString("| Toolset | Tool | Required Permissions (fine-grained) |\n") + buf.WriteString("|---------|------|-------------------------------------|\n") + + // ToolsForRegistration applies the same feature-flag filtering as the + // README so flag-gated duplicates (e.g. list_issues) don't appear twice. + tools := r.ToolsForRegistration(context.Background()) + var rows []string + for _, tool := range tools { + if tool.RequiredPermissions.IsZero() { + continue + } + rows = append(rows, fmt.Sprintf("| `%s` | `%s` | `%s` |", + tool.Toolset.ID, tool.Tool.Name, tool.RequiredPermissions.String())) + } + sort.Strings(rows) + + if len(rows) == 0 { + buf.WriteString("| *(none currently)* | | |") + return buf.String() + } + + buf.WriteString(strings.Join(rows, "\n")) + return buf.String() +} diff --git a/cmd/github-mcp-server/list_permissions.go b/cmd/github-mcp-server/list_permissions.go new file mode 100644 index 0000000000..4f89aaca36 --- /dev/null +++ b/cmd/github-mcp-server/list_permissions.go @@ -0,0 +1,252 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// ToolPermissionInfo contains fine-grained permission information for a single tool. +type ToolPermissionInfo struct { + Name string `json:"name"` + Toolset string `json:"toolset"` + ReadOnly bool `json:"read_only"` + Requirement string `json:"requirement,omitempty"` +} + +// PermissionsOutput is the full output structure for the list-permissions command. +type PermissionsOutput struct { + Tools []ToolPermissionInfo `json:"tools"` + UniquePermissions []string `json:"unique_permissions"` + EnabledToolsets []string `json:"enabled_toolsets"` + ReadOnly bool `json:"read_only"` +} + +var listPermissionsCmd = &cobra.Command{ + Use: "list-permissions", + Short: "List required fine-grained permissions for enabled tools", + Long: `List the required fine-grained permissions for all enabled tools. + +This command creates an inventory based on the same flags as the stdio command +and outputs the declared fine-grained permission requirement for each enabled +tool. Tools with no declared requirement are ungated (always shown) and are +omitted from the per-tool listing. + +The output format can be controlled with the --output flag: + - text (default): Human-readable text output + - json: JSON output for programmatic use + - summary: Just the unique permissions referenced + +Examples: + # List permissions for default toolsets + github-mcp-server list-permissions + + # List permissions for specific toolsets + github-mcp-server list-permissions --toolsets=repos,issues,pull_requests + + # List permissions for all toolsets + github-mcp-server list-permissions --toolsets=all + + # Output as JSON + github-mcp-server list-permissions --output=json + + # Just show unique permissions referenced + github-mcp-server list-permissions --output=summary`, + RunE: func(_ *cobra.Command, _ []string) error { + return runListPermissions() + }, +} + +func init() { + listPermissionsCmd.Flags().StringP("output", "o", "text", "Output format: text, json, or summary") + _ = viper.BindPFlag("list-permissions-output", listPermissionsCmd.Flags().Lookup("output")) + + rootCmd.AddCommand(listPermissionsCmd) +} + +func runListPermissions() error { + // Get toolsets configuration (same logic as stdio command) + var enabledToolsets []string + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + } + // else: enabledToolsets stays nil, meaning "use defaults" + + // Get specific tools (similar to toolsets) + var enabledTools []string + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + } + + readOnly := viper.GetBool("read-only") + outputFormat := viper.GetString("list-permissions-output") + + // Create translation helper + t, _ := translations.TranslationHelper() + + // Build inventory using the same logic as the stdio server + inventoryBuilder := github.NewInventory(t). + WithReadOnly(readOnly) + + if enabledToolsets != nil { + inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets) + } + + if len(enabledTools) > 0 { + inventoryBuilder = inventoryBuilder.WithTools(enabledTools) + } + + inv, err := inventoryBuilder.Build() + if err != nil { + return fmt.Errorf("failed to build inventory: %w", err) + } + + output := collectToolPermissions(inv, readOnly) + + switch outputFormat { + case "json": + return outputPermissionsJSON(output) + case "summary": + return outputPermissionsSummary(output) + default: + return outputPermissionsText(output) + } +} + +func collectToolPermissions(inv *inventory.Inventory, readOnly bool) PermissionsOutput { + var tools []ToolPermissionInfo + permSet := make(map[permissions.Permission]bool) + + // Get all available tools from the inventory; use context.Background() + // for feature flag evaluation. + availableTools := inv.AvailableTools(context.Background()) + + for _, serverTool := range availableTools { + req := serverTool.RequiredPermissions + if req.IsZero() { + continue + } + + tools = append(tools, ToolPermissionInfo{ + Name: serverTool.Tool.Name, + Toolset: string(serverTool.Toolset.ID), + ReadOnly: serverTool.IsReadOnly(), + Requirement: req.String(), + }) + + for _, p := range req.Permissions() { + permSet[p] = true + } + } + + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + + uniquePermissions := make([]string, 0, len(permSet)) + for p := range permSet { + uniquePermissions = append(uniquePermissions, string(p)) + } + sort.Strings(uniquePermissions) + + toolsetIDs := inv.ToolsetIDs() + toolsetIDStrs := make([]string, len(toolsetIDs)) + for i, id := range toolsetIDs { + toolsetIDStrs[i] = string(id) + } + + return PermissionsOutput{ + Tools: tools, + UniquePermissions: uniquePermissions, + EnabledToolsets: toolsetIDStrs, + ReadOnly: readOnly, + } +} + +func outputPermissionsJSON(output PermissionsOutput) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(output) +} + +func outputPermissionsSummary(output PermissionsOutput) error { + if len(output.UniquePermissions) == 0 { + fmt.Println("No fine-grained permissions declared for enabled tools.") + return nil + } + + fmt.Println("Fine-grained permissions referenced by enabled tools:") + fmt.Println() + for _, p := range output.UniquePermissions { + fmt.Printf(" %s\n", p) + } + fmt.Printf("\nTotal: %d unique permission(s)\n", len(output.UniquePermissions)) + return nil +} + +func outputPermissionsText(output PermissionsOutput) error { + fmt.Printf("Fine-Grained Permissions for Enabled Tools\n") + fmt.Printf("==========================================\n\n") + + fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", ")) + fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly) + + if len(output.Tools) == 0 { + fmt.Println("No tools declare a fine-grained permission requirement (all are ungated).") + return nil + } + + // Group tools by toolset + toolsByToolset := make(map[string][]ToolPermissionInfo) + for _, tool := range output.Tools { + toolsByToolset[tool.Toolset] = append(toolsByToolset[tool.Toolset], tool) + } + + var toolsetNames []string + for name := range toolsByToolset { + toolsetNames = append(toolsetNames, name) + } + sort.Strings(toolsetNames) + + for _, toolsetName := range toolsetNames { + tools := toolsByToolset[toolsetName] + fmt.Printf("## %s\n\n", formatToolsetName(toolsetName)) + + for _, tool := range tools { + rwIndicator := "๐Ÿ“" + if tool.ReadOnly { + rwIndicator = "๐Ÿ‘" + } + fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, tool.Requirement) + } + fmt.Println() + } + + // Summary + fmt.Println("## Summary") + fmt.Println() + fmt.Println("Unique permissions referenced:") + for _, p := range output.UniquePermissions { + fmt.Printf(" โ€ข %s\n", p) + } + fmt.Printf("\nTotal: %d gated tools, %d unique permissions\n", len(output.Tools), len(output.UniquePermissions)) + + fmt.Println("\nLegend: ๐Ÿ‘ = read-only, ๐Ÿ“ = read-write") + fmt.Println("Note: tools without a declared requirement are ungated and always shown.") + + return nil +} diff --git a/docs/feature-flags.md b/docs/feature-flags.md index cb02463a10..b4fa5d80bd 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -36,6 +36,7 @@ runtime behavior (such as output formatting) won't appear here. - **create_pull_request** - Open new pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `pull_requests:write` - **MCP App UI**: `ui://github-mcp-server/pr-write` - `base`: Branch to merge into (string, required) - `body`: PR description (string, optional) @@ -52,6 +53,7 @@ runtime behavior (such as output formatting) won't appear here. - **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:write` - **MCP App UI**: `ui://github-mcp-server/issue-write` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) @@ -75,6 +77,7 @@ runtime behavior (such as output formatting) won't appear here. - **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:write` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) @@ -102,6 +105,7 @@ runtime behavior (such as output formatting) won't appear here. - **list_issues** - List issues - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:read` - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) @@ -125,6 +129,7 @@ runtime behavior (such as output formatting) won't appear here. - **create_issue** - Create Issue - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:write` - `body`: Issue body content (optional) (string, optional) - `owner`: Repository owner (username or organization) (string, required) - `repo`: Repository name (string, required) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 2277f0c8e2..edfb1745fc 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -30,6 +30,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - **create_pull_request** - Open new pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `pull_requests:write` - **MCP App UI**: `ui://github-mcp-server/pr-write` - `base`: Branch to merge into (string, required) - `body`: PR description (string, optional) @@ -46,6 +47,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:write` - **MCP App UI**: `ui://github-mcp-server/issue-write` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) @@ -69,6 +71,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:write` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) @@ -96,6 +99,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - **list_issues** - List issues - **Required OAuth Scopes**: `repo` + - **Required Permissions (fine-grained)**: `issues:read` - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) diff --git a/docs/permissions-filtering.md b/docs/permissions-filtering.md new file mode 100644 index 0000000000..89ecf8fe33 --- /dev/null +++ b/docs/permissions-filtering.md @@ -0,0 +1,83 @@ +# Fine-Grained Permission Filtering + +The GitHub MCP Server records the **fine-grained permission** each tool needs. This lets a consumer hide tools that the caller's token (a fine-grained PAT or GitHub App installation token) is not authorized to use โ€” mirroring the way [PAT scope filtering](./scope-filtering.md) works for classic-PAT OAuth scopes. + +> **Note:** This subsystem is **dormant in the OSS server**. The OSS server has no source for a caller's granted permissions, so it never hides tools on this basis. The permission requirements are declarative metadata published here as the public source of truth; a consumer such as the [remote MCP server](./remote-server.md) supplies the granted-permission set to activate filtering. + +## How It Works + +Each tool may declare a `RequiredPermissions` requirement built from the typed permission catalog in [`pkg/permissions`](../pkg/permissions). A requirement is an **OR of AND-sets** of `permission:level` pairs: + +- A single-endpoint tool collapses to one pair, e.g. `issues:write`. +- A tool that calls multiple endpoints **ANDs** their requirements together. +- Where an endpoint accepts one of several permissions, those alternatives are **ORed**. + +Permission levels form an ordered lattice: `read < write < admin`. A grant of `write` therefore satisfies a `read` requirement. + +A tool with **no** declared requirement (the zero value) is **ungated** and always shown. + +## Permission Catalog + +The catalog of permission names, their resource scope (repository, organization, or account), and their valid levels is **generated** from the public [`github/rest-api-description`](https://github.com/github/rest-api-description) `app-permissions` schema. It is regenerated with: + +```bash +go generate ./pkg/permissions +``` + +This is the only source consulted โ€” the catalog contains exclusively public data that also appears in the REST API documentation and the `X-Accepted-GitHub-Permissions` response header. Enterprise permissions are excluded, since they are not relevant to repository/organization MCP tooling. + +## Token Types + +| Token type | Permission handling | +|------------|---------------------| +| **Fine-grained PAT** (`github_pat_`) | A consumer can hide tools whose required permission the token lacks; the GitHub API still enforces permissions at call time | +| **GitHub App** (`ghs_`) | Same as fine-grained PAT โ€” permissions come from the app installation | +| **Classic PAT** (`ghp_`) | Uses OAuth [scope filtering](./scope-filtering.md) instead | + +## Inspecting Tool Requirements + +List the per-tool fine-grained permission requirements with the CLI: + +```bash +script/list-permissions # human-readable text +script/list-permissions --format json +script/list-permissions --format summary +``` + +The generated table below is produced by `script/generate-docs` and lists every tool that currently declares a requirement. + +## Tool Permission Requirements + + +| Toolset | Tool | Required Permissions (fine-grained) | +|---------|------|-------------------------------------| +| `actions` | `actions_get` | `actions:read` | +| `actions` | `actions_list` | `actions:read` | +| `actions` | `actions_run_trigger` | `actions:write` | +| `actions` | `get_job_logs` | `actions:read` | +| `code_security` | `get_code_scanning_alert` | `security_events:read` | +| `code_security` | `list_code_scanning_alerts` | `security_events:read` | +| `dependabot` | `get_dependabot_alert` | `vulnerability_alerts:read` | +| `dependabot` | `list_dependabot_alerts` | `vulnerability_alerts:read` | +| `issues` | `issue_read` | `issues:read` | +| `issues` | `issue_write` | `issues:write` | +| `issues` | `list_issues` | `issues:read` | +| `pull_requests` | `create_pull_request` | `pull_requests:write` | +| `pull_requests` | `list_pull_requests` | `pull_requests:read` | +| `pull_requests` | `pull_request_read` | `pull_requests:read` | +| `repos` | `create_branch` | `contents:write` | +| `repos` | `create_or_update_file` | `contents:write` | +| `repos` | `get_file_contents` | `contents:read` | +| `repos` | `list_branches` | `contents:read` | +| `repos` | `list_commits` | `contents:read` | +| `repos` | `list_tags` | `contents:read` | +| `repos` | `push_files` | `contents:write` | +| `secret_protection` | `get_secret_scanning_alert` | `secret_scanning_alerts:read` | +| `secret_protection` | `list_secret_scanning_alerts` | `secret_scanning_alerts:read` | + + +## Related Documentation + +- [PAT Scope Filtering](./scope-filtering.md) +- [Server Configuration Guide](./server-configuration.md) +- [GitHub fine-grained PAT permissions](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 2342664c3a..ceb288224b 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -458,6 +458,12 @@ This happens transparentlyโ€”no configuration needed. If scope detection fails f See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. +### Fine-Grained Permission Requirements + +Each tool also declares the **fine-grained permission** it needs (e.g. `issues:write`). This metadata is published as the public source of truth and powers tool filtering for fine-grained PATs and GitHub App tokens in consumers such as the remote server. It is dormant in the OSS server, which has no granted-permission source. + +See [Fine-Grained Permission Filtering](./permissions-filtering.md) for details. + --- ## Troubleshooting diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 9dac877736..ccce5bfd71 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -14,6 +14,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" @@ -400,7 +401,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, - ) + ).WithPermissions(permissions.Require(permissions.Actions.Read())) return tool } @@ -522,7 +523,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, - ) + ).WithPermissions(permissions.Require(permissions.Actions.Read())) return tool } @@ -639,7 +640,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, - ) + ).WithPermissions(permissions.Require(permissions.Actions.Write())) return tool } @@ -766,7 +767,7 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Actions.Read())) return tool } diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index fb8b7a79c8..bd0b303866 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" @@ -96,7 +97,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.SecurityEvents.Read())) } func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -221,5 +222,5 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.SecurityEvents.Read())) } diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 1ac6b1b44c..cb52c496b0 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -10,6 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" @@ -97,7 +98,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.VulnerabilityAlerts.Read())) } func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -210,7 +211,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.VulnerabilityAlerts.Read())) } // dependabotErrMsg enhances error messages for dependabot API failures by diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 27fc0a4abe..5518372ede 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -14,6 +14,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -822,7 +823,7 @@ Options are: default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }) + }).WithPermissions(permissions.Require(permissions.Issues.Read())) } func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { @@ -2050,7 +2051,7 @@ Options are: default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil } - }) + }).WithPermissions(permissions.Require(permissions.Issues.Write())) st.FeatureFlagEnable = FeatureFlagIssueFields st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular} return st @@ -2274,7 +2275,7 @@ Options are: default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil } - }) + }).WithPermissions(permissions.Require(permissions.Issues.Write())) st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular, FeatureFlagIssueFields} return st } @@ -2760,7 +2761,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { result := MarshalledTextResult(resp) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) return result, nil, nil - }) + }).WithPermissions(permissions.Require(permissions.Issues.Read())) st.FeatureFlagEnable = FeatureFlagIssueFields return st } @@ -2958,7 +2959,7 @@ func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool result := MarshalledTextResult(resp) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) return result, nil, nil - }) + }).WithPermissions(permissions.Require(permissions.Issues.Read())) st.FeatureFlagDisable = []string{FeatureFlagIssueFields} return st } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 3ddfd682f6..a505de8b54 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -10,6 +10,7 @@ import ( ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" @@ -187,7 +188,7 @@ func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerT } return utils.NewToolResultText(string(r)), nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Issues.Write())) st.FeatureFlagEnable = FeatureFlagIssuesGranular return st } diff --git a/pkg/github/permission_filter.go b/pkg/github/permission_filter.go new file mode 100644 index 0000000000..3eeeb1b31b --- /dev/null +++ b/pkg/github/permission_filter.go @@ -0,0 +1,57 @@ +package github + +import ( + "context" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" +) + +// GetToolPermissionMapFromInventory builds a tool permission map from an +// inventory, extracting each tool's RequiredPermissions. Tools with a +// zero-value (empty) requirement are omitted, since an empty requirement means +// "no gate". This mirrors scopes.GetToolScopeMapFromInventory. +func GetToolPermissionMapFromInventory(inv *inventory.Inventory) permissions.ToolPermissionMap { + result := make(permissions.ToolPermissionMap) + allTools := inv.AllTools() + for i := range allTools { + tool := &allTools[i] + if !tool.RequiredPermissions.IsZero() { + result[tool.Tool.Name] = tool.RequiredPermissions + } + } + return result +} + +// SetToolPermissionMapFromInventory builds and stores the global tool +// permission map from an inventory, so middleware and other consumers can look +// up requirements. Mirrors scopes.SetToolScopeMapFromInventory. +func SetToolPermissionMapFromInventory(inv *inventory.Inventory) { + permissions.SetGlobalToolPermissionMap(GetToolPermissionMapFromInventory(inv)) +} + +// UnionPermissions returns the sorted, de-duplicated set of fine-grained +// permissions referenced by every tool in the inventory. +func UnionPermissions(inv *inventory.Inventory) []permissions.Permission { + return GetToolPermissionMapFromInventory(inv).UniquePermissions() +} + +// CreateToolPermissionFilter creates an inventory.ToolFilter that hides tools +// whose fine-grained permission requirement is not satisfied by the granted +// permissions. +// +// The filter FAILS OPEN: if granted is nil (no permission source available, as +// is the case in the OSS server today) the filter includes every tool. Tools +// with a zero-value requirement are always included. This means the OSS server +// ships this subsystem dormant โ€” there is no granted-permission source in OSS, +// so no tools are hidden. A consumer such as the remote server supplies the +// granted map to activate filtering. +func CreateToolPermissionFilter(granted map[permissions.Permission]permissions.Level) inventory.ToolFilter { + return func(_ context.Context, tool *inventory.ServerTool) (bool, error) { + // Fail open when there is no granted-permission source. + if granted == nil { + return true, nil + } + return tool.RequiredPermissions.SatisfiedBy(granted), nil + } +} diff --git a/pkg/github/permission_filter_test.go b/pkg/github/permission_filter_test.go new file mode 100644 index 0000000000..6904a95e42 --- /dev/null +++ b/pkg/github/permission_filter_test.go @@ -0,0 +1,75 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateToolPermissionFilter(t *testing.T) { + toolNoReq := &inventory.ServerTool{Tool: mcp.Tool{Name: "ungated"}} + toolIssuesWrite := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "create_issue"}, + RequiredPermissions: permissions.Require(permissions.Issues.Write()), + } + + t.Run("fails open when granted is nil", func(t *testing.T) { + filter := CreateToolPermissionFilter(nil) + for _, tool := range []*inventory.ServerTool{toolNoReq, toolIssuesWrite} { + ok, err := filter(context.Background(), tool) + require.NoError(t, err) + assert.True(t, ok, "nil granted must include every tool") + } + }) + + t.Run("ungated tools always included", func(t *testing.T) { + filter := CreateToolPermissionFilter(map[permissions.Permission]permissions.Level{}) + ok, err := filter(context.Background(), toolNoReq) + require.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("gated tool hidden without sufficient grant", func(t *testing.T) { + filter := CreateToolPermissionFilter(map[permissions.Permission]permissions.Level{ + permissions.Issues: permissions.LevelRead, + }) + ok, err := filter(context.Background(), toolIssuesWrite) + require.NoError(t, err) + assert.False(t, ok, "issues:read must not satisfy issues:write") + }) + + t.Run("gated tool shown with sufficient grant", func(t *testing.T) { + filter := CreateToolPermissionFilter(map[permissions.Permission]permissions.Level{ + permissions.Issues: permissions.LevelWrite, + }) + ok, err := filter(context.Background(), toolIssuesWrite) + require.NoError(t, err) + assert.True(t, ok) + }) +} + +func TestGetToolPermissionMapFromInventory(t *testing.T) { + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{ + {Tool: mcp.Tool{Name: "ungated"}}, + { + Tool: mcp.Tool{Name: "create_issue"}, + RequiredPermissions: permissions.Require(permissions.Issues.Write()), + }, + }).Build() + require.NoError(t, err) + + m := GetToolPermissionMapFromInventory(inv) + _, hasUngated := m["ungated"] + assert.False(t, hasUngated, "tools with zero requirement are omitted") + req, hasGated := m["create_issue"] + require.True(t, hasGated) + assert.Equal(t, "issues:write", req.String()) + + union := UnionPermissions(inv) + assert.Equal(t, []permissions.Permission{permissions.Issues}, union) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index ae7d04331d..7461602418 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -17,6 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -157,7 +158,7 @@ Possible options: default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }) + }).WithPermissions(permissions.Require(permissions.PullRequests.Read())) } func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { @@ -781,7 +782,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo } return utils.NewToolResultText(string(r)), nil, nil - }) + }).WithPermissions(permissions.Require(permissions.PullRequests.Write())) } // UpdatePullRequest creates a tool to update an existing pull request. @@ -1324,7 +1325,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool // confidentiality follows repo visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelListIssues) return result, nil, nil - }) + }).WithPermissions(permissions.Require(permissions.PullRequests.Read())) } // MergePullRequest creates a tool to merge a pull request. diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 60bb45c44f..e4e795bdfa 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -15,6 +15,7 @@ import ( "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" @@ -281,7 +282,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Contents.Read())) } // ListBranches creates a tool to list branches in a GitHub repository. @@ -373,7 +374,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Contents.Read())) } // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. @@ -569,7 +570,7 @@ SHA MUST be provided for existing file updates. return MarshalledTextResult(minimalResponse), nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Contents.Write())) } // CreateRepository creates a tool to create a new GitHub repository. @@ -891,7 +892,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError("failed to get file contents"), nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Contents.Read())) } // ForkRepository creates a tool to fork a repository. @@ -1288,7 +1289,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Contents.Write())) } // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. @@ -1523,7 +1524,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Contents.Write())) } // ListTags creates a tool to list tags in a GitHub repository. @@ -1614,7 +1615,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.Contents.Read())) } // GetTag creates a tool to get details about a specific tag in a GitHub repository. diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 18cfe73771..94dcad7a04 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -10,6 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" @@ -97,7 +98,7 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.SecretScanningAlerts.Read())) } func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -212,5 +213,5 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) return result, nil, nil }, - ) + ).WithPermissions(permissions.Require(permissions.SecretScanningAlerts.Read())) } diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 326009b59f..1e3693cf30 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/pkg/permissions" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -83,6 +84,20 @@ type ServerTool struct { // This includes the required scopes plus any higher-level scopes that provide // the necessary permissions due to scope hierarchy. AcceptedScopes []string + + // RequiredPermissions specifies the fine-grained permissions this tool needs. + // The zero value is an empty requirement, meaning the tool has no fine-grained + // permission gate and is always shown. Set it with WithPermissions at the tool + // definition site, mirroring how RequiredScopes is set. + RequiredPermissions permissions.Requirement +} + +// WithPermissions returns a copy of the tool with its fine-grained permission +// requirement set. It is chainable so it can be applied at the tool definition +// site, e.g. NewTool(...).WithPermissions(permissions.Require(permissions.Issues.Write())). +func (st ServerTool) WithPermissions(r permissions.Requirement) ServerTool { + st.RequiredPermissions = r + return st } // IsReadOnly returns true if this tool is marked as read-only via annotations. diff --git a/pkg/permissions/catalog.go b/pkg/permissions/catalog.go new file mode 100644 index 0000000000..1970deb617 --- /dev/null +++ b/pkg/permissions/catalog.go @@ -0,0 +1,74 @@ +package permissions + +import "slices" + +// CatalogEntry describes a single fine-grained permission as published in the +// public OpenAPI app-permissions schema: its scope and the access levels that +// the permission accepts. +type CatalogEntry struct { + // Permission is the typed permission name. + Permission Permission + // Scope is where the permission is granted (repo, org, or account). + Scope Scope + // Levels are the valid access levels for this permission, ascending. + Levels []Level +} + +// Lookup returns the catalog entry for a permission, if known. +func Lookup(p Permission) (CatalogEntry, bool) { + e, ok := Catalog[p] + return e, ok +} + +// ScopeOf returns the scope of a permission, defaulting to ScopeRepo for +// permissions not present in the catalog. +func ScopeOf(p Permission) Scope { + if e, ok := Catalog[p]; ok { + return e.Scope + } + return ScopeRepo +} + +// MaxLevel returns the highest level a permission can be granted at, or +// LevelNone if the permission is unknown. +func MaxLevel(p Permission) Level { + e, ok := Catalog[p] + if !ok || len(e.Levels) == 0 { + return LevelNone + } + highest := LevelNone + for _, l := range e.Levels { + if l > highest { + highest = l + } + } + return highest +} + +// IsValidLevel reports whether the permission accepts the given level. +func IsValidLevel(p Permission, l Level) bool { + e, ok := Catalog[p] + if !ok { + return false + } + return slices.Contains(e.Levels, l) +} + +// AllPermissions returns every permission in the catalog, sorted by name. +func AllPermissions() []Permission { + out := make([]Permission, 0, len(Catalog)) + for p := range Catalog { + out = append(out, p) + } + sortPermissions(out) + return out +} + +func sortPermissions(ps []Permission) { + // insertion sort keeps this dependency-free and the slice is small + for i := 1; i < len(ps); i++ { + for j := i; j > 0 && ps[j] < ps[j-1]; j-- { + ps[j], ps[j-1] = ps[j-1], ps[j] + } + } +} diff --git a/pkg/permissions/catalog_generated.go b/pkg/permissions/catalog_generated.go new file mode 100644 index 0000000000..be7bc06e91 --- /dev/null +++ b/pkg/permissions/catalog_generated.go @@ -0,0 +1,125 @@ +// Code generated by gen.go; DO NOT EDIT. +// +// Source: https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json +// Generated from the PUBLIC components/schemas/app-permissions schema of the +// github/rest-api-description OpenAPI description. Regenerate with: +// +// go generate ./pkg/permissions + +package permissions + +// Typed permission constants, one per public fine-grained permission. +const ( + Actions Permission = "actions" + Administration Permission = "administration" + ArtifactMetadata Permission = "artifact_metadata" + Attestations Permission = "attestations" + Checks Permission = "checks" + CodeQuality Permission = "code_quality" + Codespaces Permission = "codespaces" + Contents Permission = "contents" + CustomPropertiesForOrganizations Permission = "custom_properties_for_organizations" + DependabotSecrets Permission = "dependabot_secrets" + Deployments Permission = "deployments" + Discussions Permission = "discussions" + EmailAddresses Permission = "email_addresses" + Environments Permission = "environments" + Followers Permission = "followers" + GitSSHKeys Permission = "git_ssh_keys" + GPGKeys Permission = "gpg_keys" + InteractionLimits Permission = "interaction_limits" + Issues Permission = "issues" + Members Permission = "members" + MergeQueues Permission = "merge_queues" + Metadata Permission = "metadata" + OrganizationAdministration Permission = "organization_administration" + OrganizationAnnouncementBanners Permission = "organization_announcement_banners" + OrganizationCopilotAgentSettings Permission = "organization_copilot_agent_settings" + OrganizationCopilotSeatManagement Permission = "organization_copilot_seat_management" + OrganizationCustomOrgRoles Permission = "organization_custom_org_roles" + OrganizationCustomProperties Permission = "organization_custom_properties" + OrganizationCustomRoles Permission = "organization_custom_roles" + OrganizationEvents Permission = "organization_events" + OrganizationHooks Permission = "organization_hooks" + OrganizationPackages Permission = "organization_packages" + OrganizationPersonalAccessTokenRequests Permission = "organization_personal_access_token_requests" + OrganizationPersonalAccessTokens Permission = "organization_personal_access_tokens" + OrganizationPlan Permission = "organization_plan" + OrganizationProjects Permission = "organization_projects" + OrganizationSecrets Permission = "organization_secrets" + OrganizationSelfHostedRunners Permission = "organization_self_hosted_runners" + OrganizationUserBlocking Permission = "organization_user_blocking" + Packages Permission = "packages" + Pages Permission = "pages" + Profile Permission = "profile" + PullRequests Permission = "pull_requests" + RepositoryCustomProperties Permission = "repository_custom_properties" + RepositoryHooks Permission = "repository_hooks" + RepositoryProjects Permission = "repository_projects" + SecretScanningAlerts Permission = "secret_scanning_alerts" + Secrets Permission = "secrets" + SecurityEvents Permission = "security_events" + SingleFile Permission = "single_file" + Starring Permission = "starring" + Statuses Permission = "statuses" + VulnerabilityAlerts Permission = "vulnerability_alerts" + Workflows Permission = "workflows" +) + +// Catalog maps each known permission to its scope and valid access levels. +var Catalog = map[Permission]CatalogEntry{ + Actions: {Permission: Actions, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Administration: {Permission: Administration, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + ArtifactMetadata: {Permission: ArtifactMetadata, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Attestations: {Permission: Attestations, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Checks: {Permission: Checks, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + CodeQuality: {Permission: CodeQuality, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Codespaces: {Permission: Codespaces, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Contents: {Permission: Contents, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + CustomPropertiesForOrganizations: {Permission: CustomPropertiesForOrganizations, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + DependabotSecrets: {Permission: DependabotSecrets, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Deployments: {Permission: Deployments, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Discussions: {Permission: Discussions, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + EmailAddresses: {Permission: EmailAddresses, Scope: ScopeAccount, Levels: []Level{LevelRead, LevelWrite}}, + Environments: {Permission: Environments, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Followers: {Permission: Followers, Scope: ScopeAccount, Levels: []Level{LevelRead, LevelWrite}}, + GitSSHKeys: {Permission: GitSSHKeys, Scope: ScopeAccount, Levels: []Level{LevelRead, LevelWrite}}, + GPGKeys: {Permission: GPGKeys, Scope: ScopeAccount, Levels: []Level{LevelRead, LevelWrite}}, + InteractionLimits: {Permission: InteractionLimits, Scope: ScopeAccount, Levels: []Level{LevelRead, LevelWrite}}, + Issues: {Permission: Issues, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Members: {Permission: Members, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + MergeQueues: {Permission: MergeQueues, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Metadata: {Permission: Metadata, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationAdministration: {Permission: OrganizationAdministration, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationAnnouncementBanners: {Permission: OrganizationAnnouncementBanners, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationCopilotAgentSettings: {Permission: OrganizationCopilotAgentSettings, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationCopilotSeatManagement: {Permission: OrganizationCopilotSeatManagement, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationCustomOrgRoles: {Permission: OrganizationCustomOrgRoles, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationCustomProperties: {Permission: OrganizationCustomProperties, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite, LevelAdmin}}, + OrganizationCustomRoles: {Permission: OrganizationCustomRoles, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationEvents: {Permission: OrganizationEvents, Scope: ScopeOrg, Levels: []Level{LevelRead}}, + OrganizationHooks: {Permission: OrganizationHooks, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationPackages: {Permission: OrganizationPackages, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationPersonalAccessTokenRequests: {Permission: OrganizationPersonalAccessTokenRequests, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationPersonalAccessTokens: {Permission: OrganizationPersonalAccessTokens, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationPlan: {Permission: OrganizationPlan, Scope: ScopeOrg, Levels: []Level{LevelRead}}, + OrganizationProjects: {Permission: OrganizationProjects, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite, LevelAdmin}}, + OrganizationSecrets: {Permission: OrganizationSecrets, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationSelfHostedRunners: {Permission: OrganizationSelfHostedRunners, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + OrganizationUserBlocking: {Permission: OrganizationUserBlocking, Scope: ScopeOrg, Levels: []Level{LevelRead, LevelWrite}}, + Packages: {Permission: Packages, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Pages: {Permission: Pages, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Profile: {Permission: Profile, Scope: ScopeAccount, Levels: []Level{LevelWrite}}, + PullRequests: {Permission: PullRequests, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + RepositoryCustomProperties: {Permission: RepositoryCustomProperties, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + RepositoryHooks: {Permission: RepositoryHooks, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + RepositoryProjects: {Permission: RepositoryProjects, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite, LevelAdmin}}, + SecretScanningAlerts: {Permission: SecretScanningAlerts, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Secrets: {Permission: Secrets, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + SecurityEvents: {Permission: SecurityEvents, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + SingleFile: {Permission: SingleFile, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Starring: {Permission: Starring, Scope: ScopeAccount, Levels: []Level{LevelRead, LevelWrite}}, + Statuses: {Permission: Statuses, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + VulnerabilityAlerts: {Permission: VulnerabilityAlerts, Scope: ScopeRepo, Levels: []Level{LevelRead, LevelWrite}}, + Workflows: {Permission: Workflows, Scope: ScopeRepo, Levels: []Level{LevelWrite}}, +} diff --git a/pkg/permissions/catalog_test.go b/pkg/permissions/catalog_test.go new file mode 100644 index 0000000000..057942f119 --- /dev/null +++ b/pkg/permissions/catalog_test.go @@ -0,0 +1,88 @@ +package permissions + +import "testing" + +func TestCatalogGenerated(t *testing.T) { + // A representative sample of permissions seeded by tools must exist with + // the expected scope and levels. + checks := []struct { + perm Permission + scope Scope + levels []Level + }{ + {Issues, ScopeRepo, []Level{LevelRead, LevelWrite}}, + {Contents, ScopeRepo, []Level{LevelRead, LevelWrite}}, + {PullRequests, ScopeRepo, []Level{LevelRead, LevelWrite}}, + {Actions, ScopeRepo, []Level{LevelRead, LevelWrite}}, + {SecurityEvents, ScopeRepo, []Level{LevelRead, LevelWrite}}, + {SecretScanningAlerts, ScopeRepo, []Level{LevelRead, LevelWrite}}, + {VulnerabilityAlerts, ScopeRepo, []Level{LevelRead, LevelWrite}}, + {OrganizationProjects, ScopeOrg, []Level{LevelRead, LevelWrite, LevelAdmin}}, + {Profile, ScopeAccount, []Level{LevelWrite}}, + } + for _, c := range checks { + e, ok := Lookup(c.perm) + if !ok { + t.Errorf("permission %q missing from catalog", c.perm) + continue + } + if e.Scope != c.scope { + t.Errorf("%q scope = %v, want %v", c.perm, e.Scope, c.scope) + } + if len(e.Levels) != len(c.levels) { + t.Errorf("%q levels = %v, want %v", c.perm, e.Levels, c.levels) + } + } +} + +func TestCatalogExcludesEnterprise(t *testing.T) { + for p := range Catalog { + if len(p) >= len("enterprise_") && p[:len("enterprise_")] == "enterprise_" { + t.Errorf("enterprise permission %q should be excluded", p) + } + } +} + +func TestMaxLevelAndValidLevel(t *testing.T) { + if MaxLevel(OrganizationProjects) != LevelAdmin { + t.Errorf("MaxLevel(OrganizationProjects) = %v, want admin", MaxLevel(OrganizationProjects)) + } + if MaxLevel("does_not_exist") != LevelNone { + t.Error("unknown permission should have MaxLevel none") + } + if !IsValidLevel(Issues, LevelWrite) { + t.Error("issues:write should be valid") + } + if IsValidLevel(Issues, LevelAdmin) { + t.Error("issues:admin should be invalid") + } +} + +func TestToolPermissionMapUniquePermissions(t *testing.T) { + m := ToolPermissionMap{ + "a": Require(Issues.Write()), + "b": Require(Issues.Read()), + "c": AllOf(Contents.Read(), PullRequests.Read()), + } + got := m.UniquePermissions() + want := []Permission{Contents, Issues, PullRequests} + if len(got) != len(want) { + t.Fatalf("UniquePermissions() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("UniquePermissions()[%d] = %v, want %v", i, got[i], want[i]) + } + } +} + +func TestGlobalToolPermissionMap(t *testing.T) { + t.Cleanup(func() { SetGlobalToolPermissionMap(nil) }) + SetGlobalToolPermissionMap(ToolPermissionMap{"x": Require(Issues.Write())}) + if got := GetToolRequirement("x"); got.String() != "issues:write" { + t.Errorf("GetToolRequirement(x) = %q", got.String()) + } + if !GetToolRequirement("unknown").IsZero() { + t.Error("unknown tool should return zero requirement") + } +} diff --git a/pkg/permissions/gen.go b/pkg/permissions/gen.go new file mode 100644 index 0000000000..4ac5aac96d --- /dev/null +++ b/pkg/permissions/gen.go @@ -0,0 +1,230 @@ +//go:build ignore + +// Command gen generates catalog_generated.go: the typed catalog of GitHub +// fine-grained permissions (one Permission constant per permission, plus a +// Catalog map giving each permission's scope and valid levels). +// +// It reads ONLY the PUBLIC github/rest-api-description OpenAPI description and, +// specifically, the components/schemas/app-permissions schema, which is the +// authoritative public enumeration of fine-grained permission names and the +// access levels (read/write/admin) each one accepts. No internal sources are +// read or referenced. +// +// Per-endpoint / per-tool permission requirements are NOT derived here; those +// are hand-authored at each tool definition site (mirroring RequiredScopes). +// +// Usage: +// +// go generate ./pkg/permissions +// go run gen.go # fetch the public spec over HTTP +// go run gen.go -spec path/to/spec.json # use a local copy of the public spec +package main + +import ( + "encoding/json" + "flag" + "fmt" + "go/format" + "io" + "net/http" + "os" + "sort" + "strings" + "time" +) + +// defaultSpecURL is the public, bundled GitHub REST API OpenAPI description. +const defaultSpecURL = "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json" + +// orgPermissions are organization-scoped permissions whose names do not carry +// the organization_ prefix. +var orgPermissions = map[string]bool{ + "members": true, + "custom_properties_for_organizations": true, +} + +// accountPermissions are user-account-scoped permissions (bare names that apply +// to the authenticated user rather than a repository). +var accountPermissions = map[string]bool{ + "email_addresses": true, + "followers": true, + "git_ssh_keys": true, + "gpg_keys": true, + "interaction_limits": true, + "profile": true, + "starring": true, +} + +// acronyms maps lowercase tokens to their canonical capitalization, following +// the repository's Go acronym conventions. +var acronyms = map[string]string{ + "ssh": "SSH", + "gpg": "GPG", + "id": "ID", + "api": "API", + "url": "URL", +} + +type openAPISpec struct { + Components struct { + Schemas struct { + AppPermissions struct { + Properties map[string]struct { + Enum []string `json:"enum"` + } `json:"properties"` + } `json:"app-permissions"` + } `json:"schemas"` + } `json:"components"` +} + +type entry struct { + name string + cons string + scope string // "ScopeRepo" | "ScopeOrg" | "ScopeAccount" + levels []string +} + +func main() { + specPath := flag.String("spec", "", "path to a local copy of the public api.github.com.json (defaults to fetching over HTTP)") + out := flag.String("out", "catalog_generated.go", "output file path") + flag.Parse() + + raw, source, err := loadSpec(*specPath) + if err != nil { + fmt.Fprintf(os.Stderr, "gen: %v\n", err) + os.Exit(1) + } + + var spec openAPISpec + if err := json.Unmarshal(raw, &spec); err != nil { + fmt.Fprintf(os.Stderr, "gen: parse spec: %v\n", err) + os.Exit(1) + } + + props := spec.Components.Schemas.AppPermissions.Properties + if len(props) == 0 { + fmt.Fprintln(os.Stderr, "gen: no app-permissions properties found in spec") + os.Exit(1) + } + + var entries []entry + for name, prop := range props { + // Exclude enterprise permissions: they are not repo/org MCP tooling. + if strings.HasPrefix(name, "enterprise_") { + continue + } + levels := make([]string, 0, len(prop.Enum)) + for _, lvl := range prop.Enum { + switch lvl { + case "read": + levels = append(levels, "LevelRead") + case "write": + levels = append(levels, "LevelWrite") + case "admin": + levels = append(levels, "LevelAdmin") + } + } + entries = append(entries, entry{ + name: name, + cons: constName(name), + scope: scopeFor(name), + levels: levels, + }) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name }) + + src := render(source, entries) + formatted, err := format.Source([]byte(src)) + if err != nil { + fmt.Fprintf(os.Stderr, "gen: gofmt: %v\n%s", err, src) + os.Exit(1) + } + if err := os.WriteFile(*out, formatted, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "gen: write %s: %v\n", *out, err) + os.Exit(1) + } + fmt.Printf("wrote %s (%d permissions) from %s\n", *out, len(entries), source) +} + +func loadSpec(path string) (data []byte, source string, err error) { + if path != "" { + b, err := os.ReadFile(path) //#nosec G304 -- developer-supplied path for code generation + if err != nil { + return nil, "", fmt.Errorf("read spec %s: %w", path, err) + } + return b, path, nil + } + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(defaultSpecURL) + if err != nil { + return nil, "", fmt.Errorf("fetch spec: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("fetch spec: unexpected status %d", resp.StatusCode) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("read spec body: %w", err) + } + return b, defaultSpecURL, nil +} + +func scopeFor(name string) string { + switch { + case strings.HasPrefix(name, "organization_"), orgPermissions[name]: + return "ScopeOrg" + case accountPermissions[name]: + return "ScopeAccount" + default: + return "ScopeRepo" + } +} + +// constName converts a snake_case permission name into an exported Go +// identifier, applying the repository's acronym conventions. +func constName(name string) string { + parts := strings.Split(name, "_") + var b strings.Builder + for _, p := range parts { + if p == "" { + continue + } + if up, ok := acronyms[p]; ok { + b.WriteString(up) + continue + } + b.WriteString(strings.ToUpper(p[:1])) + b.WriteString(p[1:]) + } + return b.String() +} + +func render(source string, entries []entry) string { + var b strings.Builder + b.WriteString("// Code generated by gen.go; DO NOT EDIT.\n") + b.WriteString("//\n") + b.WriteString("// Source: " + source + "\n") + b.WriteString("// Generated from the PUBLIC components/schemas/app-permissions schema of the\n") + b.WriteString("// github/rest-api-description OpenAPI description. Regenerate with:\n") + b.WriteString("//\n") + b.WriteString("//\tgo generate ./pkg/permissions\n") + b.WriteString("\n") + b.WriteString("package permissions\n\n") + + b.WriteString("// Typed permission constants, one per public fine-grained permission.\n") + b.WriteString("const (\n") + for _, e := range entries { + fmt.Fprintf(&b, "\t%s Permission = %q\n", e.cons, e.name) + } + b.WriteString(")\n\n") + + b.WriteString("// Catalog maps each known permission to its scope and valid access levels.\n") + b.WriteString("var Catalog = map[Permission]CatalogEntry{\n") + for _, e := range entries { + fmt.Fprintf(&b, "\t%s: {Permission: %s, Scope: %s, Levels: []Level{%s}},\n", + e.cons, e.cons, e.scope, strings.Join(e.levels, ", ")) + } + b.WriteString("}\n") + return b.String() +} diff --git a/pkg/permissions/map.go b/pkg/permissions/map.go new file mode 100644 index 0000000000..6b43752da2 --- /dev/null +++ b/pkg/permissions/map.go @@ -0,0 +1,51 @@ +package permissions + +import "slices" + +// ToolPermissionMap maps tool names to their fine-grained permission +// requirement. It mirrors scopes.ToolScopeMap. Tools with a zero-value +// (empty) requirement are omitted, since an empty requirement means "no gate". +type ToolPermissionMap map[string]Requirement + +// globalToolPermissionMap is populated from the inventory by the github package +// (github.SetToolPermissionMapFromInventory) so that middleware and other +// consumers can look up requirements without re-deriving them. +var globalToolPermissionMap ToolPermissionMap + +// SetGlobalToolPermissionMap stores the global tool permission map. +func SetGlobalToolPermissionMap(m ToolPermissionMap) { + globalToolPermissionMap = m +} + +// GetToolPermissionMap returns the global tool permission map, or an empty map +// if it has not been populated yet. +func GetToolPermissionMap() ToolPermissionMap { + if globalToolPermissionMap == nil { + return make(ToolPermissionMap) + } + return globalToolPermissionMap +} + +// GetToolRequirement returns the requirement for a single tool from the global +// map. The zero-value Requirement (no gate) is returned when the tool is +// unknown or ungated. +func GetToolRequirement(toolName string) Requirement { + return GetToolPermissionMap()[toolName] +} + +// UniquePermissions returns the sorted, de-duplicated set of permissions used +// across all tools in the map. +func (m ToolPermissionMap) UniquePermissions() []Permission { + seen := make(map[Permission]struct{}) + for _, req := range m { + for _, p := range req.Permissions() { + seen[p] = struct{}{} + } + } + out := make([]Permission, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + slices.Sort(out) + return out +} diff --git a/pkg/permissions/permissions.go b/pkg/permissions/permissions.go new file mode 100644 index 0000000000..5d6dc44e32 --- /dev/null +++ b/pkg/permissions/permissions.go @@ -0,0 +1,308 @@ +// Package permissions provides a declarative, typed model for the fine-grained +// permissions (FGP) that GitHub fine-grained tokens carry. It mirrors the +// pkg/scopes subsystem: pkg/scopes models classic OAuth scopes, while this +// package models the fine-grained permission requirements of MCP tools. +// +// The typed permission catalog (one Permission constant per public permission, +// its scope, and its valid levels) is generated from the PUBLIC +// github/rest-api-description OpenAPI description (the components/schemas/ +// app-permissions schema). See gen.go. Per-tool requirements are hand-authored +// at the tool definition site, exactly like RequiredScopes. +// +// This package contains ONLY public data: permission names and levels are +// published in the REST API documentation and in the public OpenAPI schema. +package permissions + +//go:generate go run gen.go + +import ( + "slices" + "sort" + "strings" +) + +// Permission is a typed GitHub fine-grained permission name, e.g. "issues" or +// "organization_administration". The set of valid values is enumerated in +// catalog_generated.go. +type Permission string + +// Level is an access level for a permission. Levels form an ordered lattice +// (read < write < admin); a higher level satisfies a requirement for a lower +// level (holding "write" satisfies a "read" requirement). +type Level int + +const ( + // LevelNone means no access (the zero value). + LevelNone Level = iota + // LevelRead grants read access. + LevelRead + // LevelWrite grants write access (implies read). + LevelWrite + // LevelAdmin grants admin access (implies write and read). + LevelAdmin +) + +// String returns the lowercase API name of the level (read/write/admin), or +// "none" for LevelNone. +func (l Level) String() string { + switch l { + case LevelRead: + return "read" + case LevelWrite: + return "write" + case LevelAdmin: + return "admin" + default: + return "none" + } +} + +// ParseLevel converts a level name from the public schema (read/write/admin) +// into a Level. Unknown names map to LevelNone. +func ParseLevel(s string) Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "read": + return LevelRead + case "write": + return LevelWrite + case "admin": + return LevelAdmin + default: + return LevelNone + } +} + +// Scope describes where a permission is granted. It is part of the typed +// permission value: bare permission names are granted at the repository or +// account level, while organization_* permissions are granted at the +// organization level. +type Scope int + +const ( + // ScopeRepo is a repository-level permission (the common case). + ScopeRepo Scope = iota + // ScopeOrg is an organization-level permission. + ScopeOrg + // ScopeAccount is a user-account-level permission. + ScopeAccount +) + +// String returns a human-readable name for the scope. +func (s Scope) String() string { + switch s { + case ScopeOrg: + return "organization" + case ScopeAccount: + return "account" + default: + return "repository" + } +} + +// Read returns a PermReq requiring at least read access to the permission. +func (p Permission) Read() PermReq { return PermReq{Perm: p, Min: LevelRead} } + +// Write returns a PermReq requiring at least write access to the permission. +func (p Permission) Write() PermReq { return PermReq{Perm: p, Min: LevelWrite} } + +// Admin returns a PermReq requiring admin access to the permission. +func (p Permission) Admin() PermReq { return PermReq{Perm: p, Min: LevelAdmin} } + +// PermReq is a single permission requirement: a permission together with the +// minimum level needed to satisfy it. +type PermReq struct { + Perm Permission + Min Level +} + +// String renders the requirement as "permission:level", e.g. "issues:write". +func (r PermReq) String() string { + return string(r.Perm) + ":" + r.Min.String() +} + +// Requirement expresses the fine-grained permissions a tool needs. It is an +// OR of AND-sets ("any of these alternatives, where an alternative requires +// all of its permissions"): +// +// anyOf = [ [a AND b], [c] ] => (a AND b) OR (c) +// +// The zero value is an empty requirement, which means "no gate": a tool with a +// zero-value Requirement is always shown. +type Requirement struct { + // anyOf holds the alternatives. Each inner slice is an AND-set of + // permission requirements. Unexported so the structure can only be built + // through the combinators below, keeping invariants (sorted, deduped). + anyOf [][]PermReq +} + +// IsZero reports whether the requirement is empty (no gate). +func (r Requirement) IsZero() bool { + return len(r.anyOf) == 0 +} + +// Require builds a requirement satisfied when ALL of the given permission +// requirements are held. This is the idiomatic constructor for the common +// single-endpoint tool, e.g. Require(Issues.Write()). +func Require(rs ...PermReq) Requirement { + return newRequirement(rs) +} + +// AllOf is a synonym for Require, reading more naturally when listing several +// permissions that are all required together. +func AllOf(rs ...PermReq) Requirement { + return newRequirement(rs) +} + +// AnyOf combines requirements with OR: the result is satisfied if any of the +// given requirements is satisfied. Empty inputs are ignored. +func AnyOf(reqs ...Requirement) Requirement { + var out Requirement + for _, req := range reqs { + out.anyOf = append(out.anyOf, req.anyOf...) + } + out.normalize() + return out +} + +// And combines two requirements with AND. The result is the distribution of +// the two OR-of-AND forms: every alternative of r is concatenated with every +// alternative of other. This models a tool that calls multiple endpoints, each +// contributing its own permission requirement. +func (r Requirement) And(other Requirement) Requirement { + if r.IsZero() { + return other + } + if other.IsZero() { + return r + } + var out Requirement + for _, a := range r.anyOf { + for _, b := range other.anyOf { + combined := make([]PermReq, 0, len(a)+len(b)) + combined = append(combined, a...) + combined = append(combined, b...) + out.anyOf = append(out.anyOf, combined) + } + } + out.normalize() + return out +} + +// SatisfiedBy reports whether the granted permissions satisfy the requirement. +// An empty requirement is always satisfied. The granted map records the level +// held for each permission; a higher granted level satisfies a lower minimum. +func (r Requirement) SatisfiedBy(granted map[Permission]Level) bool { + if r.IsZero() { + return true + } + for _, andSet := range r.anyOf { + if andSetSatisfied(andSet, granted) { + return true + } + } + return false +} + +func andSetSatisfied(andSet []PermReq, granted map[Permission]Level) bool { + for _, req := range andSet { + if granted[req.Perm] < req.Min { + return false + } + } + return true +} + +// Permissions returns the sorted, de-duplicated set of permissions referenced +// anywhere in the requirement. +func (r Requirement) Permissions() []Permission { + seen := make(map[Permission]struct{}) + for _, andSet := range r.anyOf { + for _, req := range andSet { + seen[req.Perm] = struct{}{} + } + } + out := make([]Permission, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + slices.Sort(out) + return out +} + +// String renders the requirement for documentation and CLI output. AND-sets +// are joined with " AND " and alternatives with " OR ", e.g.: +// +// "issues:write" +// "contents:read AND pull_requests:read" +// "issues:write OR organization_projects:write" +// +// The empty requirement renders as an empty string. +func (r Requirement) String() string { + if r.IsZero() { + return "" + } + alts := make([]string, 0, len(r.anyOf)) + for _, andSet := range r.anyOf { + parts := make([]string, 0, len(andSet)) + for _, req := range andSet { + parts = append(parts, req.String()) + } + alts = append(alts, strings.Join(parts, " AND ")) + } + return strings.Join(alts, " OR ") +} + +// newRequirement constructs a single-alternative requirement from an AND-set, +// dropping zero-level entries and normalizing. +func newRequirement(rs []PermReq) Requirement { + andSet := make([]PermReq, 0, len(rs)) + for _, r := range rs { + if r.Perm == "" || r.Min == LevelNone { + continue + } + andSet = append(andSet, r) + } + if len(andSet) == 0 { + return Requirement{} + } + out := Requirement{anyOf: [][]PermReq{andSet}} + out.normalize() + return out +} + +// normalize sorts each AND-set and the list of alternatives so that equal +// requirements have an identical structure, giving deterministic output for +// docs and tests. Within an AND-set, duplicate permissions collapse to their +// highest required level. +func (r *Requirement) normalize() { + for i, andSet := range r.anyOf { + highest := make(map[Permission]Level) + for _, req := range andSet { + if req.Min > highest[req.Perm] { + highest[req.Perm] = req.Min + } + } + deduped := make([]PermReq, 0, len(highest)) + for perm, lvl := range highest { + deduped = append(deduped, PermReq{Perm: perm, Min: lvl}) + } + sort.Slice(deduped, func(a, b int) bool { + if deduped[a].Perm != deduped[b].Perm { + return deduped[a].Perm < deduped[b].Perm + } + return deduped[a].Min < deduped[b].Min + }) + r.anyOf[i] = deduped + } + sort.Slice(r.anyOf, func(a, b int) bool { + return andSetKey(r.anyOf[a]) < andSetKey(r.anyOf[b]) + }) +} + +func andSetKey(andSet []PermReq) string { + parts := make([]string, len(andSet)) + for i, req := range andSet { + parts[i] = req.String() + } + return strings.Join(parts, ",") +} diff --git a/pkg/permissions/permissions_test.go b/pkg/permissions/permissions_test.go new file mode 100644 index 0000000000..42815b631f --- /dev/null +++ b/pkg/permissions/permissions_test.go @@ -0,0 +1,143 @@ +package permissions + +import ( + "reflect" + "testing" +) + +func TestLevelOrdering(t *testing.T) { + if LevelNone >= LevelRead || LevelRead >= LevelWrite || LevelWrite >= LevelAdmin { + t.Fatalf("levels are not strictly ordered") + } +} + +func TestParseLevel(t *testing.T) { + cases := map[string]Level{ + "read": LevelRead, "WRITE": LevelWrite, "admin": LevelAdmin, "nope": LevelNone, "": LevelNone, + } + for in, want := range cases { + if got := ParseLevel(in); got != want { + t.Errorf("ParseLevel(%q) = %v, want %v", in, got, want) + } + } + if got := ParseLevel(" admin "); got != LevelAdmin { + t.Errorf("ParseLevel trims surrounding whitespace, got %v", got) + } +} + +func TestRequireSatisfiedBy(t *testing.T) { + req := Require(Issues.Write()) + + tests := []struct { + name string + granted map[Permission]Level + want bool + }{ + {"exact write", map[Permission]Level{Issues: LevelWrite}, true}, + {"admin satisfies write", map[Permission]Level{Issues: LevelAdmin}, true}, + {"read does not satisfy write", map[Permission]Level{Issues: LevelRead}, false}, + {"missing permission", map[Permission]Level{Contents: LevelWrite}, false}, + {"empty grant", map[Permission]Level{}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := req.SatisfiedBy(tc.granted); got != tc.want { + t.Errorf("SatisfiedBy(%v) = %v, want %v", tc.granted, got, tc.want) + } + }) + } +} + +func TestZeroRequirementAlwaysSatisfied(t *testing.T) { + var zero Requirement + if !zero.IsZero() { + t.Fatal("expected zero value to be IsZero") + } + if !zero.SatisfiedBy(nil) { + t.Fatal("zero requirement must be satisfied by anything (no gate)") + } +} + +func TestAllOfIsAnd(t *testing.T) { + req := AllOf(Contents.Read(), PullRequests.Read()) + if req.SatisfiedBy(map[Permission]Level{Contents: LevelRead}) { + t.Error("AllOf must require every permission") + } + if !req.SatisfiedBy(map[Permission]Level{Contents: LevelRead, PullRequests: LevelWrite}) { + t.Error("AllOf satisfied when all permissions held") + } +} + +func TestAnyOfIsOr(t *testing.T) { + req := AnyOf(Require(Issues.Write()), Require(OrganizationProjects.Write())) + if !req.SatisfiedBy(map[Permission]Level{Issues: LevelWrite}) { + t.Error("AnyOf satisfied by first alternative") + } + if !req.SatisfiedBy(map[Permission]Level{OrganizationProjects: LevelWrite}) { + t.Error("AnyOf satisfied by second alternative") + } + if req.SatisfiedBy(map[Permission]Level{Contents: LevelWrite}) { + t.Error("AnyOf not satisfied by unrelated permission") + } +} + +func TestAndDistributes(t *testing.T) { + // (issues:read) AND (contents:read OR pull_requests:read) + req := Require(Issues.Read()).And(AnyOf(Require(Contents.Read()), Require(PullRequests.Read()))) + if !req.SatisfiedBy(map[Permission]Level{Issues: LevelRead, Contents: LevelRead}) { + t.Error("expected satisfied via contents branch") + } + if !req.SatisfiedBy(map[Permission]Level{Issues: LevelRead, PullRequests: LevelRead}) { + t.Error("expected satisfied via pull_requests branch") + } + if req.SatisfiedBy(map[Permission]Level{Contents: LevelRead}) { + t.Error("missing required issues:read should fail") + } +} + +func TestAndWithZeroIsIdentity(t *testing.T) { + req := Require(Issues.Write()) + var zero Requirement + if !reflect.DeepEqual(req.And(zero), req) { + t.Error("AND with zero on the right should be identity") + } + if !reflect.DeepEqual(zero.And(req), req) { + t.Error("AND with zero on the left should be identity") + } +} + +func TestRequirementString(t *testing.T) { + tests := []struct { + name string + req Requirement + want string + }{ + {"single", Require(Issues.Write()), "issues:write"}, + {"and", AllOf(Contents.Read(), PullRequests.Read()), "contents:read AND pull_requests:read"}, + {"or", AnyOf(Require(Issues.Write()), Require(OrganizationProjects.Write())), "issues:write OR organization_projects:write"}, + {"zero", Requirement{}, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.req.String(); got != tc.want { + t.Errorf("String() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestPermissions(t *testing.T) { + req := AnyOf(Require(Issues.Write()), AllOf(Contents.Read(), Issues.Read())) + got := req.Permissions() + want := []Permission{Contents, Issues} + if !reflect.DeepEqual(got, want) { + t.Errorf("Permissions() = %v, want %v", got, want) + } +} + +func TestNormalizeCollapsesDuplicateToHighest(t *testing.T) { + req := Require(Issues.Read(), Issues.Write()) + if got := req.String(); got != "issues:write" { + t.Errorf("duplicate permission should collapse to highest level, got %q", got) + } +} diff --git a/script/list-permissions b/script/list-permissions new file mode 100755 index 0000000000..8d96c3b2b1 --- /dev/null +++ b/script/list-permissions @@ -0,0 +1,24 @@ +#!/bin/bash +# +# List required fine-grained permissions for enabled tools. +# +# Usage: +# script/list-permissions [--toolsets=...] [--output=text|json|summary] +# +# Examples: +# script/list-permissions +# script/list-permissions --toolsets=all --output=json +# script/list-permissions --toolsets=repos,issues --output=summary +# + +set -e + +cd "$(dirname "$0")/.." + +# Build the server if it doesn't exist or is outdated +if [ ! -f github-mcp-server ] || [ cmd/github-mcp-server/list_permissions.go -nt github-mcp-server ]; then + echo "Building github-mcp-server..." >&2 + go build -o github-mcp-server ./cmd/github-mcp-server +fi + +exec ./github-mcp-server list-permissions "$@"