From fed1050e5feadb3dc54c0c583597ddbd3dbc89b2 Mon Sep 17 00:00:00 2001 From: Artem Sierikov Date: Sat, 21 Mar 2026 14:27:48 +0100 Subject: [PATCH] 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. --- README.md | 3 + pkg/github/__toolsnaps__/get_user.snap | 20 ++++ pkg/github/context_tools_test.go | 10 +- pkg/github/helper_test.go | 3 +- pkg/github/tools.go | 6 +- pkg/github/users.go | 91 ++++++++++++++ pkg/github/users_test.go | 160 +++++++++++++++++++++++++ 7 files changed, 285 insertions(+), 8 deletions(-) create mode 100644 pkg/github/__toolsnaps__/get_user.snap create mode 100644 pkg/github/users.go create mode 100644 pkg/github/users_test.go diff --git a/README.md b/README.md index e9992694e..581b29cda 100644 --- a/README.md +++ b/README.md @@ -1380,6 +1380,9 @@ The following sets of tools are available: people Users +- **get_user** - Get a user by username + - `username`: Username of the user (string, required) + - **search_users** - Search users - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) diff --git a/pkg/github/__toolsnaps__/get_user.snap b/pkg/github/__toolsnaps__/get_user.snap new file mode 100644 index 000000000..259d3cdac --- /dev/null +++ b/pkg/github/__toolsnaps__/get_user.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get a user by username" + }, + "description": "Get user by username. Use this when you need information about specific GitHub user.", + "inputSchema": { + "properties": { + "username": { + "description": "Username of the user", + "type": "string" + } + }, + "required": [ + "username" + ], + "type": "object" + }, + "name": "get_user" +} \ No newline at end of file diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 392501985..3206e812c 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -57,7 +57,7 @@ func Test_GetMe(t *testing.T) { { name: "successful get user", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUser: mockResponse(t, http.StatusOK, mockUser), + GetAuthenticatedUser: mockResponse(t, http.StatusOK, mockUser), }), requestArgs: map[string]any{}, expectToolError: false, @@ -66,7 +66,7 @@ func Test_GetMe(t *testing.T) { { name: "successful get user with reason", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUser: mockResponse(t, http.StatusOK, mockUser), + GetAuthenticatedUser: mockResponse(t, http.StatusOK, mockUser), }), requestArgs: map[string]any{ "reason": "Testing API", @@ -84,7 +84,7 @@ func Test_GetMe(t *testing.T) { { name: "get user fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUser: badRequestHandler("expected test failure"), + GetAuthenticatedUser: badRequestHandler("expected test failure"), }), requestArgs: map[string]any{}, expectToolError: true, @@ -246,13 +246,13 @@ func Test_GetTeams(t *testing.T) { // Factory function for mock HTTP clients with user response httpClientWithUser := func() *http.Client { return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUser: mockResponse(t, http.StatusOK, mockUser), + GetAuthenticatedUser: mockResponse(t, http.StatusOK, mockUser), }) } httpClientUserFails := func() *http.Client { return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUser: badRequestHandler("expected test failure"), + GetAuthenticatedUser: badRequestHandler("expected test failure"), }) } diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index ff752f5f3..184195ab3 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -20,8 +20,9 @@ import ( // These constants define the URL patterns used in HTTP mocking for tests const ( // User endpoints - GetUser = "GET /user" + GetAuthenticatedUser = "GET /user" GetUserStarred = "GET /user/starred" + GetUserByUsername = "GET /users/{username}" GetUsersGistsByUsername = "GET /users/{username}/gists" GetUsersStarredByUsername = "GET /users/{username}/starred" PutUserStarredByOwnerByRepo = "PUT /user/starred/{owner}/{repo}" diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 3f1c291a7..ffa0e2528 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -5,10 +5,11 @@ import ( "slices" "strings" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v82/github" "github.com/shurcooL/githubv4" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" ) type GetClientFn func(context.Context) (*github.Client, error) @@ -199,6 +200,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { SubIssueWrite(t), // User tools + GetUser(t), SearchUsers(t), // Organization tools diff --git a/pkg/github/users.go b/pkg/github/users.go new file mode 100644 index 000000000..8f3e47b42 --- /dev/null +++ b/pkg/github/users.go @@ -0,0 +1,91 @@ +package github + +import ( + "context" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" +) + +// GetUser creates a tool to get a user by username. +func GetUser(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataUsers, + mcp.Tool{ + Name: "get_user", + Description: t("TOOL_GET_USER_DESCRIPTION", "Get user by username. Use this when you need information about specific GitHub user."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_USER_TITLE", "Get a user by username"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "username": { + Type: "string", + Description: t("TOOL_GET_USER_USERNAME_DESCRIPTION", "Username of the user"), + }, + }, + Required: []string{"username"}, + }, + }, + nil, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + return getUserHandler(ctx, deps, args) + }, + ) +} + +func getUserHandler(ctx context.Context, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, any, error) { + username, err := RequiredParam[string](args, "username") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + user, resp, err := client.Users.Get(ctx, username) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get user", + resp, + 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(), + PublicRepos: user.GetPublicRepos(), + PublicGists: user.GetPublicGists(), + Followers: user.GetFollowers(), + Following: user.GetFollowing(), + CreatedAt: user.GetCreatedAt().Time, + UpdatedAt: user.GetUpdatedAt().Time, + PrivateGists: user.GetPrivateGists(), + TotalPrivateRepos: user.GetTotalPrivateRepos(), + OwnedPrivateRepos: user.GetOwnedPrivateRepos(), + }, + } + + return MarshalledTextResult(minimalUser), nil, nil +} diff --git a/pkg/github/users_test.go b/pkg/github/users_test.go new file mode 100644 index 000000000..1e3a95a24 --- /dev/null +++ b/pkg/github/users_test.go @@ -0,0 +1,160 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/google/go-github/v82/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" +) + +func Test_GetUser(t *testing.T) { + // Verify tool definition once + serverTool := GetUser(translations.NullTranslationHelper) + tool := serverTool.Tool + 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, "get_user", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "username") + assert.ElementsMatch(t, schema.Required, []string{"username"}) + + mockUser := &github.User{ + Login: github.Ptr("google?"), + ID: github.Ptr(int64(1234)), + HTMLURL: github.Ptr("https://github.com/non-existent-john-doe"), + AvatarURL: github.Ptr("https://github.com/avatar-url/avatar.png"), + Name: github.Ptr("John Doe"), + Company: github.Ptr("Gophers"), + Blog: github.Ptr("https://blog.golang.org"), + Location: github.Ptr("Europe/Berlin"), + Email: github.Ptr("non-existent-john-doe@gmail.com"), + Hireable: github.Ptr(false), + Bio: github.Ptr("Just a test user"), + TwitterUsername: github.Ptr("non_existent_john_doe"), + PublicRepos: github.Ptr(42), + PublicGists: github.Ptr(11), + Followers: github.Ptr(10), + Following: github.Ptr(50), + CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, + UpdatedAt: &github.Timestamp{Time: time.Now()}, + PrivateGists: github.Ptr(11), + TotalPrivateRepos: github.Ptr(int64(5)), + OwnedPrivateRepos: github.Ptr(int64(3)), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedUser *github.User + expectedErrMsg string + }{ + { + name: "successful user retrieval by username", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUserByUsername: mockResponse(t, http.StatusOK, mockUser), + }), + requestArgs: map[string]any{ + "username": "non-existent-john-doe", + }, + expectError: false, + expectedUser: mockUser, + }, + { + name: "user not found", + mockedClient: MockHTTPClientWithHandler(badRequestHandler("user not found")), + requestArgs: map[string]any{ + "username": "other-non-existent-john-doe", + }, + expectError: true, + expectedErrMsg: "failed to get user", + }, + { + name: "error getting user", + mockedClient: MockHTTPClientWithHandler(badRequestHandler("some other error")), + requestArgs: map[string]any{ + "username": "non-existent-john-doe", + }, + expectError: true, + expectedErrMsg: "failed to get user", + }, + { + name: "missing username parameter", + mockedClient: MockHTTPClientWithHandler(badRequestHandler("missing username parameter")), + requestArgs: map[string]any{}, + expectError: true, + expectedErrMsg: "missing required parameter", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Parse and verify the 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.ID, returnedUser.ID) + assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL) + assert.Equal(t, *tc.expectedUser.AvatarURL, returnedUser.AvatarURL) + // Details + assert.Equal(t, *tc.expectedUser.Name, returnedUser.Details.Name) + assert.Equal(t, *tc.expectedUser.Company, returnedUser.Details.Company) + assert.Equal(t, *tc.expectedUser.Blog, returnedUser.Details.Blog) + assert.Equal(t, *tc.expectedUser.Location, returnedUser.Details.Location) + assert.Equal(t, *tc.expectedUser.Email, returnedUser.Details.Email) + assert.Equal(t, *tc.expectedUser.Hireable, returnedUser.Details.Hireable) + assert.Equal(t, *tc.expectedUser.Bio, returnedUser.Details.Bio) + assert.Equal(t, *tc.expectedUser.TwitterUsername, returnedUser.Details.TwitterUsername) + assert.Equal(t, *tc.expectedUser.PublicRepos, returnedUser.Details.PublicRepos) + assert.Equal(t, *tc.expectedUser.PublicGists, returnedUser.Details.PublicGists) + assert.Equal(t, *tc.expectedUser.Followers, returnedUser.Details.Followers) + assert.Equal(t, *tc.expectedUser.Following, returnedUser.Details.Following) + assert.Equal(t, *tc.expectedUser.PrivateGists, returnedUser.Details.PrivateGists) + assert.Equal(t, *tc.expectedUser.TotalPrivateRepos, returnedUser.Details.TotalPrivateRepos) + assert.Equal(t, *tc.expectedUser.OwnedPrivateRepos, returnedUser.Details.OwnedPrivateRepos) + }) + } +}