Skip to content

Commit fed1050

Browse files
committed
feat: add get_user tool to fetch user by username (#1970)
Add a new `get_user` MCP tool that retrieves a GitHub user's profile by username using the public `GET /users/{username}` endpoint. Returns a MinimalUser with UserDetails.
1 parent b1575ed commit fed1050

File tree

7 files changed

+285
-8
lines changed

7 files changed

+285
-8
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,9 @@ The following sets of tools are available:
13801380

13811381
<summary><picture><source media="(prefers-color-scheme: dark)" srcset="pkg/octicons/icons/people-dark.png"><source media="(prefers-color-scheme: light)" srcset="pkg/octicons/icons/people-light.png"><img src="pkg/octicons/icons/people-light.png" width="20" height="20" alt="people"></picture> Users</summary>
13821382

1383+
- **get_user** - Get a user by username
1384+
- `username`: Username of the user (string, required)
1385+
13831386
- **search_users** - Search users
13841387
- **Required OAuth Scopes**: `repo`
13851388
- `order`: Sort order (string, optional)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "Get a user by username"
5+
},
6+
"description": "Get user by username. Use this when you need information about specific GitHub user.",
7+
"inputSchema": {
8+
"properties": {
9+
"username": {
10+
"description": "Username of the user",
11+
"type": "string"
12+
}
13+
},
14+
"required": [
15+
"username"
16+
],
17+
"type": "object"
18+
},
19+
"name": "get_user"
20+
}

pkg/github/context_tools_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func Test_GetMe(t *testing.T) {
5757
{
5858
name: "successful get user",
5959
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
60-
GetUser: mockResponse(t, http.StatusOK, mockUser),
60+
GetAuthenticatedUser: mockResponse(t, http.StatusOK, mockUser),
6161
}),
6262
requestArgs: map[string]any{},
6363
expectToolError: false,
@@ -66,7 +66,7 @@ func Test_GetMe(t *testing.T) {
6666
{
6767
name: "successful get user with reason",
6868
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
69-
GetUser: mockResponse(t, http.StatusOK, mockUser),
69+
GetAuthenticatedUser: mockResponse(t, http.StatusOK, mockUser),
7070
}),
7171
requestArgs: map[string]any{
7272
"reason": "Testing API",
@@ -84,7 +84,7 @@ func Test_GetMe(t *testing.T) {
8484
{
8585
name: "get user fails",
8686
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
87-
GetUser: badRequestHandler("expected test failure"),
87+
GetAuthenticatedUser: badRequestHandler("expected test failure"),
8888
}),
8989
requestArgs: map[string]any{},
9090
expectToolError: true,
@@ -246,13 +246,13 @@ func Test_GetTeams(t *testing.T) {
246246
// Factory function for mock HTTP clients with user response
247247
httpClientWithUser := func() *http.Client {
248248
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
249-
GetUser: mockResponse(t, http.StatusOK, mockUser),
249+
GetAuthenticatedUser: mockResponse(t, http.StatusOK, mockUser),
250250
})
251251
}
252252

253253
httpClientUserFails := func() *http.Client {
254254
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
255-
GetUser: badRequestHandler("expected test failure"),
255+
GetAuthenticatedUser: badRequestHandler("expected test failure"),
256256
})
257257
}
258258

