Skip to content

Commit 46d220f

Browse files
authored
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 b8be4c1 commit 46d220f

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
@@ -154,6 +154,13 @@ type MinimalResponse struct {
154154
URL string `json:"url"`
155155
}
156156

157+
// MinimalCollaborator is the trimmed output type for repository collaborators.
158+
type MinimalCollaborator struct {
159+
Login string `json:"login"`
160+
ID int64 `json:"id"`
161+
RoleName string `json:"role_name"`
162+
}
163+
157164
type MinimalProject struct {
158165
ID *int64 `json:"id,omitempty"`
159166
NodeID *string `json:"node_id,omitempty"`

pkg/github/repositories.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2202,3 +2202,111 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool
22022202
},
22032203
)
22042204
}
2205+
2206+
// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository.
2207+
func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool {
2208+
return NewTool(
2209+
ToolsetMetadataRepos,
2210+
mcp.Tool{
2211+
Name: "list_repository_collaborators",
2212+
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."),
2213+
Annotations: &mcp.ToolAnnotations{
2214+
Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"),
2215+
ReadOnlyHint: true,
2216+
},
2217+
InputSchema: func() *jsonschema.Schema {
2218+
schema := WithPagination(&jsonschema.Schema{
2219+
Type: "object",
2220+
Properties: map[string]*jsonschema.Schema{
2221+
"owner": {
2222+
Type: "string",
2223+
Description: "Repository owner",
2224+
},
2225+
"repo": {
2226+
Type: "string",
2227+
Description: "Repository name",
2228+
},
2229+
"affiliation": {
2230+
Type: "string",
2231+
Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'",
2232+
Enum: []any{"outside", "direct", "all"},
2233+
},
2234+
},
2235+
Required: []string{"owner", "repo"},
2236+
})
2237+
schema.Properties["page"].Description = "Page number for pagination (default 1, min 1)"
2238+
schema.Properties["perPage"].Description = "Results per page for pagination (default 30, min 1, max 100)"
2239+
return schema
2240+
}(),
2241+
},
2242+
[]scopes.Scope{scopes.Repo},
2243+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
2244+
owner, err := RequiredParam[string](args, "owner")
2245+
if err != nil {
2246+
return utils.NewToolResultError(err.Error()), nil, nil
2247+
}
2248+
repo, err := RequiredParam[string](args, "repo")
2249+
if err != nil {
2250+
return utils.NewToolResultError(err.Error()), nil, nil
2251+
}
2252+
affiliation, err := OptionalParam[string](args, "affiliation")
2253+
if err != nil {
2254+
return utils.NewToolResultError(err.Error()), nil, nil
2255+
}
2256+
pagination, err := OptionalPaginationParams(args)
2257+
if err != nil {
2258+
return utils.NewToolResultError(err.Error()), nil, nil
2259+
}
2260+
2261+
client, err := deps.GetClient(ctx)
2262+
if err != nil {
2263+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
2264+
}
2265+
2266+
opts := &github.ListCollaboratorsOptions{
2267+
Affiliation: affiliation,
2268+
ListOptions: github.ListOptions{
2269+
Page: pagination.Page,
2270+
PerPage: pagination.PerPage,
2271+
},
2272+
}
2273+
2274+
collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts)
2275+
if err != nil {
2276+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
2277+
"failed to list collaborators",
2278+
resp,
2279+
err,
2280+
), nil, nil
2281+
}
2282+
defer func() { _ = resp.Body.Close() }()
2283+
2284+
if resp.StatusCode != http.StatusOK {
2285+
body, err := io.ReadAll(resp.Body)
2286+
if err != nil {
2287+
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
2288+
}
2289+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil
2290+
}
2291+
2292+
result := make([]MinimalCollaborator, 0, len(collaborators))
2293+
for _, c := range collaborators {
2294+
result = append(result, MinimalCollaborator{
2295+
Login: c.GetLogin(),
2296+
ID: c.GetID(),
2297+
RoleName: c.GetRoleName(),
2298+
})
2299+
}
2300+
2301+
response := map[string]any{
2302+
"items": result,
2303+
"nextPage": resp.NextPage,
2304+
"prevPage": resp.PrevPage,
2305+
"firstPage": resp.FirstPage,
2306+
"lastPage": resp.LastPage,
2307+
}
2308+
2309+
return MarshalledTextResult(response), nil, nil
2310+
},
2311+
)
2312+
}

