Skip to content
Draft
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,17 @@ The following sets of tools are available:
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`
- `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional)

- **update_user_profile** - Update my user profile
- **Required OAuth Scopes**: `user`
- `bio`: The new short biography of the user (string, optional)
- `blog`: The new blog URL of the user (string, optional)
- `company`: The new company of the user (string, optional)
- `email`: The publicly visible email address of the user (string, optional)
- `hireable`: The new hireable value of the user (boolean, optional)
- `location`: The new location of the user (string, optional)
- `name`: The new name of the user (string, optional)
- `twitter_username`: The new Twitter username of the user (string, optional)

</details>

<details>
Expand Down
44 changes: 44 additions & 0 deletions pkg/github/__toolsnaps__/update_user_profile.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"annotations": {
"title": "Update my user profile"
},
"description": "Update the authenticated GitHub user's profile information. At least one field to update must be provided.",
"inputSchema": {
"properties": {
"bio": {
"description": "The new short biography of the user",
"type": "string"
},
"blog": {
"description": "The new blog URL of the user",
"type": "string"
},
"company": {
"description": "The new company of the user",
"type": "string"
},
"email": {
"description": "The publicly visible email address of the user",
"type": "string"
},
"hireable": {
"description": "The new hireable value of the user",
"type": "boolean"
},
"location": {
"description": "The new location of the user",
"type": "string"
},
"name": {
"description": "The new name of the user",
"type": "string"
},
"twitter_username": {
"description": "The new Twitter username of the user",
"type": "string"
}
},
"type": "object"
},
"name": "update_user_profile"
}
154 changes: 154 additions & 0 deletions pkg/github/context_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
gogithub "github.com/google/go-github/v82/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/shurcooL/githubv4"
Expand Down Expand Up @@ -217,6 +218,159 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool {
)
}

// UpdateUserProfile creates a tool to update the authenticated user's profile.
func UpdateUserProfile(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataContext,
mcp.Tool{
Name: "update_user_profile",
Description: t("TOOL_UPDATE_USER_PROFILE_DESCRIPTION", "Update the authenticated GitHub user's profile information. At least one field to update must be provided."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_USER_PROFILE_USER_TITLE", "Update my user profile"),
ReadOnlyHint: false,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: t("TOOL_UPDATE_USER_PROFILE_NAME_DESCRIPTION", "The new name of the user"),
},
"email": {
Type: "string",
Description: t("TOOL_UPDATE_USER_PROFILE_EMAIL_DESCRIPTION", "The publicly visible email address of the user"),
},
"blog": {
Type: "string",
Description: t("TOOL_UPDATE_USER_PROFILE_BLOG_DESCRIPTION", "The new blog URL of the user"),
},
"twitter_username": {
Type: "string",
Description: t("TOOL_UPDATE_USER_PROFILE_TWITTER_USERNAME_DESCRIPTION", "The new Twitter username of the user"),
},
"company": {
Type: "string",
Description: t("TOOL_UPDATE_USER_PROFILE_COMPANY_DESCRIPTION", "The new company of the user"),
},
"location": {
Type: "string",
Description: t("TOOL_UPDATE_USER_PROFILE_LOCATION_DESCRIPTION", "The new location of the user"),
},
"hireable": {
Type: "boolean",
Description: t("TOOL_UPDATE_USER_PROFILE_HIREABLE_DESCRIPTION", "The new hireable value of the user"),
},
"bio": {
Type: "string",
Description: t("TOOL_UPDATE_USER_PROFILE_BIO_DESCRIPTION", "The new short biography of the user"),
},
},
},
},
[]scopes.Scope{scopes.User},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
name, err := OptionalParam[string](args, "name")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
email, err := OptionalParam[string](args, "email")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
blog, err := OptionalParam[string](args, "blog")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
twitterUsername, err := OptionalParam[string](args, "twitter_username")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
company, err := OptionalParam[string](args, "company")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
location, err := OptionalParam[string](args, "location")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
hireable, err := OptionalParam[bool](args, "hireable")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
bio, err := OptionalParam[string](args, "bio")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

// Require at least one field to be set
_, hasHireable := args["hireable"]
if name == "" && email == "" && blog == "" && twitterUsername == "" &&
company == "" && location == "" && !hasHireable && bio == "" {
return utils.NewToolResultError("at least one field to update must be provided"), nil, nil
}

userReq := &gogithub.User{}
if name != "" {
userReq.Name = gogithub.Ptr(name)
}
if email != "" {
userReq.Email = gogithub.Ptr(email)
}
if blog != "" {
userReq.Blog = gogithub.Ptr(blog)
}
if twitterUsername != "" {
userReq.TwitterUsername = gogithub.Ptr(twitterUsername)
}
if company != "" {
userReq.Company = gogithub.Ptr(company)
}
if location != "" {
userReq.Location = gogithub.Ptr(location)
}
if hasHireable {
userReq.Hireable = gogithub.Ptr(hireable)
}
if bio != "" {
userReq.Bio = gogithub.Ptr(bio)
}

client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}

user, res, err := client.Users.Edit(ctx, userReq)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to update user profile",
res,
err,
), nil, nil
}

