Skip to content
Open
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
5 changes: 5 additions & 0 deletions docs/feature-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,9 @@ runtime behavior (such as output formatting) won't appear here.
- 'blocked_by' - the subject issue is blocked by the related issue.
- 'blocking' - the subject issue blocks the related issue. (string, required)

### `users_granular`

- **get_user** - Get a user by username
- `username`: Username of the user (string, required)

<!-- END AUTOMATED FEATURE FLAG TOOLS -->
20 changes: 20 additions & 0 deletions pkg/github/__toolsnaps__/get_user.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"annotations": {
"readOnlyHint": true,
"title": "Get a user by username"
},
"description": "Get a user by username. Use this when you need information about a specific GitHub user.",
"inputSchema": {
"properties": {
"username": {
"description": "Username of the user",
"type": "string"
}
},
"required": [
"username"
],
"type": "object"
},
"name": "get_user"
}
12 changes: 6 additions & 6 deletions pkg/github/context_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand Down Expand Up @@ -150,7 +150,7 @@ func Test_GetMe_IFC_FeatureFlag(t *testing.T) {
CreatedAt: &github.Timestamp{Time: time.Now()},
}
mockedHTTPClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUser: mockResponse(t, http.StatusOK, mockUser),
GetAuthenticatedUser: mockResponse(t, http.StatusOK, mockUser),
})

depsWithIFCFeature := func(enabled bool) *BaseDeps {
Expand Down Expand Up @@ -313,13 +313,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"),
})
}

Expand Down
7 changes: 7 additions & 0 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ const FeatureFlagFileBlame = "file_blame"
// unless explicitly opted in.
const FeatureFlagIssueDependencies = "issue_dependencies"

// FeatureFlagUsersGranular is the feature flag name for granular user tools
// such as get_user, which fetches a single GitHub user's profile by username.
// It is gated so the extra tool is not advertised by default, keeping the tool
// surface small unless opted in.
const FeatureFlagUsersGranular = "users_granular"

// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
// by users via --features CLI flag or X-MCP-Features HTTP header.
// Only flags in this list are accepted; unknown flags are silently ignored.
Expand All @@ -35,6 +41,7 @@ var AllowedFeatureFlags = []string{
FeatureFlagPullRequestsGranular,
FeatureFlagFileBlame,
FeatureFlagIssueDependencies,
FeatureFlagUsersGranular,
}

// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
// These constants define the URL patterns used in HTTP mocking for tests
const (
// User endpoints
GetUser = "GET /user"
GetAuthenticatedUser = "GET /user"
GetUsersByUsername = "GET /users/{username}"
GetUserStarred = "GET /user/starred"
GetUsersGistsByUsername = "GET /users/{username}/gists"
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
IssueDependencyWrite(t),

// User tools
GetUser(t),
SearchUsers(t),

// Organization tools
Expand Down
93 changes: 93 additions & 0 deletions pkg/github/users.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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 {
st := NewTool(
ToolsetMetadataUsers,
mcp.Tool{
Name: "get_user",
Description: t("TOOL_GET_USER_DESCRIPTION", "Get a user by username. Use this when you need information about a 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)
},
)
st.FeatureFlagEnable = FeatureFlagUsersGranular
return st
}

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(),
},
}
Comment on lines +66 to +90

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MinimalUser/UserDetails mapping here duplicates the same field-by-field conversion logic in GetMe (context_tools.go). Since this is now shared behavior across multiple tools, consider extracting a small helper (e.g., buildMinimalUserWithDetails(*github.User)) to avoid future divergence when the shape changes.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. But right now there are only two call sites (GetMe and GetUser), and the conversion is straightforward field mapping. I'd prefer to wait for a third consumer before extracting a helper.


return MarshalledTextResult(minimalUser), nil, nil
}
164 changes: 164 additions & 0 deletions pkg/github/users_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package github

import (
"context"
"encoding/json"
"net/http"
"testing"
"time"

"github.com/google/go-github/v87/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))

// get_user is a granular user tool, gated so it is not advertised unless
// the users_granular feature flag opts it in.
assert.Equal(t, FeatureFlagUsersGranular, serverTool.FeatureFlagEnable, "get_user must be gated behind the users_granular feature flag")

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{
GetUsersByUsername: mockResponse(t, http.StatusOK, mockUser),
}),
requestArgs: map[string]any{
"username": "non-existent-john-doe",
},
expectError: false,
expectedUser: mockUser,
},
{
name: "user not found",
mockedClient: MockHTTPClientWithHandler(mockResponse(t, http.StatusNotFound, `{"message":"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 := mustNewGHClient(t, 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)
})
}
}