pkg/github/repositories_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4368,3 +4368,149 @@ func Test_UnstarRepository(t *testing.T) {
43684368
})
43694369
}
43704370
}
4371+
4372+
func Test_ListRepositoryCollaborators(t *testing.T) {
4373+
// Verify tool definition once
4374+
serverTool := ListRepositoryCollaborators(translations.NullTranslationHelper)
4375+
tool := serverTool.Tool
4376+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
4377+
4378+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
4379+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
4380+
4381+
assert.Equal(t, "list_repository_collaborators", tool.Name)
4382+
assert.NotEmpty(t, tool.Description)
4383+
assert.True(t, tool.Annotations.ReadOnlyHint)
4384+
assert.Contains(t, schema.Properties, "owner")
4385+
assert.Contains(t, schema.Properties, "repo")
4386+
assert.Contains(t, schema.Properties, "affiliation")
4387+
assert.Contains(t, schema.Properties, "page")
4388+
assert.Contains(t, schema.Properties, "perPage")
4389+
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
4390+
4391+
mockCollaborators := []*github.User{
4392+
{
4393+
Login: github.Ptr("user1"),
4394+
ID: github.Ptr(int64(101)),
4395+
RoleName: github.Ptr("admin"),
4396+
},
4397+
{
4398+
Login: github.Ptr("user2"),
4399+
ID: github.Ptr(int64(102)),
4400+
RoleName: github.Ptr("write"),
4401+
},
4402+
}
4403+
4404+
tests := []struct {
4405+
name string
4406+
args map[string]any
4407+
mockResponses []MockBackendOption
4408+
wantErr bool
4409+
errContains string
4410+
}{
4411+
{
4412+
name: "success",
4413+
args: map[string]any{
4414+
"owner": "owner",
4415+
"repo": "repo",
4416+
},
4417+
mockResponses: []MockBackendOption{
4418+
WithRequestMatch(
4419+
ListCollaborators,
4420+
mockCollaborators,
4421+
),
4422+
},
4423+
},
4424+
{
4425+
name: "success with affiliation filter",
4426+
args: map[string]any{
4427+
"owner": "owner",
4428+
"repo": "repo",
4429+
"affiliation": "direct",
4430+
},
4431+
mockResponses: []MockBackendOption{
4432+
WithRequestMatch(
4433+
ListCollaborators,
4434+
mockCollaborators,
4435+
),
4436+
},
4437+
},
4438+
{
4439+
name: "missing owner",
4440+
args: map[string]any{
4441+
"repo": "repo",
4442+
},
4443+
mockResponses: []MockBackendOption{},
4444+
errContains: "missing required parameter: owner",
4445+
},
4446+
{
4447+
name: "missing repo",
4448+
args: map[string]any{
4449+
"owner": "owner",
4450+
},
4451+
mockResponses: []MockBackendOption{},
4452+
errContains: "missing required parameter: repo",
4453+
},
4454+
{
4455+
name: "empty collaborators returns empty array",
4456+
args: map[string]any{
4457+
"owner": "owner",
4458+
"repo": "repo",
4459+
},
4460+
mockResponses: []MockBackendOption{
4461+
WithRequestMatch(
4462+
ListCollaborators,
4463+
[]*github.User{},
4464+
),
4465+
},
4466+
},
4467+
}
4468+
4469+
for _, tt := range tests {
4470+
t.Run(tt.name, func(t *testing.T) {
4471+
mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...))
4472+
deps := BaseDeps{
4473+
Client: mockClient,
4474+
}
4475+
handler := serverTool.Handler(deps)
4476+
4477+
request := createMCPRequest(tt.args)
4478+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
4479+
require.NoError(t, err)
4480+
require.NotNil(t, result)
4481+
4482+
if tt.errContains != "" {
4483+
textContent := getTextResult(t, result)
4484+
assert.Contains(t, textContent.Text, tt.errContains)
4485+
return
4486+
}
4487+
4488+
textContent := getTextResult(t, result)
4489+
require.NotEmpty(t, textContent.Text)
4490+
4491+
var response struct {
4492+
Items []MinimalCollaborator `json:"items"`
4493+
NextPage int `json:"nextPage"`
4494+
PrevPage int `json:"prevPage"`
4495+
FirstPage int `json:"firstPage"`
4496+
LastPage int `json:"lastPage"`
4497+
}
4498+
err = json.Unmarshal([]byte(textContent.Text), &response)
4499+
require.NoError(t, err)
4500+
4501+
if tt.name == "empty collaborators returns empty array" {
4502+
assert.Empty(t, response.Items)
4503+
return
4504+
}
4505+
4506+
collaborators := response.Items
4507+
assert.Len(t, collaborators, 2)
4508+
assert.Equal(t, "user1", collaborators[0].Login)
4509+
assert.Equal(t, int64(101), collaborators[0].ID)
4510+
assert.Equal(t, "admin", collaborators[0].RoleName)
4511+
assert.Equal(t, "user2", collaborators[1].Login)
4512+
assert.Equal(t, int64(102), collaborators[1].ID)
4513+
assert.Equal(t, "write", collaborators[1].RoleName)
4514+
})
4515+
}
4516+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
199199
ListStarredRepositories(t),
200200
StarRepository(t),
201201
UnstarRepository(t),
202+
ListRepositoryCollaborators(t),
202203

203204
// Git tools
204205
GetRepositoryTree(t),

0 commit comments

Comments
 (0)