From 727751235ad75f13842c7009eab160b60d2e93c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 17:10:04 +0000 Subject: [PATCH 1/2] Initial plan From 7381ad4f2d8ed57d176a233954dbf2dadc19ad9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 17:18:17 +0000 Subject: [PATCH 2/2] Add update_user_profile MCP tool Agent-Logs-Url: https://github.com/github/github-mcp-server/sessions/8180b3b5-6e5d-44bb-bff0-72b375d242ab Co-authored-by: RossTarrant <14926097+RossTarrant@users.noreply.github.com> --- README.md | 11 ++ .../__toolsnaps__/update_user_profile.snap | 44 +++++ pkg/github/context_tools.go | 154 ++++++++++++++++++ pkg/github/context_tools_test.go | 132 +++++++++++++++ pkg/github/helper_test.go | 1 + pkg/github/tools.go | 1 + 6 files changed, 343 insertions(+) create mode 100644 pkg/github/__toolsnaps__/update_user_profile.snap diff --git a/README.md b/README.md index 5f9baa780e..a1aa2479f3 100644 --- a/README.md +++ b/README.md @@ -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) +
diff --git a/pkg/github/__toolsnaps__/update_user_profile.snap b/pkg/github/__toolsnaps__/update_user_profile.snap new file mode 100644 index 0000000000..d1aec9a446 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_user_profile.snap @@ -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" +} \ No newline at end of file diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 902734481a..6ea77aa2b5 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -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" @@ -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, diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 39f2058bec..d9af4218f4 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -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() diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index ff752f5f37..96e27a1c91 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -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" diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 559088f6d6..a76e02f9f5 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -175,6 +175,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { return []inventory.ServerTool{ // Context tools GetMe(t), + UpdateUserProfile(t), GetTeams(t), GetTeamMembers(t),