Skip to content

Commit 0c90263

Browse files
committed
Add tool to list repo collaborators
1 parent fbf68b2 commit 0c90263

6 files changed

Lines changed: 281 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 (min 1) (number, optional)
1264+
- `perPage`: Results per page for pagination (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. Lists users who have access to the repository. Includes their username, ID, and role name.",
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 (min 1)",
24+
"minimum": 1,
25+
"type": "number"
26+
},
27+
"perPage": {
28+
"description": "Results per page for pagination (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/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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2240,3 +2240,103 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool
22402240
},
22412241
)
22422242
}
2243+
2244+
// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository.
2245+
func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool {
2246+
return NewTool(
2247+
ToolsetMetadataRepos,
2248+
mcp.Tool{
2249+
Name: "list_repository_collaborators",
2250+
Description: t("TOOL_LIST_REPOSITORY_COLLABORATORS_DESCRIPTION", "List collaborators of a GitHub repository. Lists users who have access to the repository. Includes their username, ID, and role name."),
2251+
Annotations: &mcp.ToolAnnotations{
2252+
Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"),
2253+
ReadOnlyHint: true,
2254+
},
2255+
InputSchema: WithPagination(&jsonschema.Schema{
2256+
Type: "object",
2257+
Properties: map[string]*jsonschema.Schema{
2258+
"owner": {
2259+
Type: "string",
2260+
Description: "Repository owner",
2261+
},
2262+
"repo": {
2263+
Type: "string",
2264+
Description: "Repository name",
2265+
},
2266+
"affiliation": {
2267+
Type: "string",
2268+
Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'",
2269+
Enum: []any{"outside", "direct", "all"},
2270+
},
2271+
},
2272+
Required: []string{"owner", "repo"},
2273+
}),
2274+
},
2275+
[]scopes.Scope{scopes.Repo},
2276+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
2277+
owner, err := RequiredParam[string](args, "owner")
2278+
if err != nil {
2279+
return utils.NewToolResultError(err.Error()), nil, nil
2280+
}
2281+
repo, err := RequiredParam[string](args, "repo")
2282+
if err != nil {
2283+
return utils.NewToolResultError(err.Error()), nil, nil
2284+
}
2285+
affiliation, err := OptionalParam[string](args, "affiliation")
2286+
if err != nil {
2287+
return utils.NewToolResultError(err.Error()), nil, nil
2288+
}
2289+
pagination, err := OptionalPaginationParams(args)
2290+
if err != nil {
2291+
return utils.NewToolResultError(err.Error()), nil, nil
2292+
}
2293+
2294+
client, err := deps.GetClient(ctx)
2295+
if err != nil {
2296+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
2297+
}
2298+
2299+
opts := &github.ListCollaboratorsOptions{
2300+
Affiliation: affiliation,
2301+
ListOptions: github.ListOptions{
2302+
Page: pagination.Page,
2303+
PerPage: pagination.PerPage,
2304+
},
2305+
}
2306+
2307+
collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts)
2308+
if err != nil {
2309+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
2310+
"failed to list collaborators",
2311+
resp,
2312+
err,
2313+
), nil, nil
2314+
}
2315+
defer func() { _ = resp.Body.Close() }()
2316+
2317+
if resp.StatusCode != http.StatusOK {
2318+
body, err := io.ReadAll(resp.Body)
2319+
if err != nil {
2320+
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
2321+
}
2322+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil
2323+
}
2324+
2325+
var result []MinimalCollaborator
2326+
for _, c := range collaborators {
2327+
result = append(result, MinimalCollaborator{
2328+
Login: c.GetLogin(),
2329+
ID: c.GetID(),
2330+
RoleName: c.GetRoleName(),
2331+
})
2332+
}
2333+
2334+
r, err := json.Marshal(result)
2335+
if err != nil {
2336+
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
2337+
}
2338+
2339+
return utils.NewToolResultText(string(r)), nil, nil
2340+
},
2341+
)
2342+
}

pkg/github/repositories_test.go

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

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)