diff --git a/README.md b/README.md index 68677e8b2..ce27bdb06 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: - **Minimum scopes**: Only grant necessary permissions - `repo` - Repository operations - `read:packages` - Docker image access + - `read:org` - Organization team access - **Separate tokens**: Use different PATs for different projects/environments - **Regular rotation**: Update tokens periodically - **Never commit**: Keep tokens out of version control @@ -421,6 +422,13 @@ The following sets of tools are available (all are on by default): - **get_me** - Get my user profile - No parameters required +- **get_team_members** - Get team members + - `org`: Organization login (owner) that contains the team. (string, required) + - `team_slug`: Team slug (string, required) + +- **get_teams** - Get teams + - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional) +
diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap new file mode 100644 index 000000000..2d91bb5ea --- /dev/null +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -0,0 +1,25 @@ +{ + "annotations": { + "title": "Get team members", + "readOnlyHint": true + }, + "description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials", + "inputSchema": { + "properties": { + "org": { + "description": "Organization login (owner) that contains the team.", + "type": "string" + }, + "team_slug": { + "description": "Team slug", + "type": "string" + } + }, + "required": [ + "org", + "team_slug" + ], + "type": "object" + }, + "name": "get_team_members" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap new file mode 100644 index 000000000..39ed4db35 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -0,0 +1,17 @@ +{ + "annotations": { + "title": "Get teams", + "readOnlyHint": true + }, + "description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials", + "inputSchema": { + "properties": { + "user": { + "description": "Username to get teams for. If not provided, uses the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "name": "get_teams" +} \ No newline at end of file diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 9817fea7b..06642aa15 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -8,6 +8,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "github.com/shurcooL/githubv4" ) // UserDetails contains additional fields about a GitHub user not already @@ -90,3 +91,161 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too return tool, handler } + +type TeamInfo struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` +} + +type OrganizationTeams struct { + Org string `json:"org"` + Teams []TeamInfo `json:"teams"` +} + +func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("get_teams", + mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials")), + mcp.WithString("user", + mcp.Description(t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user.")), + ), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), + ReadOnlyHint: ToBoolPtr(true), + }), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + user, err := OptionalParam[string](request, "user") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var username string + if user != "" { + username = user + } else { + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil + } + + userResp, res, err := client.Users.Get(ctx, "") + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get user", + res, + err, + ), nil + } + username = userResp.GetLogin() + } + + gqlClient, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil + } + + var q struct { + User struct { + Organizations struct { + Nodes []struct { + Login githubv4.String + Teams struct { + Nodes []struct { + Name githubv4.String + Slug githubv4.String + Description githubv4.String + } + } `graphql:"teams(first: 100, userLogins: [$login])"` + } + } `graphql:"organizations(first: 100)"` + } `graphql:"user(login: $login)"` + } + vars := map[string]interface{}{ + "login": githubv4.String(username), + } + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil + } + + var organizations []OrganizationTeams + for _, org := range q.User.Organizations.Nodes { + orgTeams := OrganizationTeams{ + Org: string(org.Login), + Teams: make([]TeamInfo, 0, len(org.Teams.Nodes)), + } + + for _, team := range org.Teams.Nodes { + orgTeams.Teams = append(orgTeams.Teams, TeamInfo{ + Name: string(team.Name), + Slug: string(team.Slug), + Description: string(team.Description), + }) + } + + organizations = append(organizations, orgTeams) + } + + return MarshalledTextResult(organizations), nil + } +} + +func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + return mcp.NewTool("get_team_members", + mcp.WithDescription(t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials")), + mcp.WithString("org", + mcp.Description(t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team.")), + mcp.Required(), + ), + mcp.WithString("team_slug", + mcp.Description(t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug")), + mcp.Required(), + ), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_TEAM_MEMBERS_TITLE", "Get team members"), + ReadOnlyHint: ToBoolPtr(true), + }), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + teamSlug, err := RequiredParam[string](request, "team_slug") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + gqlClient, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil + } + + var q struct { + Organization struct { + Team struct { + Members struct { + Nodes []struct { + Login githubv4.String + } + } `graphql:"members(first: 100)"` + } `graphql:"team(slug: $teamSlug)"` + } `graphql:"organization(login: $org)"` + } + vars := map[string]interface{}{ + "org": githubv4.String(org), + "teamSlug": githubv4.String(teamSlug), + } + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil + } + + var members []string + for _, member := range q.Organization.Team.Members.Nodes { + members = append(members, string(member.Login)) + } + + return MarshalledTextResult(members), nil + } +} diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index ca33f8493..641707a47 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -3,13 +3,16 @@ package github import ( "context" "encoding/json" + "fmt" "testing" "time" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v74/github" "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -139,3 +142,358 @@ func Test_GetMe(t *testing.T) { }) } } + +func Test_GetTeams(t *testing.T) { + t.Parallel() + + tool, _ := GetTeams(nil, nil, translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_teams", tool.Name) + assert.True(t, *tool.Annotations.ReadOnlyHint, "get_teams tool should be read-only") + + mockUser := &github.User{ + Login: github.Ptr("testuser"), + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Bio: github.Ptr("GitHub user for testing"), + Company: github.Ptr("Test Company"), + Location: github.Ptr("Test Location"), + HTMLURL: github.Ptr("https://github.com/testuser"), + CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)}, + Type: github.Ptr("User"), + Hireable: github.Ptr(true), + TwitterUsername: github.Ptr("testuser_twitter"), + Plan: &github.Plan{ + Name: github.Ptr("pro"), + }, + } + + mockTeamsResponse := githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "organizations": map[string]any{ + "nodes": []map[string]any{ + { + "login": "testorg1", + "teams": map[string]any{ + "nodes": []map[string]any{ + { + "name": "team1", + "slug": "team1", + "description": "Team 1", + }, + { + "name": "team2", + "slug": "team2", + "description": "Team 2", + }, + }, + }, + }, + { + "login": "testorg2", + "teams": map[string]any{ + "nodes": []map[string]any{ + { + "name": "team3", + "slug": "team3", + "description": "Team 3", + }, + }, + }, + }, + }, + }, + }, + }) + + mockNoTeamsResponse := githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "organizations": map[string]any{ + "nodes": []map[string]any{}, + }, + }, + }) + + tests := []struct { + name string + stubbedGetClientFn GetClientFn + stubbedGetGQLClientFn GetGQLClientFn + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + expectedTeamsCount int + }{ + { + name: "successful get teams", + stubbedGetClientFn: stubGetClientFromHTTPFn( + mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + mockUser, + ), + ), + ), + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" + vars := map[string]interface{}{ + "login": "testuser", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{}, + expectToolError: false, + expectedTeamsCount: 2, + }, + { + name: "successful get teams for specific user", + stubbedGetClientFn: nil, + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" + vars := map[string]interface{}{ + "login": "specificuser", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{ + "user": "specificuser", + }, + expectToolError: false, + expectedTeamsCount: 2, + }, + { + name: "no teams found", + stubbedGetClientFn: stubGetClientFromHTTPFn( + mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + mockUser, + ), + ), + ), + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}" + vars := map[string]interface{}{ + "login": "testuser", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoTeamsResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{}, + expectToolError: false, + expectedTeamsCount: 0, + }, + { + name: "getting client fails", + stubbedGetClientFn: stubGetClientFnErr("expected test error"), + stubbedGetGQLClientFn: nil, + requestArgs: map[string]any{}, + expectToolError: true, + expectedToolErrMsg: "failed to get GitHub client: expected test error", + }, + { + name: "get user fails", + stubbedGetClientFn: stubGetClientFromHTTPFn( + mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUser, + badRequestHandler("expected test failure"), + ), + ), + ), + stubbedGetGQLClientFn: nil, + requestArgs: map[string]any{}, + expectToolError: true, + expectedToolErrMsg: "expected test failure", + }, + { + name: "getting GraphQL client fails", + stubbedGetClientFn: stubGetClientFromHTTPFn( + mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUser, + mockUser, + ), + ), + ), + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + return nil, fmt.Errorf("GraphQL client error") + }, + requestArgs: map[string]any{}, + expectToolError: true, + expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, handler := GetTeams(tc.stubbedGetClientFn, tc.stubbedGetGQLClientFn, translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + require.NoError(t, err) + textContent := getTextResult(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError, "expected tool call result to be an error") + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + var organizations []OrganizationTeams + err = json.Unmarshal([]byte(textContent.Text), &organizations) + require.NoError(t, err) + + assert.Len(t, organizations, tc.expectedTeamsCount) + + if tc.expectedTeamsCount > 0 { + assert.Equal(t, "testorg1", organizations[0].Org) + assert.Len(t, organizations[0].Teams, 2) + assert.Equal(t, "team1", organizations[0].Teams[0].Name) + assert.Equal(t, "team1", organizations[0].Teams[0].Slug) + assert.Equal(t, "Team 1", organizations[0].Teams[0].Description) + + if tc.expectedTeamsCount > 1 { + assert.Equal(t, "testorg2", organizations[1].Org) + assert.Len(t, organizations[1].Teams, 1) + assert.Equal(t, "team3", organizations[1].Teams[0].Name) + assert.Equal(t, "team3", organizations[1].Teams[0].Slug) + assert.Equal(t, "Team 3", organizations[1].Teams[0].Description) + } + } + }) + } +} + +func Test_GetTeamMembers(t *testing.T) { + t.Parallel() + + tool, _ := GetTeamMembers(nil, translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_team_members", tool.Name) + assert.True(t, *tool.Annotations.ReadOnlyHint, "get_team_members tool should be read-only") + + mockTeamMembersResponse := githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "team": map[string]any{ + "members": map[string]any{ + "nodes": []map[string]any{ + { + "login": "user1", + }, + { + "login": "user2", + }, + }, + }, + }, + }, + }) + + mockNoMembersResponse := githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "team": map[string]any{ + "members": map[string]any{ + "nodes": []map[string]any{}, + }, + }, + }, + }) + + tests := []struct { + name string + stubbedGetGQLClientFn GetGQLClientFn + requestArgs map[string]any + expectToolError bool + expectedToolErrMsg string + expectedMembersCount int + }{ + { + name: "successful get team members", + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}" + vars := map[string]interface{}{ + "org": "testorg", + "teamSlug": "testteam", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamMembersResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{ + "org": "testorg", + "team_slug": "testteam", + }, + expectToolError: false, + expectedMembersCount: 2, + }, + { + name: "team with no members", + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + queryStr := "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}" + vars := map[string]interface{}{ + "org": "testorg", + "teamSlug": "emptyteam", + } + matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoMembersResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient), nil + }, + requestArgs: map[string]any{ + "org": "testorg", + "team_slug": "emptyteam", + }, + expectToolError: false, + expectedMembersCount: 0, + }, + { + name: "getting GraphQL client fails", + stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { + return nil, fmt.Errorf("GraphQL client error") + }, + requestArgs: map[string]any{ + "org": "testorg", + "team_slug": "testteam", + }, + expectToolError: true, + expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, handler := GetTeamMembers(tc.stubbedGetGQLClientFn, translations.NullTranslationHelper) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + require.NoError(t, err) + textContent := getTextResult(t, result) + + if tc.expectToolError { + assert.True(t, result.IsError, "expected tool call result to be an error") + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + var members []string + err = json.Unmarshal([]byte(textContent.Text), &members) + require.NoError(t, err) + + assert.Len(t, members, tc.expectedMembersCount) + + if tc.expectedMembersCount > 0 { + assert.Equal(t, "user1", members[0]) + + if tc.expectedMembersCount > 1 { + assert.Equal(t, "user2", members[1]) + } + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 163b2050b..3fb39ada7 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -165,6 +165,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG contextTools := toolsets.NewToolset("context", "Tools that provide context about the current user and GitHub context you are operating in"). AddReadTools( toolsets.NewServerTool(GetMe(getClient, t)), + toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)), + toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)), ) gists := toolsets.NewToolset("gists", "GitHub Gist related tools").