Skip to content

Commit 590d871

Browse files
JoannaaKLSamMorrowDrums
authored andcommitted
Add tool to list repo collaborators (#2477)
* Add tool to list repo collaborators * Simplify tool description * Fix test * Return pagination info * Return page parameters * Update defaults
1 parent 0aa6442 commit 590d871

7 files changed

Lines changed: 316 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,6 +1256,14 @@ The following sets of tools are available:
12561256
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
12571257
- `repo`: Repository name (string, required)
12581258

1259+
- **list_repository_collaborators** - List repository collaborators
1260+
- **Required OAuth Scopes**: `repo`
1261+
- `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)
1262+
- `owner`: Repository owner (string, required)
1263+
- `page`: Page number for pagination (default 1, min 1) (number, optional)
1264+
- `perPage`: Results per page for pagination (default 30, min 1, max 100) (number, optional)
1265+
- `repo`: Repository name (string, required)
1266+
12591267
- **list_tags** - List tags
12601268
- **Required OAuth Scopes**: `repo`
12611269
- `owner`: Repository owner (string, required)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "List repository collaborators"
5+
},
6+
"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.",
7+
"inputSchema": {
8+
"properties": {
9+
"affiliation": {
10+
"description": "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'",
11+
"enum": [
12+
"outside",
13+
"direct",
14+
"all"
15+
],
16+
"type": "string"
17+
},
18+
"owner": {
19+
"description": "Repository owner",
20+
"type": "string"
21+
},
22+
"page": {
23+
"description": "Page number for pagination (default 1, min 1)",
24+
"minimum": 1,
25+
"type": "number"
26+
},
27+
"perPage": {
28+
"description": "Results per page for pagination (default 30, min 1, max 100)",
29+
"maximum": 100,
30+
"minimum": 1,
31+
"type": "number"
32+
},
33+
"repo": {
34+
"description": "Repository name",
35+
"type": "string"
36+
}
37+
},
38+
"required": [
39+
"owner",
40+
"repo"
41+
],
42+
"type": "object"
43+
},
44+
"name": "list_repository_collaborators"
45+
}

‎pkg/github/helper_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const (
3939
GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription"
4040
PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription"
4141
DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription"
42+
ListCollaborators = "GET /repos/{owner}/{repo}/collaborators"
4243

4344
// Git endpoints
4445
GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}"

‎pkg/github/minimal_types.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ type MinimalResponse struct {
138138
URL string `json:"url"`
139139
}
140140

141+
// MinimalCollaborator is the trimmed output type for repository collaborators.
142+
type MinimalCollaborator struct {
143+
Login string `json:"login"`
144+
ID int64 `json:"id"`
145+
RoleName string `json:"role_name"`
146+
}
147+
141148
type MinimalProject struct {
142149
ID *int64 `json:"id,omitempty"`
143150
NodeID *string `json:"node_id,omitempty"`

‎pkg/github/repositories.go‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2148,3 +2148,111 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool
21482148
},
21492149
)
21502150
}
2151+
2152+
// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository.
2153+
func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool {
2154+
return NewTool(
2155+
ToolsetMetadataRepos,
2156+
mcp.Tool{
2157+
Name: "list_repository_collaborators",
2158+
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."),
2159+
Annotations: &mcp.ToolAnnotations{
2160+
Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"),
2161+
ReadOnlyHint: true,
2162+
},
2163+
InputSchema: func() *jsonschema.Schema {
2164+
schema := WithPagination(&jsonschema.Schema{
2165+
Type: "object",
2166+
Properties: map[string]*jsonschema.Schema{
2167+
"owner": {
2168+
Type: "string",
2169+
Description: "Repository owner",
2170+
},
2171+
"repo": {
2172+
Type: "string",
2173+
Description: "Repository name",
2174+
},
2175+
"affiliation": {
2176+
Type: "string",
2177+
Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'",
2178+
Enum: []any{"outside", "direct", "all"},
2179+
},
2180+
},
2181+
Required: []string{"owner", "repo"},
2182+
})
2183+
schema.Properties["page"].Description = "Page number for pagination (default 1, min 1)"
2184+
schema.Properties["perPage"].Description = "Results per page for pagination (default 30, min 1, max 100)"
2185+
return schema
2186+
}(),
2187+
},
2188+
[]scopes.Scope{scopes.Repo},
2189+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
2190+
owner, err := RequiredParam[string](args, "owner")
2191+
if err != nil {
2192+
return utils.NewToolResultError(err.Error()), nil, nil
2193+
}
2194+
repo, err := RequiredParam[string](args, "repo")
2195+
if err != nil {
2196+
return utils.NewToolResultError(err.Error()), nil, nil
2197+
}
2198+
affiliation, err := OptionalParam[string](args, "affiliation")
2199+
if err != nil {
2200+
return utils.NewToolResultError(err.Error()), nil, nil
2201+
}
2202+
pagination, err := OptionalPaginationParams(args)
2203+
if err != nil {
2204+
return utils.NewToolResultError(err.Error()), nil, nil
2205+
}
2206+
2207+
client, err := deps.GetClient(ctx)
2208+
if err != nil {
2209+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
2210+
}
2211+
2212+
opts := &github.ListCollaboratorsOptions{
2213+
Affiliation: affiliation,
2214+
ListOptions: github.ListOptions{
2215+
Page: pagination.Page,
2216+
PerPage: pagination.PerPage,
2217+
},
2218+
}
2219+
2220+
collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts)
2221+
if err != nil {
2222+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
2223+
"failed to list collaborators",
2224+
resp,
2225+
err,
2226+
), nil, nil
2227+
}
2228+
defer func() { _ = resp.Body.Close() }()
2229+
2230+
if resp.StatusCode != http.StatusOK {
2231+
body, err := io.ReadAll(resp.Body)
2232+
if err != nil {
2233+
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
2234+
}
2235+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil
2236+
}
2237+
2238+
result := make([]MinimalCollaborator, 0, len(collaborators))
2239+
for _, c := range collaborators {
2240+
result = append(result, MinimalCollaborator{
2241+
Login: c.GetLogin(),
2242+
ID: c.GetID(),
2243+
RoleName: c.GetRoleName(),
2244+
})
2245+
}
2246+
2247+
response := map[string]any{
2248+
"items": result,
2249+
"nextPage": resp.NextPage,
2250+
"prevPage": resp.PrevPage,
2251+
"firstPage": resp.FirstPage,
2252+
"lastPage": resp.LastPage,
2253+
}
2254+
2255+
return MarshalledTextResult(response), nil, nil
2256+
},
2257+
)
2258+
}

‎pkg/github/repositories_test.go‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4180,3 +4180,149 @@ func Test_UnstarRepository(t *testing.T) {
41804180
})
41814181
}
41824182
}
4183+
4184+
func Test_ListRepositoryCollaborators(t *testing.T) {
4185+
// Verify tool definition once
4186+
serverTool := ListRepositoryCollaborators(translations.NullTranslationHelper)
4187+
tool := serverTool.Tool
4188+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
4189+
4190+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
4191+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
4192+
4193+
assert.Equal(t, "list_repository_collaborators", tool.Name)
4194+
assert.NotEmpty(t, tool.Description)
4195+
assert.True(t, tool.Annotations.ReadOnlyHint)
4196+
assert.Contains(t, schema.Properties, "owner")
4197+
assert.Contains(t, schema.Properties, "repo")
4198+
assert.Contains(t, schema.Properties, "affiliation")
4199+
assert.Contains(t, schema.Properties, "page")
4200+
assert.Contains(t, schema.Properties, "perPage")
4201+
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
4202+
4203+
mockCollaborators := []*github.User{
4204+
{
4205+
Login: github.Ptr("user1"),
4206+
ID: github.Ptr(int64(101)),
4207+
RoleName: github.Ptr("admin"),
4208+
},
4209+
{
4210+
Login: github.Ptr("user2"),
4211+
ID: github.Ptr(int64(102)),
4212+
RoleName: github.Ptr("write"),
4213+
},
4214+
}
4215+
4216+
tests := []struct {
4217+
name string
4218+
args map[string]any
4219+
mockResponses []MockBackendOption
4220+
wantErr bool
4221+
errContains string
4222+
}{
4223+
{
4224+
name: "success",
4225+
args: map[string]any{
4226+
"owner": "owner",
4227+
"repo": "repo",
4228+
},
4229+
mockResponses: []MockBackendOption{
4230+
WithRequestMatch(
4231+
ListCollaborators,
4232+
mockCollaborators,
4233+
),
4234+
},
4235+
},
4236+
{
4237+
name: "success with affiliation filter",
4238+
args: map[string]any{
4239+
"owner": "owner",
4240+
"repo": "repo",
4241+
"affiliation": "direct",
4242+
},
4243+
mockResponses: []MockBackendOption{
4244+
WithRequestMatch(
4245+
ListCollaborators,
4246+
mockCollaborators,
4247+
),
4248+
},
4249+
},
4250+
{
4251+
name: "missing owner",
4252+
args: map[string]any{
4253+
"repo": "repo",
4254+
},
4255+
mockResponses: []MockBackendOption{},
4256+
errContains: "missing required parameter: owner",
4257+
},
4258+
{
4259+
name: "missing repo",
4260+
args: map[string]any{
4261+
"owner": "owner",
4262+
},
4263+
mockResponses: []MockBackendOption{},
4264+
errContains: "missing required parameter: repo",
4265+
},
4266+
{
4267+
name: "empty collaborators returns empty array",
4268+
args: map[string]any{
4269+
"owner": "owner",
4270+
"repo": "repo",
4271+
},
4272+
mockResponses: []MockBackendOption{
4273+
WithRequestMatch(
4274+
ListCollaborators,
4275+
[]*github.User{},
4276+
),
4277+
},
4278+
},
4279+
}
4280+
4281+
for _, tt := range tests {
4282+
t.Run(tt.name, func(t *testing.T) {
4283+
mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...))
4284+
deps := BaseDeps{
4285+
Client: mockClient,
4286+
}
4287+
handler := serverTool.Handler(deps)
4288+
4289+
request := createMCPRequest(tt.args)
4290+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
4291+
require.NoError(t, err)
4292+
require.NotNil(t, result)
4293+
4294+
if tt.errContains != "" {
4295+
textContent := getTextResult(t, result)
4296+
assert.Contains(t, textContent.Text, tt.errContains)
4297+
return
4298+
}
4299+
4300+
textContent := getTextResult(t, result)
4301+
require.NotEmpty(t, textContent.Text)
4302+
4303+
var response struct {
4304+
Items []MinimalCollaborator `json:"items"`
4305+
NextPage int `json:"nextPage"`
4306+
PrevPage int `json:"prevPage"`
4307+
FirstPage int `json:"firstPage"`
4308+
LastPage int `json:"lastPage"`
4309+
}
4310+
err = json.Unmarshal([]byte(textContent.Text), &response)
4311+
require.NoError(t, err)
4312+
4313+
if tt.name == "empty collaborators returns empty array" {
4314+
assert.Empty(t, response.Items)
4315+
return
4316+
}
4317+
4318+
collaborators := response.Items
4319+
assert.Len(t, collaborators, 2)
4320+
assert.Equal(t, "user1", collaborators[0].Login)
4321+
assert.Equal(t, int64(101), collaborators[0].ID)
4322+
assert.Equal(t, "admin", collaborators[0].RoleName)
4323+
assert.Equal(t, "user2", collaborators[1].Login)
4324+
assert.Equal(t, int64(102), collaborators[1].ID)
4325+
assert.Equal(t, "write", collaborators[1].RoleName)
4326+
})
4327+
}
4328+
}

‎pkg/github/tools.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
207207
ListStarredRepositories(t),
208208
StarRepository(t),
209209
UnstarRepository(t),
210+
ListRepositoryCollaborators(t),
210211

211212
// Git tools
212213
GetRepositoryTree(t),

0 commit comments

Comments
 (0)