Skip to content

Commit 7381ad4

Browse files
CopilotRossTarrant
andauthored
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>
1 parent 7277512 commit 7381ad4

6 files changed

Lines changed: 343 additions & 0 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,17 @@ The following sets of tools are available:
683683
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`
684684
- `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional)
685685

686+
- **update_user_profile** - Update my user profile
687+
- **Required OAuth Scopes**: `user`
688+
- `bio`: The new short biography of the user (string, optional)
689+
- `blog`: The new blog URL of the user (string, optional)
690+
- `company`: The new company of the user (string, optional)
691+
- `email`: The publicly visible email address of the user (string, optional)
692+
- `hireable`: The new hireable value of the user (boolean, optional)
693+
- `location`: The new location of the user (string, optional)
694+
- `name`: The new name of the user (string, optional)
695+
- `twitter_username`: The new Twitter username of the user (string, optional)
696+
686697
</details>
687698

688699
<details>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"annotations": {
3+
"title": "Update my user profile"
4+
},
5+
"description": "Update the authenticated GitHub user's profile information. At least one field to update must be provided.",
6+
"inputSchema": {
7+
"properties": {
8+
"bio": {
9+
"description": "The new short biography of the user",
10+
"type": "string"
11+
},
12+
"blog": {
13+
"description": "The new blog URL of the user",
14+
"type": "string"
15+
},
16+
"company": {
17+
"description": "The new company of the user",
18+
"type": "string"
19+
},
20+
"email": {
21+
"description": "The publicly visible email address of the user",
22+
"type": "string"
23+
},
24+
"hireable": {
25+
"description": "The new hireable value of the user",
26+
"type": "boolean"
27+
},
28+
"location": {
29+
"description": "The new location of the user",
30+
"type": "string"
31+
},
32+
"name": {
33+
"description": "The new name of the user",
34+
"type": "string"
35+
},
36+
"twitter_username": {
37+
"description": "The new Twitter username of the user",
38+
"type": "string"
39+
}
40+
},
41+
"type": "object"
42+
},
43+
"name": "update_user_profile"
44+
}

pkg/github/context_tools.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/github/github-mcp-server/pkg/scopes"
1111
"github.com/github/github-mcp-server/pkg/translations"
1212
"github.com/github/github-mcp-server/pkg/utils"
13+
gogithub "github.com/google/go-github/v82/github"
1314
"github.com/google/jsonschema-go/jsonschema"
1415
"github.com/modelcontextprotocol/go-sdk/mcp"
1516
"github.com/shurcooL/githubv4"
@@ -217,6 +218,159 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool {
217218
)
218219
}
219220

