From a65e4bbd5fdb234fbb008de86f13c59e807f3fdf Mon Sep 17 00:00:00 2001 From: Shurong Cao <170531907+CAOShurong@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:44:04 +0800 Subject: [PATCH] fix(scopes): allow public_repo scope for public repository write tools Several repository write tools declared the broad `repo` scope, which hid them from tokens limited to `public_repo` and forced public-only OAuth deployments to request private-repository access. Lower the required scope to public_repo for tools that only operate on repositories the token can already reach: - add_issue_comment - issue_write - create_branch - push_files - create_pull_request - fork_repository Because RequiredScopes are expanded through the scope hierarchy, a full repo token remains accepted for every tool. GitHub continues to enforce actual per-repository permissions at the API layer. Fixes #3136 --- pkg/github/issues.go | 4 +- pkg/github/public_repo_scope_test.go | 89 ++++++++++++++++++++++++++++ pkg/github/pullrequests.go | 2 +- pkg/github/repositories.go | 6 +- 4 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 pkg/github/public_repo_scope_test.go diff --git a/pkg/github/issues.go b/pkg/github/issues.go index d6cc55e1ed..83bd980126 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1401,7 +1401,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "issue_number"}, }, }, - []scopes.Scope{scopes.Repo}, + []scopes.Scope{scopes.PublicRepo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -2511,7 +2511,7 @@ Options are: Required: []string{"method", "owner", "repo"}, }, }, - []scopes.Scope{scopes.Repo}, + []scopes.Scope{scopes.PublicRepo}, func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { diff --git a/pkg/github/public_repo_scope_test.go b/pkg/github/public_repo_scope_test.go new file mode 100644 index 0000000000..708b372026 --- /dev/null +++ b/pkg/github/public_repo_scope_test.go @@ -0,0 +1,89 @@ +package github + +import ( + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPublicRepoWriteToolsDeclareLeastPrivilegeScopes asserts that the public +// repository write tools advertise `public_repo` as their required scope while +// still accepting a full `repo` token (via the scope hierarchy). This enables +// least-privilege, public-only OAuth deployments instead of forcing the broad +// `repo` scope, which also grants private-repository access. +// See https://github.com/github/github-mcp-server/issues/3136 +func TestPublicRepoWriteToolsDeclareLeastPrivilegeScopes(t *testing.T) { + t.Parallel() + + builders := map[string]func(translations.TranslationHelperFunc) *inventory.ServerTool{ + "add_issue_comment": wrapToolBuilder(AddIssueComment), + "issue_write": wrapToolBuilder(IssueWrite), + "create_branch": wrapToolBuilder(CreateBranch), + "push_files": wrapToolBuilder(PushFiles), + "create_pull_request": wrapToolBuilder(CreatePullRequest), + "fork_repository": wrapToolBuilder(ForkRepository), + } + + for name, build := range builders { + t.Run(name, func(t *testing.T) { + t.Parallel() + + st := build(translations.NullTranslationHelper) + + assert.Equal(t, []string{string(scopes.PublicRepo)}, st.RequiredScopes, + "%s should require only public_repo", name) + assert.ElementsMatch(t, + []string{string(scopes.PublicRepo), string(scopes.Repo)}, st.AcceptedScopes, + "%s should accept both public_repo and repo tokens", name) + }) + } +} + +// TestPublicRepoWriteToolsVisibleToPublicRepoToken asserts the PAT tool filter +// shows these tools to a public_repo-only token and keeps them visible for a +// full repo token. +func TestPublicRepoWriteToolsVisibleToPublicRepoToken(t *testing.T) { + t.Parallel() + + publicRepoToken := []string{string(scopes.PublicRepo)} + repoToken := []string{string(scopes.Repo)} + + filterForPublicToken := CreateToolScopeFilter(publicRepoToken) + filterForRepoToken := CreateToolScopeFilter(repoToken) + + builders := map[string]func(translations.TranslationHelperFunc) *inventory.ServerTool{ + "add_issue_comment": wrapToolBuilder(AddIssueComment), + "issue_write": wrapToolBuilder(IssueWrite), + "create_branch": wrapToolBuilder(CreateBranch), + "push_files": wrapToolBuilder(PushFiles), + "create_pull_request": wrapToolBuilder(CreatePullRequest), + "fork_repository": wrapToolBuilder(ForkRepository), + } + + for name, build := range builders { + t.Run(name, func(t *testing.T) { + t.Parallel() + + st := build(translations.NullTranslationHelper) + + allowed, err := filterForPublicToken(t.Context(), st) + require.NoError(t, err) + assert.True(t, allowed, "%s should be visible with a public_repo-only token", name) + + allowed, err = filterForRepoToken(t.Context(), st) + require.NoError(t, err) + assert.True(t, allowed, "%s should remain visible with a full repo token", name) + }) + } +} + +func wrapToolBuilder(fn func(translations.TranslationHelperFunc) inventory.ServerTool) func(translations.TranslationHelperFunc) *inventory.ServerTool { + return func(t translations.TranslationHelperFunc) *inventory.ServerTool { + st := fn(t) + return &st + } +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 8801ec2894..12a6839a6d 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -706,7 +706,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "title", "head", "base"}, }, }, - []scopes.Scope{scopes.Repo}, + []scopes.Scope{scopes.PublicRepo}, func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 446d7ca551..a7ac3c6b93 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1222,7 +1222,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, - []scopes.Scope{scopes.Repo}, + []scopes.Scope{scopes.PublicRepo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1509,7 +1509,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch"}, }, }, - []scopes.Scope{scopes.Repo}, + []scopes.Scope{scopes.PublicRepo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { @@ -1641,7 +1641,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch", "files", "message"}, }, }, - []scopes.Scope{scopes.Repo}, + []scopes.Scope{scopes.PublicRepo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil {