Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ runtime behavior (such as output formatting) won't appear here.
### `thread_resolution_reason`

- **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews
- **Required OAuth Scopes**: `repo`
- **OAuth Challenge Scopes**: `repo`
- `body`: Review comment text (string, optional)
- `commitID`: SHA of commit to review (string, optional)
- `event`: Review action to perform. (string, optional)
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -1403,7 +1403,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool
Required: []string{"owner", "repo", "issue_number"},
},
},
scopes.RequireAll(scopes.Repo),
publicRepositoryWriteScopeAccess(),
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 {
Expand Down Expand Up @@ -2524,7 +2524,7 @@ Options are:
Required: []string{"method", "owner", "repo"},
},
},
scopes.RequireAll(scopes.Repo),
publicRepositoryWriteScopeAccess(),
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 {
Expand Down
103 changes: 103 additions & 0 deletions pkg/github/public_repo_scopes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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"
)

func TestPublicRepoContributionToolScopeAccess(t *testing.T) {
t.Parallel()

tools := []struct {
name string
tool inventory.ServerTool
}{
{name: "fork_repository", tool: ForkRepository(translations.NullTranslationHelper)},
{name: "create_branch", tool: CreateBranch(translations.NullTranslationHelper)},
{name: "create_pull_request", tool: CreatePullRequest(translations.NullTranslationHelper)},
{name: "issue_write", tool: IssueWrite(translations.NullTranslationHelper)},
{name: "add_issue_comment", tool: AddIssueComment(translations.NullTranslationHelper)},
}

for _, tt := range tools {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Scopes)
require.NotNil(t, tt.tool.ScopeAccess.Visible)
assert.False(t, tt.tool.ScopeAccess.Visible(nil))
assert.True(t, tt.tool.ScopeAccess.Visible([]string{string(scopes.PublicRepo)}))
assert.True(t, tt.tool.ScopeAccess.Visible([]string{string(scopes.Repo)}))

require.NotNil(t, tt.tool.ScopeAccess.Challenge)
assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Challenge(nil, nil))
assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Challenge(nil, []string{string(scopes.PublicRepo)}))
assert.Empty(t, tt.tool.ScopeAccess.Challenge(nil, []string{string(scopes.Repo)}))
})
}
}

func TestPublicRepoContributionToolsVisibleToPATs(t *testing.T) {
t.Parallel()

tools := []inventory.ServerTool{
ForkRepository(translations.NullTranslationHelper),
CreateBranch(translations.NullTranslationHelper),
PushFiles(translations.NullTranslationHelper),
CreatePullRequest(translations.NullTranslationHelper),
IssueWrite(translations.NullTranslationHelper),
AddIssueComment(translations.NullTranslationHelper),
}

tests := []struct {
name string
tokenScopes []string
wantVisible bool
}{
{name: "no scopes"},
{name: "public_repo", tokenScopes: []string{string(scopes.PublicRepo)}, wantVisible: true},
{name: "repo", tokenScopes: []string{string(scopes.Repo)}, wantVisible: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter := CreateToolScopeFilter(tt.tokenScopes)
for i := range tools {
included, err := filter(t.Context(), &tools[i])
require.NoError(t, err)
assert.Equal(t, tt.wantVisible, included, tools[i].Tool.Name)
}
})
}
}

func TestPushFilesOAuthScopeChallenges(t *testing.T) {
t.Parallel()

tool := PushFiles(translations.NullTranslationHelper)
regularFiles := map[string]any{
"files": []any{map[string]any{"path": "README.md"}},
}
workflowFiles := map[string]any{
"files": []any{map[string]any{"path": ".github/workflows/ci.yml"}},
}

assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Scopes)
require.NotNil(t, tool.ScopeAccess.Visible)
assert.True(t, tool.ScopeAccess.Visible([]string{string(scopes.PublicRepo)}))
assert.True(t, tool.ScopeAccess.Visible([]string{string(scopes.Repo)}))

assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(regularFiles, nil))
assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(regularFiles, []string{string(scopes.PublicRepo)}))
assert.Empty(t, tool.ScopeAccess.Challenge(regularFiles, []string{string(scopes.Repo)}))

assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, nil))
assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.PublicRepo)}))
assert.Equal(t, []string{string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Repo)}))
assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Workflow)}))
assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.PublicRepo), string(scopes.Workflow)}))
assert.Empty(t, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Repo), string(scopes.Workflow)}))
}
2 changes: 1 addition & 1 deletion pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
Required: []string{"owner", "repo", "title", "head", "base"},
},
},
scopes.RequireAll(scopes.Repo),
publicRepositoryWriteScopeAccess(),
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 {
Expand Down
6 changes: 3 additions & 3 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -1229,7 +1229,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool {
Required: []string{"owner", "repo"},
},
},
scopes.RequireAll(scopes.Repo),
publicRepositoryWriteScopeAccess(),
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 {
Expand Down Expand Up @@ -1526,7 +1526,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool {
Required: []string{"owner", "repo", "branch"},
},
},
scopes.RequireAll(scopes.Repo),
publicRepositoryWriteScopeAccess(),
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 {
Expand Down Expand Up @@ -1658,7 +1658,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
Required: []string{"owner", "repo", "branch", "files", "message"},
},
},
scopes.RequireAll(scopes.Repo),
publicRepositoryWriteScopeAccess(),
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 {
Expand Down
10 changes: 7 additions & 3 deletions pkg/github/repository_path.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,12 @@ func workflowScopeChallengeForFiles(arguments map[string]any, activeScopes []str
containsWorkflow = true
}
}
if containsWorkflow {
return scopes.ChallengeAll(activeScopes, scopes.Repo, scopes.Workflow)
var challenge []string
if !scopes.HasAll(activeScopes, scopes.Repo) {
challenge = append(challenge, string(scopes.Repo))
}
return scopes.ChallengeAll(activeScopes, scopes.Repo)
if containsWorkflow && !scopes.HasAll(activeScopes, scopes.Workflow) {
challenge = append(challenge, string(scopes.Workflow))
}
return challenge
}
2 changes: 1 addition & 1 deletion pkg/github/repository_path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func TestFileWriteWorkflowScopeChallenges(t *testing.T) {
map[string]any{"path": "README.md"},
map[string]any{"path": ".github/workflows/ci.yml"},
}},
want: []string{"repo", "workflow"},
want: []string{"workflow"},
},
{
name: "push validates entries after workflow",
Expand Down
8 changes: 8 additions & 0 deletions pkg/github/tool_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import (
"github.com/github/github-mcp-server/pkg/scopes"
)

func publicRepositoryWriteScopeAccess() inventory.ScopeAccess {
access := scopes.RequireAll(scopes.Repo)
access.Visible = func(activeScopes []string) bool {
return scopes.HasAll(activeScopes, scopes.PublicRepo)
}
return access
}

func repositoryOrOrganizationScopeAccess() inventory.ScopeAccess {
return scopes.DynamicChallenge(
[]scopes.Scope{scopes.Repo, scopes.ReadOrg},
Expand Down
1 change: 1 addition & 0 deletions pkg/scopes/scopes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func TestOAuthScopeCatalog(t *testing.T) {

func TestScopeChecks(t *testing.T) {
assert.True(t, HasAll([]string{"repo", "workflow"}, Repo, Workflow))
assert.True(t, HasAll([]string{"repo"}, PublicRepo))
assert.False(t, HasAll([]string{"repo"}, Repo, Workflow))
assert.True(t, HasAll([]string{"admin:org"}, ReadOrg))
assert.True(t, HasAllScopeNames([]string{"admin:org"}, []string{"read:org"}))
Expand Down
Loading