Skip to content

Add get_teams and get_team_members tools #834

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Aug 13, 2025
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a70bcc5
add team tool with tests
mattdholloway Aug 7, 2025
c9289a2
add to tools
mattdholloway Aug 7, 2025
42fac05
add toolsnaps and docs
mattdholloway Aug 7, 2025
8d97b5e
Merge branch 'main' into teams-tool
mattdholloway Aug 7, 2025
c54f39d
Update pkg/github/context_tools.go
mattdholloway Aug 7, 2025
661daff
rewrite to allow providing user
mattdholloway Aug 8, 2025
8bc6489
Merge branch 'teams-tool' of https://github.com/github/github-mcp-ser…
mattdholloway Aug 8, 2025
8ebb834
rRename get_my_teams to get_teams and update documentation and tests
mattdholloway Aug 8, 2025
837af76
Merge branch 'main' into teams-tool
mattdholloway Aug 8, 2025
c04c6dc
remove old snap
mattdholloway Aug 8, 2025
896d34b
rm old comments
mattdholloway Aug 8, 2025
2b8c2c2
update test teams to numbered examples
mattdholloway Aug 8, 2025
b460280
Update descriptions for allow finding teams of other users
mattdholloway Aug 8, 2025
2d183e1
return empty result over custom empty error
mattdholloway Aug 8, 2025
8a332fc
fix test expectations for no teams found
mattdholloway Aug 8, 2025
b28d98b
flatten teams response to not include Nodes
mattdholloway Aug 11, 2025
3acaaeb
update description to include clarification about teams you are a mem…
mattdholloway Aug 11, 2025
165d38c
fix typo in tool desc
mattdholloway Aug 11, 2025
de994ca
updated description to be more generic for accecss note
mattdholloway Aug 11, 2025
4652d60
amended error handling
mattdholloway Aug 11, 2025
ce8880f
Update pkg/github/context_tools.go
mattdholloway Aug 11, 2025
f92b4e0
add additional tool to get team members
mattdholloway Aug 11, 2025
4429b1f
Merge branch 'teams-tool' of https://github.com/github/github-mcp-ser…
mattdholloway Aug 11, 2025
15e5e10
update tool desc for get_team_members to include warning about auth
mattdholloway Aug 11, 2025
8c59077
added new scope info
mattdholloway Aug 11, 2025
c590779
Merge branch 'main' into teams-tool
mattdholloway Aug 11, 2025
2b7fccf
refactor to parse params individually
LuluBeatson Aug 13, 2025
5672632
GetTeams - rename "login" field to "org"
LuluBeatson Aug 13, 2025
c192a47
Merge branch 'main' into teams-tool
LuluBeatson Aug 13, 2025
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,9 @@ The following sets of tools are available (all are on by default):
- **get_me** - Get my user profile
- No parameters required

- **get_teams** - Get teams
- `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional)

</details>

<details>
Expand Down
17 changes: 17 additions & 0 deletions pkg/github/__toolsnaps__/get_teams.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"annotations": {
"title": "Get teams",
"readOnlyHint": true
},
"description": "Get details of the teams the user is a member of",
"inputSchema": {
"properties": {
"user": {
"description": "Username to get teams for. If not provided, uses the authenticated user.",
"type": "string"
}
},
"type": "object"
},
"name": "get_teams"
}
85 changes: 85 additions & 0 deletions pkg/github/context_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package github

import (
"context"
"fmt"
"time"

ghErrors "github.com/github/github-mcp-server/pkg/errors"
"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
Expand Down Expand Up @@ -90,3 +92,86 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too

return tool, handler
}

func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
tool := mcp.NewTool("get_teams",
mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of")),
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),
}),
)

type args struct {
User *string `json:"user,omitempty"`
}
handler := mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, a args) (*mcp.CallToolResult, error) {
var username string
if a.User != nil && *a.User != "" {
username = *a.User
} else {
client, err := getClient(ctx)
if err != nil {
return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil
}

user, res, err := client.Users.Get(ctx, "")
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get user",
res,
err,
), nil
}
username = user.GetLogin()
}

gqlClient, err := getGQLClient(ctx)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", 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 mcp.NewToolResultError(err.Error()), nil
}

t := q.User.Organizations.Nodes
if len(t) == 0 {
return mcp.NewToolResultError("no teams found for user"), nil
}
// Check if any teams exist within the organizations
teamCount := 0
for _, org := range t {
teamCount += len(org.Teams.Nodes)
}
if teamCount == 0 {
return mcp.NewToolResultError("no teams found for user"), nil
}

return MarshalledTextResult(t), nil
})

return tool, handler
}
234 changes: 234 additions & 0 deletions pkg/github/context_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -139,3 +142,234 @@ 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("[email protected]"),
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: true,
expectedToolErrMsg: "no teams found for user",
},
{
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 []struct {
Login string `json:"login"`
Teams struct {
Nodes []struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
} `json:"nodes"`
} `json:"teams"`
}
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].Login)
assert.Len(t, organizations[0].Teams.Nodes, 2)
assert.Equal(t, "team1", organizations[0].Teams.Nodes[0].Name)
assert.Equal(t, "team1", organizations[0].Teams.Nodes[0].Slug)

assert.Equal(t, "testorg2", organizations[1].Login)
assert.Len(t, organizations[1].Teams.Nodes, 1)
assert.Equal(t, "team3", organizations[1].Teams.Nodes[0].Name)
}
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ 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)),
)

gists := toolsets.NewToolset("gists", "GitHub Gist related tools").
Expand Down
Loading