Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1256,6 +1256,14 @@ The following sets of tools are available:
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)

- **list_repository_collaborators** - List repository collaborators
- **Required OAuth Scopes**: `repo`
- `affiliation`: Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all' (string, optional)
- `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)
- `repo`: Repository name (string, required)

- **list_tags** - List tags
- **Required OAuth Scopes**: `repo`
- `owner`: Repository owner (string, required)
Expand Down
45 changes: 45 additions & 0 deletions pkg/github/__toolsnaps__/list_repository_collaborators.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"annotations": {
"readOnlyHint": true,
"title": "List repository collaborators"
},
"description": "List collaborators of a GitHub repository.",
"inputSchema": {
"properties": {
"affiliation": {
"description": "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'",
"enum": [
"outside",
"direct",
"all"
],
"type": "string"
},
"owner": {
"description": "Repository owner",
"type": "string"
},
"page": {
"description": "Page number for pagination (min 1)",
"minimum": 1,
"type": "number"
},
"perPage": {
"description": "Results per page for pagination (min 1, max 100)",
"maximum": 100,
"minimum": 1,
"type": "number"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo"
],
"type": "object"
},
"name": "list_repository_collaborators"
}
7 changes: 7 additions & 0 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ type MinimalResponse struct {
URL string `json:"url"`
}

// MinimalCollaborator is the trimmed output type for repository collaborators.
type MinimalCollaborator struct {
Login string `json:"login"`
ID int64 `json:"id"`
RoleName string `json:"role_name"`
}

type MinimalProject struct {
ID *int64 `json:"id,omitempty"`
NodeID *string `json:"node_id,omitempty"`
Expand Down
100 changes: 100 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -2202,3 +2202,103 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool
},
)
}

// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository.
func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "list_repository_collaborators",
Description: t("TOOL_LIST_REPOSITORY_COLLABORATORS_DESCRIPTION", "List collaborators of a GitHub repository."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"),
ReadOnlyHint: true,
},
InputSchema: WithPagination(&jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"affiliation": {
Type: "string",
Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'",
Enum: []any{"outside", "direct", "all"},
},
},
Required: []string{"owner", "repo"},
}),
},
[]scopes.Scope{scopes.Repo},
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 {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
affiliation, err := OptionalParam[string](args, "affiliation")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := OptionalPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

client, err := deps.GetClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

opts := &github.ListCollaboratorsOptions{
Affiliation: affiliation,
ListOptions: github.ListOptions{
Page: pagination.Page,
PerPage: pagination.PerPage,
},
}

collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to list collaborators",
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil
}

result := make([]MinimalCollaborator, 0, len(collaborators))
for _, c := range collaborators {
result = append(result, MinimalCollaborator{
Login: c.GetLogin(),
ID: c.GetID(),
RoleName: c.GetRoleName(),
})
}

r, err := json.Marshal(result)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}

return utils.NewToolResultText(string(r)), nil, nil
Comment thread
JoannaaKL marked this conversation as resolved.
Outdated
},
)
}
140 changes: 140 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4368,3 +4368,143 @@
})
}
}

func Test_ListRepositoryCollaborators(t *testing.T) {
// Verify tool definition once
serverTool := ListRepositoryCollaborators(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))

schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")

assert.Equal(t, "list_repository_collaborators", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.True(t, tool.Annotations.ReadOnlyHint)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "affiliation")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "perPage")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})

mockCollaborators := []*github.User{
{
Login: github.Ptr("user1"),
ID: github.Ptr(int64(101)),
RoleName: github.Ptr("admin"),
},
{
Login: github.Ptr("user2"),
ID: github.Ptr(int64(102)),
RoleName: github.Ptr("write"),
},
}

tests := []struct {
name string
args map[string]any
mockResponses []MockBackendOption
wantErr bool
errContains string
}{
{
name: "success",
args: map[string]any{
"owner": "owner",
"repo": "repo",
},
mockResponses: []MockBackendOption{
WithRequestMatch(
GetReposCollaboratorsByOwnerByRepo,

Check failure on line 4419 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4419 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (macos-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4419 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4419 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / lint

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4419 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4419 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4419 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (macos-latest)

undefined: GetReposCollaboratorsByOwnerByRepo
mockCollaborators,
),
},
},
{
name: "success with affiliation filter",
args: map[string]any{
"owner": "owner",
"repo": "repo",
"affiliation": "direct",
},
mockResponses: []MockBackendOption{
WithRequestMatch(
GetReposCollaboratorsByOwnerByRepo,

Check failure on line 4433 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4433 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (macos-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4433 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4433 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / lint

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4433 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4433 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4433 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (macos-latest)

undefined: GetReposCollaboratorsByOwnerByRepo
mockCollaborators,
),
},
},
{
name: "missing owner",
args: map[string]any{
"repo": "repo",
},
mockResponses: []MockBackendOption{},
errContains: "missing required parameter: owner",
},
{
name: "missing repo",
args: map[string]any{
"owner": "owner",
},
mockResponses: []MockBackendOption{},
errContains: "missing required parameter: repo",
},
{
name: "empty collaborators returns empty array",
args: map[string]any{
"owner": "owner",
"repo": "repo",
},
mockResponses: []MockBackendOption{
WithRequestMatch(
GetReposCollaboratorsByOwnerByRepo,

Check failure on line 4462 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4462 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (macos-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4462 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4462 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / lint

undefined: GetReposCollaboratorsByOwnerByRepo (typecheck)

Check failure on line 4462 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (windows-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4462 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

undefined: GetReposCollaboratorsByOwnerByRepo

Check failure on line 4462 in pkg/github/repositories_test.go

View workflow job for this annotation

GitHub Actions / build (macos-latest)

undefined: GetReposCollaboratorsByOwnerByRepo
[]*github.User{},
),
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...))
deps := BaseDeps{
Client: mockClient,
}
handler := serverTool.Handler(deps)

request := createMCPRequest(tt.args)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.NotNil(t, result)

if tt.errContains != "" {
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tt.errContains)
return
}

textContent := getTextResult(t, result)
require.NotEmpty(t, textContent.Text)

var collaborators []MinimalCollaborator
err = json.Unmarshal([]byte(textContent.Text), &collaborators)
require.NoError(t, err)

if tt.name == "empty collaborators returns empty array" {
assert.Empty(t, collaborators)
assert.Equal(t, "[]", textContent.Text)
return
}

assert.Len(t, collaborators, 2)
assert.Equal(t, "user1", collaborators[0].Login)
assert.Equal(t, int64(101), collaborators[0].ID)
assert.Equal(t, "admin", collaborators[0].RoleName)
assert.Equal(t, "user2", collaborators[1].Login)
assert.Equal(t, int64(102), collaborators[1].ID)
assert.Equal(t, "write", collaborators[1].RoleName)
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
ListStarredRepositories(t),
StarRepository(t),
UnstarRepository(t),
ListRepositoryCollaborators(t),

// Git tools
GetRepositoryTree(t),
Expand Down
Loading