Skip to content
Open
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,12 @@ Possible options:

<summary>Repositories</summary>

- **add_repository_collaborator** - Add repository collaborator
- `owner`: Repository owner (string, required)
- `permission`: Permission level to grant. Defaults to 'push' when not specified. (string, optional)
- `repo`: Repository name (string, required)
- `username`: Username of the collaborator to add (string, required)

- **create_branch** - Create branch
- `branch`: Name for new branch (string, required)
- `from_branch`: Source branch (defaults to repo default) (string, optional)
Expand Down
40 changes: 40 additions & 0 deletions pkg/github/__toolsnaps__/add_repository_collaborator.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"annotations": {
"title": "Add repository collaborator"
},
"description": "Add a collaborator to a GitHub repository and set their permission level",
"inputSchema": {
"type": "object",
"required": [
"owner",
"repo",
"username"
],
"properties": {
"owner": {
"type": "string",
"description": "Repository owner"
},
"permission": {
"type": "string",
"description": "Permission level to grant. Defaults to 'push' when not specified.",
"enum": [
"pull",
"triage",
"push",
"maintain",
"admin"
]
},
"repo": {
"type": "string",
"description": "Repository name"
},
"username": {
"type": "string",
"description": "Username of the collaborator to add"
}
}
},
"name": "add_repository_collaborator"
}
110 changes: 110 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -2113,3 +2113,113 @@ func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFun

return tool, handler
}

// AddRepositoryCollaborator creates a tool to add a collaborator to a repository with a specific permission level.
func AddRepositoryCollaborator(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, mcp.ToolHandlerFor[map[string]any, any]) {
tool := mcp.Tool{
Name: "add_repository_collaborator",
Description: t("TOOL_ADD_REPOSITORY_COLLABORATOR_DESCRIPTION", "Add a collaborator to a GitHub repository and set their permission level"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_ADD_REPOSITORY_COLLABORATOR_USER_TITLE", "Add repository collaborator"),
ReadOnlyHint: false,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"username": {
Type: "string",
Description: "Username of the collaborator to add",
},
"permission": {
Type: "string",
Description: "Permission level to grant. Defaults to 'push' when not specified.",
Enum: []any{"pull", "triage", "push", "maintain", "admin"},
},
},
Required: []string{"owner", "repo", "username"},
},
}

handler := mcp.ToolHandlerFor[map[string]any, any](func(ctx context.Context, _ *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
}
username, err := RequiredParam[string](args, "username")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
permission, err := OptionalParam[string](args, "permission")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

var opts *github.RepositoryAddCollaboratorOptions
if permission != "" {
opts = &github.RepositoryAddCollaboratorOptions{
Permission: permission,
}
}

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

invitation, resp, err := client.Repositories.AddCollaborator(ctx, owner, repo, username, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to add collaborator %s to %s/%s", username, owner, repo),
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusAccepted {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return utils.NewToolResultError(fmt.Sprintf("failed to add collaborator: %s", string(body))), nil, nil
}

effectivePermission := permission
if effectivePermission == "" && invitation != nil {
effectivePermission = invitation.GetPermissions()
}

var message string
switch resp.StatusCode {
case http.StatusCreated, http.StatusAccepted:
message = fmt.Sprintf("Invitation sent to %s for %s/%s", username, owner, repo)
if effectivePermission != "" {
message += fmt.Sprintf(" with %s permission", effectivePermission)
}
if invitation != nil && invitation.GetID() != 0 {
message += fmt.Sprintf(" (invitation id %d)", invitation.GetID())
}
case http.StatusNoContent:
message = fmt.Sprintf("%s already has access to %s/%s", username, owner, repo)
if effectivePermission != "" {
message += fmt.Sprintf(" (permission %s)", effectivePermission)
}
}

return utils.NewToolResultText(message), nil, nil
})

return tool, handler
}
112 changes: 112 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3293,6 +3293,118 @@ func Test_UnstarRepository(t *testing.T) {
}
}

func Test_AddRepositoryCollaborator(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := AddRepositoryCollaborator(stubGetClientFn(mockClient), translations.NullTranslationHelper)
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, "add_repository_collaborator", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "username")
assert.Contains(t, schema.Properties, "permission")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "username"})

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedErrMsg string
expectedText string
}{
{
name: "invitation created with permission",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PutReposCollaboratorsByOwnerByRepoByUsername,
expect(t, expectations{
path: "/repos/octo/test-repo/collaborators/new-user",
requestBody: map[string]any{
"permission": "maintain",
},
}).andThen(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id": 42, "permissions": "maintain"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "octo",
"repo": "test-repo",
"username": "new-user",
"permission": "maintain",
},
expectedText: "Invitation sent to new-user",
},
{
name: "already collaborator",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PutReposCollaboratorsByOwnerByRepoByUsername,
expectPath(t, "/repos/octo/test-repo/collaborators/existing").andThen(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}),
),
),
requestArgs: map[string]interface{}{
"owner": "octo",
"repo": "test-repo",
"username": "existing",
},
expectedText: "already has access",
},
{
name: "API error",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PutReposCollaboratorsByOwnerByRepoByUsername,
mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}),
),
),
requestArgs: map[string]interface{}{
"owner": "octo",
"repo": "test-repo",
"username": "blocked-user",
"permission": "push",
},
expectError: true,
expectedErrMsg: "failed to add collaborator",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := github.NewClient(tc.mockedClient)
_, handler := AddRepositoryCollaborator(stubGetClientFn(client), translations.NullTranslationHelper)

request := createMCPRequest(tc.requestArgs)
result, _, err := handler(context.Background(), &request, tc.requestArgs)

if tc.expectError {
require.NotNil(t, result)
textResult := getTextResult(t, result)
assert.Contains(t, textResult.Text, tc.expectedErrMsg)
return
}

require.NoError(t, err)
require.NotNil(t, result)

textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedText)
if perm, ok := tc.requestArgs["permission"]; ok && perm != "" {
assert.Contains(t, textContent.Text, perm.(string))
}
})
}
}

func Test_RepositoriesGetRepositoryTree(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(CreateBranch(getClient, t)),
toolsets.NewServerTool(PushFiles(getClient, t)),
toolsets.NewServerTool(DeleteFile(getClient, t)),
toolsets.NewServerTool(AddRepositoryCollaborator(getClient, t)),
).
AddResourceTemplates(
toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)),
Expand Down