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
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 (default 1, min 1) (number, optional)
- `perPage`: Results per page for pagination (default 30, 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. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter.",
"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 (default 1, min 1)",
"minimum": 1,
"type": "number"
},
"perPage": {
"description": "Results per page for pagination (default 30, 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"
}
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (
GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription"
PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription"
DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription"
ListCollaborators = "GET /repos/{owner}/{repo}/collaborators"

// Git endpoints
GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}"
Expand Down
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
108 changes: 108 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -2202,3 +2202,111 @@ 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. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"),
ReadOnlyHint: true,
},
InputSchema: func() *jsonschema.Schema {
schema := 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"},
})
schema.Properties["page"].Description = "Page number for pagination (default 1, min 1)"
schema.Properties["perPage"].Description = "Results per page for pagination (default 30, min 1, max 100)"
return schema
}(),
},
[]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(),
})
}

response := map[string]any{
"items": result,
"nextPage": resp.NextPage,
"prevPage": resp.PrevPage,
"firstPage": resp.FirstPage,
"lastPage": resp.LastPage,
}

return MarshalledTextResult(response), nil, nil
},
)
}
146 changes: 146 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4368,3 +4368,149 @@ func Test_UnstarRepository(t *testing.T) {
})
}
}

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(
ListCollaborators,
mockCollaborators,
),
},
},
{
name: "success with affiliation filter",
args: map[string]any{
"owner": "owner",
"repo": "repo",
"affiliation": "direct",
},
mockResponses: []MockBackendOption{
WithRequestMatch(
ListCollaborators,
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(
ListCollaborators,
[]*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 response struct {
Items []MinimalCollaborator `json:"items"`
NextPage int `json:"nextPage"`
PrevPage int `json:"prevPage"`
FirstPage int `json:"firstPage"`
LastPage int `json:"lastPage"`
}
err = json.Unmarshal([]byte(textContent.Text), &response)
require.NoError(t, err)

if tt.name == "empty collaborators returns empty array" {
assert.Empty(t, response.Items)
return
}

collaborators := response.Items
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