Skip to content

Commit 1832210

Browse files
mattdhollowayCopilotLuluBeatson
authored
Add get_teams and get_team_members tools (#834)
* add team tool with tests * add to tools * add toolsnaps and docs * Update pkg/github/context_tools.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * rewrite to allow providing user * rRename get_my_teams to get_teams and update documentation and tests * remove old snap * rm old comments * update test teams to numbered examples * Update descriptions for allow finding teams of other users * return empty result over custom empty error * fix test expectations for no teams found * flatten teams response to not include Nodes * update description to include clarification about teams you are a member of * fix typo in tool desc * updated description to be more generic for accecss note * amended error handling * Update pkg/github/context_tools.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add additional tool to get team members * update tool desc for get_team_members to include warning about auth * added new scope info * refactor to parse params individually * GetTeams - rename "login" field to "org" --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: LuluBeatson <lulubeatson@github.com> Co-authored-by: Lulu <59149422+LuluBeatson@users.noreply.github.com>
1 parent d52c1d4 commit 1832210

File tree

6 files changed

+569
-0
lines changed

6 files changed

+569
-0
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
144144
- **Minimum scopes**: Only grant necessary permissions
145145
- `repo` - Repository operations
146146
- `read:packages` - Docker image access
147+
- `read:org` - Organization team access
147148
- **Separate tokens**: Use different PATs for different projects/environments
148149
- **Regular rotation**: Update tokens periodically
149150
- **Never commit**: Keep tokens out of version control
@@ -421,6 +422,13 @@ The following sets of tools are available (all are on by default):
421422
- **get_me** - Get my user profile
422423
- No parameters required
423424

425+
- **get_team_members** - Get team members
426+
- `org`: Organization login (owner) that contains the team. (string, required)
427+
- `team_slug`: Team slug (string, required)
428+
429+
- **get_teams** - Get teams
430+
- `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional)
431+
424432
</details>
425433

426434
<details>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"annotations": {
3+
"title": "Get team members",
4+
"readOnlyHint": true
5+
},
6+
"description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials",
7+
"inputSchema": {
8+
"properties": {
9+
"org": {
10+
"description": "Organization login (owner) that contains the team.",
11+
"type": "string"
12+
},
13+
"team_slug": {
14+
"description": "Team slug",
15+
"type": "string"
16+
}
17+
},
18+
"required": [
19+
"org",
20+
"team_slug"
21+
],
22+
"type": "object"
23+
},
24+
"name": "get_team_members"
25+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"annotations": {
3+
"title": "Get teams",
4+
"readOnlyHint": true
5+
},
6+
"description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials",
7+
"inputSchema": {
8+
"properties": {
9+
"user": {
10+
"description": "Username to get teams for. If not provided, uses the authenticated user.",
11+
"type": "string"
12+
}
13+
},
14+
"type": "object"
15+
},
16+
"name": "get_teams"
17+
}

pkg/github/context_tools.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/github/github-mcp-server/pkg/translations"
99
"github.com/mark3labs/mcp-go/mcp"
1010
"github.com/mark3labs/mcp-go/server"
11+
"github.com/shurcooL/githubv4"
1112
)
1213

1314
// UserDetails contains additional fields about a GitHub user not already
@@ -90,3 +91,161 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too
9091

9192
return tool, handler
9293
}
94+
95+
type TeamInfo struct {
96+
Name string `json:"name"`
97+
Slug string `json:"slug"`
98+
Description string `json:"description"`
99+
}
100+
101+
type OrganizationTeams struct {
102+
Org string `json:"org"`
103+
Teams []TeamInfo `json:"teams"`
104+
}
105+
106+
func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
107+
return mcp.NewTool("get_teams",
108+
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")),
109+
mcp.WithString("user",
110+
mcp.Description(t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user.")),
111+
),
112+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
113+
Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"),
114+
ReadOnlyHint: ToBoolPtr(true),
115+
}),
116+
),
117+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
118+
user, err := OptionalParam[string](request, "user")
119+
if err != nil {
120+
return mcp.NewToolResultError(err.Error()), nil
121+
}
122+
123+
var username string
124+
if user != "" {
125+
username = user
126+
} else {
127+
client, err := getClient(ctx)
128+
if err != nil {
129+
return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil
130+
}
131+
132+
userResp, res, err := client.Users.Get(ctx, "")
133+
if err != nil {
134+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
135+
"failed to get user",
136+
res,
137+
err,
138+
), nil
139+
}
140+
username = userResp.GetLogin()
141+
}
142+
143+
gqlClient, err := getGQLClient(ctx)
144+
if err != nil {
145+
return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil
146+
}
147+
148+
var q struct {
149+
User struct {
150+
Organizations struct {
151+
Nodes []struct {
152+
Login githubv4.String
153+
Teams struct {
154+
Nodes []struct {
155+
Name githubv4.String
156+
Slug githubv4.String
157+
Description githubv4.String
158+
}
159+
} `graphql:"teams(first: 100, userLogins: [$login])"`
160+
}
161+
} `graphql:"organizations(first: 100)"`
162+
} `graphql:"user(login: $login)"`
163+
}
164+
vars := map[string]interface{}{
165+
"login": githubv4.String(username),
166+
}
167+
if err := gqlClient.Query(ctx, &q, vars); err != nil {
168+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil
169+
}
170+
171+
var organizations []OrganizationTeams
172+
for _, org := range q.User.Organizations.Nodes {
173+
orgTeams := OrganizationTeams{
174+
Org: string(org.Login),
175+
Teams: make([]TeamInfo, 0, len(org.Teams.Nodes)),
176+
}
177+
178+
for _, team := range org.Teams.Nodes {
179+
orgTeams.Teams = append(orgTeams.Teams, TeamInfo{
180+
Name: string(team.Name),
181+
Slug: string(team.Slug),
182+
Description: string(team.Description),
183+
})
184+
}
185+
186+
organizations = append(organizations, orgTeams)
187+
}
188+
189+
return MarshalledTextResult(organizations), nil
190+
}
191+
}
192+
193+
func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
194+
return mcp.NewTool("get_team_members",
195+
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")),
196+
mcp.WithString("org",
197+
mcp.Description(t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team.")),
198+
mcp.Required(),
199+
),
200+
mcp.WithString("team_slug",
201+
mcp.Description(t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug")),
202+
mcp.Required(),
203+
),
204+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
205+
Title: t("TOOL_GET_TEAM_MEMBERS_TITLE", "Get team members"),
206+
ReadOnlyHint: ToBoolPtr(true),
207+
}),
208+
),
209+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
210+
org, err := RequiredParam[string](request, "org")
211+
if err != nil {
212+
return mcp.NewToolResultError(err.Error()), nil
213+
}
214+
215+
teamSlug, err := RequiredParam[string](request, "team_slug")
216+
if err != nil {
217+
return mcp.NewToolResultError(err.Error()), nil
218+
}
219+
220+
gqlClient, err := getGQLClient(ctx)
221+
if err != nil {
222+
return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil
223+
}
224+
225+
var q struct {
226+
Organization struct {
227+
Team struct {
228+
Members struct {
229+
Nodes []struct {
230+
Login githubv4.String
231+
}
232+
} `graphql:"members(first: 100)"`
233+
} `graphql:"team(slug: $teamSlug)"`
234+
} `graphql:"organization(login: $org)"`
235+
}
236+
vars := map[string]interface{}{
237+
"org": githubv4.String(org),
238+
"teamSlug": githubv4.String(teamSlug),
239+
}
240+
if err := gqlClient.Query(ctx, &q, vars); err != nil {
241+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil
242+
}
243+
244+
var members []string
245+
for _, member := range q.Organization.Team.Members.Nodes {
246+
members = append(members, string(member.Login))
247+
}
248+
249+
return MarshalledTextResult(members), nil
250+
}
251+
}

0 commit comments

Comments
 (0)