pkg/github/helper_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ import (
2020
// These constants define the URL patterns used in HTTP mocking for tests
2121
const (
2222
// User endpoints
23-
GetUser = "GET /user"
23+
GetAuthenticatedUser = "GET /user"
2424
GetUserStarred = "GET /user/starred"
25+
GetUserByUsername = "GET /users/{username}"
2526
GetUsersGistsByUsername = "GET /users/{username}/gists"
2627
GetUsersStarredByUsername = "GET /users/{username}/starred"
2728
PutUserStarredByOwnerByRepo = "PUT /user/starred/{owner}/{repo}"

pkg/github/tools.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import (
55
"slices"
66
"strings"
77

8-
"github.com/github/github-mcp-server/pkg/inventory"
9-
"github.com/github/github-mcp-server/pkg/translations"
108
"github.com/google/go-github/v82/github"
119
"github.com/shurcooL/githubv4"
10+
11+
"github.com/github/github-mcp-server/pkg/inventory"
12+
"github.com/github/github-mcp-server/pkg/translations"
1213
)
1314

1415
type GetClientFn func(context.Context) (*github.Client, error)
@@ -199,6 +200,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
199200
SubIssueWrite(t),
200201

201202
// User tools
203+
GetUser(t),
202204
SearchUsers(t),
203205

204206
// Organization tools

pkg/github/users.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package github
2+
3+
import (
4+
"context"
5+
6+
"github.com/google/jsonschema-go/jsonschema"
7+
"github.com/modelcontextprotocol/go-sdk/mcp"
8+
9+
ghErrors "github.com/github/github-mcp-server/pkg/errors"
10+
"github.com/github/github-mcp-server/pkg/inventory"
11+
"github.com/github/github-mcp-server/pkg/translations"
12+
"github.com/github/github-mcp-server/pkg/utils"
13+
)
14+
15+
// GetUser creates a tool to get a user by username.
16+
func GetUser(t translations.TranslationHelperFunc) inventory.ServerTool {
17+
return NewTool(
18+
ToolsetMetadataUsers,
19+
mcp.Tool{
20+
Name: "get_user",
21+
Description: t("TOOL_GET_USER_DESCRIPTION", "Get user by username. Use this when you need information about specific GitHub user."),
22+
Annotations: &mcp.ToolAnnotations{
23+
Title: t("TOOL_GET_USER_TITLE", "Get a user by username"),
24+
ReadOnlyHint: true,
25+
},
26+
InputSchema: &jsonschema.Schema{
27+
Type: "object",
28+
Properties: map[string]*jsonschema.Schema{
29+
"username": {
30+
Type: "string",
31+
Description: t("TOOL_GET_USER_USERNAME_DESCRIPTION", "Username of the user"),
32+
},
33+
},
34+
Required: []string{"username"},
35+
},
36+
},
37+
nil,
38+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
39+
return getUserHandler(ctx, deps, args)
40+
},
41+
)
42+
}
43+
44+
func getUserHandler(ctx context.Context, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, any, error) {
45+
username, err := RequiredParam[string](args, "username")
46+
if err != nil {
47+
return utils.NewToolResultError(err.Error()), nil, nil
48+
}
49+
50+
client, err := deps.GetClient(ctx)
51+
if err != nil {
52+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
53+
}
54+
55+
user, resp, err := client.Users.Get(ctx, username)
56+
if err != nil {
57+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
58+
"failed to get user",
59+
resp,
60+
err,
61+
), nil, nil
62+
}
63+
64+
minimalUser := MinimalUser{
65+
Login: user.GetLogin(),
66+
ID: user.GetID(),
67+
ProfileURL: user.GetHTMLURL(),
68+
AvatarURL: user.GetAvatarURL(),
69+
Details: &UserDetails{
70+
Name: user.GetName(),
71+
Company: user.GetCompany(),
72+
Blog: user.GetBlog(),
73+
Location: user.GetLocation(),
74+
Email: user.GetEmail(),
75+
Hireable: user.GetHireable(),
76+
Bio: user.GetBio(),
77+
TwitterUsername: user.GetTwitterUsername(),
78+
PublicRepos: user.GetPublicRepos(),
79+
PublicGists: user.GetPublicGists(),
80+
Followers: user.GetFollowers(),
81+
Following: user.GetFollowing(),
82+
CreatedAt: user.GetCreatedAt().Time,
83+
UpdatedAt: user.GetUpdatedAt().Time,
84+
PrivateGists: user.GetPrivateGists(),
85+
TotalPrivateRepos: user.GetTotalPrivateRepos(),
86+
OwnedPrivateRepos: user.GetOwnedPrivateRepos(),
87+
},
88+
}
89+
90+
return MarshalledTextResult(minimalUser), nil, nil
91+
}