minimalUser := MinimalUser{
Login: user.GetLogin(),
ID: user.GetID(),
ProfileURL: user.GetHTMLURL(),
AvatarURL: user.GetAvatarURL(),
Details: &UserDetails{
Name: user.GetName(),
Company: user.GetCompany(),
Blog: user.GetBlog(),
Location: user.GetLocation(),
Email: user.GetEmail(),
Hireable: user.GetHireable(),
Bio: user.GetBio(),
TwitterUsername: user.GetTwitterUsername(),
},
}

return MarshalledTextResult(minimalUser), nil, nil
},
)
}

func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
ToolsetMetadataContext,
Expand Down
132 changes: 132 additions & 0 deletions pkg/github/context_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,138 @@ func Test_GetMe(t *testing.T) {
}
}

func Test_UpdateUserProfile(t *testing.T) {
t.Parallel()

serverTool := UpdateUserProfile(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))

// Verify some basic very important properties
assert.Equal(t, "update_user_profile", tool.Name)
assert.False(t, tool.Annotations.ReadOnlyHint, "update_user_profile tool should not be read-only")

// Setup mock updated user response
mockUpdatedUser := &github.User{
Login: github.Ptr("testuser"),
Name: github.Ptr("Updated Name"),
Email: github.Ptr("updated@example.com"),
Bio: github.Ptr("Updated bio"),
Company: github.Ptr("Updated Company"),
Location: github.Ptr("Updated Location"),
Blog: github.Ptr("https://updated.example.com"),
TwitterUsername: github.Ptr("updated_twitter"),
Hireable: github.Ptr(true),
HTMLURL: github.Ptr("https://github.com/testuser"),
}

tests := []struct {
name string
mockedClient *http.Client
clientErr string
requestArgs map[string]any
expectToolError bool
expectedToolErrMsg string
expectedUser *github.User
}{
{
name: "successful update with name",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchUser: mockResponse(t, http.StatusOK, mockUpdatedUser),
}),
requestArgs: map[string]any{"name": "Updated Name"},
expectToolError: false,
expectedUser: mockUpdatedUser,
},
{
name: "successful update with multiple fields",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchUser: mockResponse(t, http.StatusOK, mockUpdatedUser),
}),
requestArgs: map[string]any{
"name": "Updated Name",
"email": "updated@example.com",
"bio": "Updated bio",
"company": "Updated Company",
"location": "Updated Location",
"blog": "https://updated.example.com",
"twitter_username": "updated_twitter",
"hireable": true,
},
expectToolError: false,
expectedUser: mockUpdatedUser,
},
{
name: "no fields provided",
requestArgs: map[string]any{},
expectToolError: true,
expectedToolErrMsg: "at least one field to update must be provided",
},
{
name: "getting client fails",
clientErr: "expected test error",
requestArgs: map[string]any{"name": "Updated Name"},
expectToolError: true,
expectedToolErrMsg: "failed to get GitHub client: expected test error",
},
{
name: "update user profile fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchUser: badRequestHandler("expected test failure"),
}),
requestArgs: map[string]any{"name": "Updated Name"},
expectToolError: true,
expectedToolErrMsg: "expected test failure",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var deps ToolDependencies
if tc.clientErr != "" {
deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr), obsv: stubExporters()}
} else if tc.mockedClient != nil {
obs := stubExporters()
deps = BaseDeps{Client: github.NewClient(tc.mockedClient), Obsv: obs}
} else {
deps = stubDeps{obsv: stubExporters()}
}
handler := serverTool.Handler(deps)

request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)

if tc.expectToolError {
require.True(t, result.IsError, "expected tool call result to be an error")
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
return
}

require.False(t, result.IsError)
textContent := getTextResult(t, result)

var returnedUser MinimalUser
err = json.Unmarshal([]byte(textContent.Text), &returnedUser)
require.NoError(t, err)

assert.Equal(t, *tc.expectedUser.Login, returnedUser.Login)
assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL)

require.NotNil(t, returnedUser.Details)
assert.Equal(t, *tc.expectedUser.Name, returnedUser.Details.Name)
assert.Equal(t, *tc.expectedUser.Email, returnedUser.Details.Email)
assert.Equal(t, *tc.expectedUser.Bio, returnedUser.Details.Bio)
assert.Equal(t, *tc.expectedUser.Company, returnedUser.Details.Company)
assert.Equal(t, *tc.expectedUser.Location, returnedUser.Details.Location)
assert.Equal(t, *tc.expectedUser.Blog, returnedUser.Details.Blog)
assert.Equal(t, *tc.expectedUser.TwitterUsername, returnedUser.Details.TwitterUsername)
assert.Equal(t, *tc.expectedUser.Hireable, returnedUser.Details.Hireable)
})
}
}

func Test_GetTeams(t *testing.T) {
t.Parallel()

Expand Down
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
const (
// User endpoints
GetUser = "GET /user"
PatchUser = "PATCH /user"
GetUserStarred = "GET /user/starred"
GetUsersGistsByUsername = "GET /users/{username}/gists"
GetUsersStarredByUsername = "GET /users/{username}/starred"
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
return []inventory.ServerTool{
// Context tools
GetMe(t),
UpdateUserProfile(t),
GetTeams(t),
GetTeamMembers(t),

Expand Down