221+
// UpdateUserProfile creates a tool to update the authenticated user's profile.
222+
func UpdateUserProfile(t translations.TranslationHelperFunc) inventory.ServerTool {
223+
return NewTool(
224+
ToolsetMetadataContext,
225+
mcp.Tool{
226+
Name: "update_user_profile",
227+
Description: t("TOOL_UPDATE_USER_PROFILE_DESCRIPTION", "Update the authenticated GitHub user's profile information. At least one field to update must be provided."),
228+
Annotations: &mcp.ToolAnnotations{
229+
Title: t("TOOL_UPDATE_USER_PROFILE_USER_TITLE", "Update my user profile"),
230+
ReadOnlyHint: false,
231+
},
232+
InputSchema: &jsonschema.Schema{
233+
Type: "object",
234+
Properties: map[string]*jsonschema.Schema{
235+
"name": {
236+
Type: "string",
237+
Description: t("TOOL_UPDATE_USER_PROFILE_NAME_DESCRIPTION", "The new name of the user"),
238+
},
239+
"email": {
240+
Type: "string",
241+
Description: t("TOOL_UPDATE_USER_PROFILE_EMAIL_DESCRIPTION", "The publicly visible email address of the user"),
242+
},
243+
"blog": {
244+
Type: "string",
245+
Description: t("TOOL_UPDATE_USER_PROFILE_BLOG_DESCRIPTION", "The new blog URL of the user"),
246+
},
247+
"twitter_username": {
248+
Type: "string",
249+
Description: t("TOOL_UPDATE_USER_PROFILE_TWITTER_USERNAME_DESCRIPTION", "The new Twitter username of the user"),
250+
},
251+
"company": {
252+
Type: "string",
253+
Description: t("TOOL_UPDATE_USER_PROFILE_COMPANY_DESCRIPTION", "The new company of the user"),
254+
},
255+
"location": {
256+
Type: "string",
257+
Description: t("TOOL_UPDATE_USER_PROFILE_LOCATION_DESCRIPTION", "The new location of the user"),
258+
},
259+
"hireable": {
260+
Type: "boolean",
261+
Description: t("TOOL_UPDATE_USER_PROFILE_HIREABLE_DESCRIPTION", "The new hireable value of the user"),
262+
},
263+
"bio": {
264+
Type: "string",
265+
Description: t("TOOL_UPDATE_USER_PROFILE_BIO_DESCRIPTION", "The new short biography of the user"),
266+
},
267+
},
268+
},
269+
},
270+
[]scopes.Scope{scopes.User},
271+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
272+
name, err := OptionalParam[string](args, "name")
273+
if err != nil {
274+
return utils.NewToolResultError(err.Error()), nil, nil
275+
}
276+
email, err := OptionalParam[string](args, "email")
277+
if err != nil {
278+
return utils.NewToolResultError(err.Error()), nil, nil
279+
}
280+
blog, err := OptionalParam[string](args, "blog")
281+
if err != nil {
282+
return utils.NewToolResultError(err.Error()), nil, nil
283+
}
284+
twitterUsername, err := OptionalParam[string](args, "twitter_username")
285+
if err != nil {
286+
return utils.NewToolResultError(err.Error()), nil, nil
287+
}
288+
company, err := OptionalParam[string](args, "company")
289+
if err != nil {
290+
return utils.NewToolResultError(err.Error()), nil, nil
291+
}
292+
location, err := OptionalParam[string](args, "location")
293+
if err != nil {
294+
return utils.NewToolResultError(err.Error()), nil, nil
295+
}
296+
hireable, err := OptionalParam[bool](args, "hireable")
297+
if err != nil {
298+
return utils.NewToolResultError(err.Error()), nil, nil
299+
}
300+
bio, err := OptionalParam[string](args, "bio")
301+
if err != nil {
302+
return utils.NewToolResultError(err.Error()), nil, nil
303+
}
304+
305+
// Require at least one field to be set
306+
_, hasHireable := args["hireable"]
307+
if name == "" && email == "" && blog == "" && twitterUsername == "" &&
308+
company == "" && location == "" && !hasHireable && bio == "" {
309+
return utils.NewToolResultError("at least one field to update must be provided"), nil, nil
310+
}
311+
312+
userReq := &gogithub.User{}
313+
if name != "" {
314+
userReq.Name = gogithub.Ptr(name)
315+
}
316+
if email != "" {
317+
userReq.Email = gogithub.Ptr(email)
318+
}
319+
if blog != "" {
320+
userReq.Blog = gogithub.Ptr(blog)
321+
}
322+
if twitterUsername != "" {
323+
userReq.TwitterUsername = gogithub.Ptr(twitterUsername)
324+
}
325+
if company != "" {
326+
userReq.Company = gogithub.Ptr(company)
327+
}
328+
if location != "" {
329+
userReq.Location = gogithub.Ptr(location)
330+
}
331+
if hasHireable {
332+
userReq.Hireable = gogithub.Ptr(hireable)
333+
}
334+
if bio != "" {
335+
userReq.Bio = gogithub.Ptr(bio)
336+
}
337+
338+
client, err := deps.GetClient(ctx)
339+
if err != nil {
340+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
341+
}
342+
343+
user, res, err := client.Users.Edit(ctx, userReq)
344+
if err != nil {
345+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
346+
"failed to update user profile",
347+
res,
348+
err,
349+
), nil, nil
350+
}
351+
352+
minimalUser := MinimalUser{
353+
Login: user.GetLogin(),
354+
ID: user.GetID(),
355+
ProfileURL: user.GetHTMLURL(),
356+
AvatarURL: user.GetAvatarURL(),
357+
Details: &UserDetails{
358+
Name: user.GetName(),
359+
Company: user.GetCompany(),
360+
Blog: user.GetBlog(),
361+
Location: user.GetLocation(),
362+
Email: user.GetEmail(),
363+
Hireable: user.GetHireable(),
364+
Bio: user.GetBio(),
365+
TwitterUsername: user.GetTwitterUsername(),
366+
},
367+
}
368+
369+
return MarshalledTextResult(minimalUser), nil, nil
370+
},
371+
)
372+
}
373+
220374
func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool {
221375
return NewTool(
222376
ToolsetMetadataContext,

pkg/github/context_tools_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,138 @@ func Test_GetMe(t *testing.T) {
139139
}
140140
}
141141

142+
func Test_UpdateUserProfile(t *testing.T) {
143+
t.Parallel()
144+
145+
serverTool := UpdateUserProfile(translations.NullTranslationHelper)
146+
tool := serverTool.Tool
147+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
148+
149+
// Verify some basic very important properties
150+
assert.Equal(t, "update_user_profile", tool.Name)
151+
assert.False(t, tool.Annotations.ReadOnlyHint, "update_user_profile tool should not be read-only")
152+
153+
// Setup mock updated user response
154+
mockUpdatedUser := &github.User{
155+
Login: github.Ptr("testuser"),
156+
Name: github.Ptr("Updated Name"),
157+
Email: github.Ptr("updated@example.com"),
158+
Bio: github.Ptr("Updated bio"),
159+
Company: github.Ptr("Updated Company"),
160+
Location: github.Ptr("Updated Location"),
161+
Blog: github.Ptr("https://updated.example.com"),
162+
TwitterUsername: github.Ptr("updated_twitter"),
163+
Hireable: github.Ptr(true),
164+
HTMLURL: github.Ptr("https://github.com/testuser"),
165+
}
166+
167+
tests := []struct {
168+
name string
169+
mockedClient *http.Client
170+
clientErr string
171+
requestArgs map[string]any
172+
expectToolError bool
173+
expectedToolErrMsg string
174+
expectedUser *github.User
175+
}{
176+
{
177+
name: "successful update with name",
178+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
179+
PatchUser: mockResponse(t, http.StatusOK, mockUpdatedUser),
180+
}),
181+
requestArgs: map[string]any{"name": "Updated Name"},
182+
expectToolError: false,
183+
expectedUser: mockUpdatedUser,
184+
},
185+
{
186+
name: "successful update with multiple fields",
187+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
188+
PatchUser: mockResponse(t, http.StatusOK, mockUpdatedUser),
189+
}),
190+
requestArgs: map[string]any{
191+
"name": "Updated Name",
192+
"email": "updated@example.com",
193+
"bio": "Updated bio",
194+
"company": "Updated Company",
195+
"location": "Updated Location",
196+
"blog": "https://updated.example.com",
197+
"twitter_username": "updated_twitter",
198+
"hireable": true,
199+
},
200+
expectToolError: false,
201+
expectedUser: mockUpdatedUser,
202+
},
203+
{
204+
name: "no fields provided",
205+
requestArgs: map[string]any{},
206+
expectToolError: true,
207+
expectedToolErrMsg: "at least one field to update must be provided",
208+
},
209+
{
210+
name: "getting client fails",
211+
clientErr: "expected test error",
212+
requestArgs: map[string]any{"name": "Updated Name"},
213+
expectToolError: true,
214+
expectedToolErrMsg: "failed to get GitHub client: expected test error",
215+
},
216+
{
217+
name: "update user profile fails",
218+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
219+
PatchUser: badRequestHandler("expected test failure"),
220+
}),
221+
requestArgs: map[string]any{"name": "Updated Name"},
222+
expectToolError: true,
223+
expectedToolErrMsg: "expected test failure",
224+
},
225+
}
226+
227+
for _, tc := range tests {
228+
t.Run(tc.name, func(t *testing.T) {
229+
var deps ToolDependencies
230+
if tc.clientErr != "" {
231+
deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr), obsv: stubExporters()}
232+
} else if tc.mockedClient != nil {
233+
obs := stubExporters()
234+
deps = BaseDeps{Client: github.NewClient(tc.mockedClient), Obsv: obs}
235+
} else {
236+
deps = stubDeps{obsv: stubExporters()}
237+
}
238+
handler := serverTool.Handler(deps)
239+
240+
request := createMCPRequest(tc.requestArgs)
241+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
242+
require.NoError(t, err)
243+
244+
if tc.expectToolError {
245+
require.True(t, result.IsError, "expected tool call result to be an error")
246+
errorContent := getErrorResult(t, result)
247+
assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
248+
return
249+
}
250+
251+
require.False(t, result.IsError)
252+
textContent := getTextResult(t, result)
253+
254+
var returnedUser MinimalUser
255+
err = json.Unmarshal([]byte(textContent.Text), &returnedUser)
256+
require.NoError(t, err)
257+
258+
assert.Equal(t, *tc.expectedUser.Login, returnedUser.Login)
259+
assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL)
260+
261+
require.NotNil(t, returnedUser.Details)
262+
assert.Equal(t, *tc.expectedUser.Name, returnedUser.Details.Name)
263+
assert.Equal(t, *tc.expectedUser.Email, returnedUser.Details.Email)
264+
assert.Equal(t, *tc.expectedUser.Bio, returnedUser.Details.Bio)
265+
assert.Equal(t, *tc.expectedUser.Company, returnedUser.Details.Company)
266+
assert.Equal(t, *tc.expectedUser.Location, returnedUser.Details.Location)
267+
assert.Equal(t, *tc.expectedUser.Blog, returnedUser.Details.Blog)
268+
assert.Equal(t, *tc.expectedUser.TwitterUsername, returnedUser.Details.TwitterUsername)
269+
assert.Equal(t, *tc.expectedUser.Hireable, returnedUser.Details.Hireable)
270+
})
271+
}
272+
}
273+
142274
func Test_GetTeams(t *testing.T) {
143275
t.Parallel()
144276

pkg/github/helper_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
const (
2222
// User endpoints
2323
GetUser = "GET /user"
24+
PatchUser = "PATCH /user"
2425
GetUserStarred = "GET /user/starred"
2526
GetUsersGistsByUsername = "GET /users/{username}/gists"
2627
GetUsersStarredByUsername = "GET /users/{username}/starred"

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
175175
return []inventory.ServerTool{
176176
// Context tools
177177
GetMe(t),
178+
UpdateUserProfile(t),
178179
GetTeams(t),
179180
GetTeamMembers(t),
180181

0 commit comments

Comments
 (0)