pkg/github/users_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"github.com/google/go-github/v82/github"
11+
"github.com/google/jsonschema-go/jsonschema"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
15+
"github.com/github/github-mcp-server/internal/toolsnaps"
16+
"github.com/github/github-mcp-server/pkg/translations"
17+
)
18+
19+
func Test_GetUser(t *testing.T) {
20+
// Verify tool definition once
21+
serverTool := GetUser(translations.NullTranslationHelper)
22+
tool := serverTool.Tool
23+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
24+
25+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
26+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
27+
28+
assert.Equal(t, "get_user", tool.Name)
29+
assert.NotEmpty(t, tool.Description)
30+
assert.Contains(t, schema.Properties, "username")
31+
assert.ElementsMatch(t, schema.Required, []string{"username"})
32+
33+
mockUser := &github.User{
34+
Login: github.Ptr("google?"),
35+
ID: github.Ptr(int64(1234)),
36+
HTMLURL: github.Ptr("https://github.com/non-existent-john-doe"),
37+
AvatarURL: github.Ptr("https://github.com/avatar-url/avatar.png"),
38+
Name: github.Ptr("John Doe"),
39+
Company: github.Ptr("Gophers"),
40+
Blog: github.Ptr("https://blog.golang.org"),
41+
Location: github.Ptr("Europe/Berlin"),
42+
Email: github.Ptr("non-existent-john-doe@gmail.com"),
43+
Hireable: github.Ptr(false),
44+
Bio: github.Ptr("Just a test user"),
45+
TwitterUsername: github.Ptr("non_existent_john_doe"),
46+
PublicRepos: github.Ptr(42),
47+
PublicGists: github.Ptr(11),
48+
Followers: github.Ptr(10),
49+
Following: github.Ptr(50),
50+
CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
51+
UpdatedAt: &github.Timestamp{Time: time.Now()},
52+
PrivateGists: github.Ptr(11),
53+
TotalPrivateRepos: github.Ptr(int64(5)),
54+
OwnedPrivateRepos: github.Ptr(int64(3)),
55+
}
56+
57+
tests := []struct {
58+
name string
59+
mockedClient *http.Client
60+
requestArgs map[string]any
61+
expectError bool
62+
expectedUser *github.User
63+
expectedErrMsg string
64+
}{
65+
{
66+
name: "successful user retrieval by username",
67+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
68+
GetUserByUsername: mockResponse(t, http.StatusOK, mockUser),
69+
}),
70+
requestArgs: map[string]any{
71+
"username": "non-existent-john-doe",
72+
},
73+
expectError: false,
74+
expectedUser: mockUser,
75+
},
76+
{
77+
name: "user not found",
78+
mockedClient: MockHTTPClientWithHandler(badRequestHandler("user not found")),
79+
requestArgs: map[string]any{
80+
"username": "other-non-existent-john-doe",
81+
},
82+
expectError: true,
83+
expectedErrMsg: "failed to get user",
84+
},
85+
{
86+
name: "error getting user",
87+
mockedClient: MockHTTPClientWithHandler(badRequestHandler("some other error")),
88+
requestArgs: map[string]any{
89+
"username": "non-existent-john-doe",
90+
},
91+
expectError: true,
92+
expectedErrMsg: "failed to get user",
93+
},
94+
{
95+
name: "missing username parameter",
96+
mockedClient: MockHTTPClientWithHandler(badRequestHandler("missing username parameter")),
97+
requestArgs: map[string]any{},
98+
expectError: true,
99+
expectedErrMsg: "missing required parameter",
100+
},
101+
}
102+
103+
for _, tc := range tests {
104+
t.Run(tc.name, func(t *testing.T) {
105+
// Setup client with mock
106+
client := github.NewClient(tc.mockedClient)
107+
deps := BaseDeps{
108+
Client: client,
109+
}
110+
handler := serverTool.Handler(deps)
111+
112+
// Create call request
113+
request := createMCPRequest(tc.requestArgs)
114+
115+
// Call handler
116+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
117+
118+
// Verify results
119+
if tc.expectError {
120+
require.NoError(t, err)
121+
require.True(t, result.IsError)
122+
errorContent := getErrorResult(t, result)
123+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
124+
return
125+
}
126+
127+
require.NoError(t, err)
128+
require.False(t, result.IsError)
129+
130+
// Parse the result and get the text content if no error
131+
textContent := getTextResult(t, result)
132+
133+
// Parse and verify the result
134+
var returnedUser MinimalUser
135+
err = json.Unmarshal([]byte(textContent.Text), &returnedUser)
136+
require.NoError(t, err)
137+
138+
assert.Equal(t, *tc.expectedUser.Login, returnedUser.Login)
139+
assert.Equal(t, *tc.expectedUser.ID, returnedUser.ID)
140+
assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL)
141+
assert.Equal(t, *tc.expectedUser.AvatarURL, returnedUser.AvatarURL)
142+
// Details
143+
assert.Equal(t, *tc.expectedUser.Name, returnedUser.Details.Name)
144+
assert.Equal(t, *tc.expectedUser.Company, returnedUser.Details.Company)
145+
assert.Equal(t, *tc.expectedUser.Blog, returnedUser.Details.Blog)
146+
assert.Equal(t, *tc.expectedUser.Location, returnedUser.Details.Location)
147+
assert.Equal(t, *tc.expectedUser.Email, returnedUser.Details.Email)
148+
assert.Equal(t, *tc.expectedUser.Hireable, returnedUser.Details.Hireable)
149+
assert.Equal(t, *tc.expectedUser.Bio, returnedUser.Details.Bio)
150+
assert.Equal(t, *tc.expectedUser.TwitterUsername, returnedUser.Details.TwitterUsername)
151+
assert.Equal(t, *tc.expectedUser.PublicRepos, returnedUser.Details.PublicRepos)
152+
assert.Equal(t, *tc.expectedUser.PublicGists, returnedUser.Details.PublicGists)
153+
assert.Equal(t, *tc.expectedUser.Followers, returnedUser.Details.Followers)
154+
assert.Equal(t, *tc.expectedUser.Following, returnedUser.Details.Following)
155+
assert.Equal(t, *tc.expectedUser.PrivateGists, returnedUser.Details.PrivateGists)
156+
assert.Equal(t, *tc.expectedUser.TotalPrivateRepos, returnedUser.Details.TotalPrivateRepos)
157+
assert.Equal(t, *tc.expectedUser.OwnedPrivateRepos, returnedUser.Details.OwnedPrivateRepos)
158+
})
159+
}
160+
}

0 commit comments

Comments
 (0)