Repository: github/github-mcp-server

Issue:
Add support for getting members of a GitHub team
### Describe the feature or problem you’d like to solve

In CODEOWNERS we leverage GitHub teams as much as possible to avoid further code changes when teams change. I want to be able to ask the AI agent "who owns X code?", which finds the CODEOWNERS entry, and then uses the GitHub MCP server to produce the list of team members.

### Proposed solution

Add GitHub team support to the MCP implementation.

### Additional context

None right now, but will add if I think of any.

Discussion:

Solution patch:
diff --git a/README.md b/README.md
index 68677e8..ce27bdb 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)
+
 </details>
 
 <details>
diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap
new file mode 100644
index 0000000..2d91bb5
--- /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 0000000..39ed4db
--- /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 9817fea..06642aa 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/tools.go b/pkg/github/tools.go
index 163b205..3fb39ad 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").



Test patch:
diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go
index ca33f84..641707a 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])
+				}
+			}
+		})
+	}
+}