From a70bcc55939450835d4a84d5641a261b5359c638 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 7 Aug 2025 14:58:08 +0100 Subject: [PATCH 01/23] add team tool with tests --- pkg/github/context_tools.go | 66 ++++++++++ pkg/github/context_tools_test.go | 217 +++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 9817fea7b..5c39580bc 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -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 @@ -90,3 +92,67 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too return tool, handler } + +func GetMyTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + tool := mcp.NewTool("get_my_teams", + mcp.WithDescription(t("TOOL_GET_MY_TEAMS_DESCRIPTION", "Get details of the teams the authenticated user is a member of.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_MY_TEAMS_TITLE", "Get my teams"), + ReadOnlyHint: ToBoolPtr(true), + }), + ) + + type args struct{} + handler := mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, _ args) (*mcp.CallToolResult, error) { + 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 + } + + 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(user.GetLogin()), + } + 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 + } + + return MarshalledTextResult(t), nil + }) + + return tool, handler +} diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 56f61e936..00dbbf7ec 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/v73/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,217 @@ func Test_GetMe(t *testing.T) { }) } } + +func Test_GetMyTeams(t *testing.T) { + t.Parallel() + + tool, _ := GetMyTeams(nil, nil, translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_my_teams", tool.Name) + assert.True(t, *tool.Annotations.ReadOnlyHint, "get_my_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": "Frontend Team", + "slug": "frontend-team", + "description": "Team responsible for frontend development", + }, + { + "name": "Backend Team", + "slug": "backend-team", + "description": "Team responsible for backend development", + }, + }, + }, + }, + { + "login": "testorg2", + "teams": map[string]any{ + "nodes": []map[string]any{ + { + "name": "DevOps Team", + "slug": "devops-team", + "description": "Team responsible for DevOps and infrastructure", + }, + }, + }, + }, + }, + }, + }, + }) + + 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) { + // The GraphQL query constructed by the Go struct + 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: "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 := GetMyTeams(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, "Frontend Team", organizations[0].Teams.Nodes[0].Name) + assert.Equal(t, "frontend-team", organizations[0].Teams.Nodes[0].Slug) + + assert.Equal(t, "testorg2", organizations[1].Login) + assert.Len(t, organizations[1].Teams.Nodes, 1) + assert.Equal(t, "DevOps Team", organizations[1].Teams.Nodes[0].Name) + } + }) + } +} From c9289a2f48edb16b61f6e9ea1c168dc0e750a25b Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 7 Aug 2025 14:58:15 +0100 Subject: [PATCH 02/23] add to tools --- pkg/github/tools.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 7fb1d39c0..c116e4f67 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -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(GetMyTeams(getClient, getGQLClient, t)), ) gists := toolsets.NewToolset("gists", "GitHub Gist related tools"). From 42fac05dee2449f7f3100c94851a0066aee02cd1 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 7 Aug 2025 15:00:55 +0100 Subject: [PATCH 03/23] add toolsnaps and docs --- README.md | 3 +++ pkg/github/__toolsnaps__/get_my_teams.snap | 12 ++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 pkg/github/__toolsnaps__/get_my_teams.snap diff --git a/README.md b/README.md index b2b8cfafc..15e67d021 100644 --- a/README.md +++ b/README.md @@ -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_my_teams** - Get my teams + - No parameters required +
diff --git a/pkg/github/__toolsnaps__/get_my_teams.snap b/pkg/github/__toolsnaps__/get_my_teams.snap new file mode 100644 index 000000000..85b0fd9c9 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_my_teams.snap @@ -0,0 +1,12 @@ +{ + "annotations": { + "title": "Get my teams", + "readOnlyHint": true + }, + "description": "Get details of the teams the authenticated user is a member of.", + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "get_my_teams" +} \ No newline at end of file From c54f39d14d33db139687efabba3c9219fd568945 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 7 Aug 2025 15:51:07 +0100 Subject: [PATCH 04/23] Update pkg/github/context_tools.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/github/context_tools.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 5c39580bc..f27026a3a 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -150,6 +150,14 @@ func GetMyTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translatio 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 }) From 661dafffdd0e3596002344eeb12e67f0c897f19b Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 8 Aug 2025 16:34:51 +0100 Subject: [PATCH 05/23] rewrite to allow providing user --- pkg/github/__toolsnaps__/get_my_teams.snap | 7 +++- pkg/github/context_tools.go | 41 ++++++++++++++-------- pkg/github/context_tools_test.go | 18 ++++++++++ 3 files changed, 50 insertions(+), 16 deletions(-) diff --git a/pkg/github/__toolsnaps__/get_my_teams.snap b/pkg/github/__toolsnaps__/get_my_teams.snap index 85b0fd9c9..8b2aef3e9 100644 --- a/pkg/github/__toolsnaps__/get_my_teams.snap +++ b/pkg/github/__toolsnaps__/get_my_teams.snap @@ -5,7 +5,12 @@ }, "description": "Get details of the teams the authenticated user is a member of.", "inputSchema": { - "properties": {}, + "properties": { + "user": { + "description": "Username to get teams for. If not provided, uses the authenticated user.", + "type": "string" + } + }, "type": "object" }, "name": "get_my_teams" diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 5c39580bc..7c074da52 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -96,26 +96,37 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too func GetMyTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { tool := mcp.NewTool("get_my_teams", mcp.WithDescription(t("TOOL_GET_MY_TEAMS_DESCRIPTION", "Get details of the teams the authenticated user is a member of.")), + mcp.WithString("user", + mcp.Description(t("TOOL_GET_MY_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user.")), + ), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_GET_MY_TEAMS_TITLE", "Get my teams"), ReadOnlyHint: ToBoolPtr(true), }), ) - type args struct{} - handler := mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, _ args) (*mcp.CallToolResult, error) { - 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 + 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) @@ -140,7 +151,7 @@ func GetMyTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translatio } `graphql:"user(login: $login)"` } vars := map[string]interface{}{ - "login": githubv4.String(user.GetLogin()), + "login": githubv4.String(username), } if err := gqlClient.Query(ctx, &q, vars); err != nil { return mcp.NewToolResultError(err.Error()), nil diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 6d6c4401a..d1f9309ab 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -248,6 +248,24 @@ func Test_GetMyTeams(t *testing.T) { expectToolError: false, expectedTeamsCount: 2, }, + { + name: "successful get teams for specific user", + stubbedGetClientFn: nil, // No REST client needed when user is provided + 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( From 8ebb8340918d3e2a18245ad4eeea772fc46f14ff Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 8 Aug 2025 16:37:07 +0100 Subject: [PATCH 06/23] rRename get_my_teams to get_teams and update documentation and tests --- README.md | 4 ++-- pkg/github/__toolsnaps__/get_teams.snap | 17 +++++++++++++++++ pkg/github/context_tools.go | 10 +++++----- pkg/github/context_tools_test.go | 10 +++++----- pkg/github/tools.go | 2 +- 5 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 pkg/github/__toolsnaps__/get_teams.snap diff --git a/README.md b/README.md index 15e67d021..f896f17da 100644 --- a/README.md +++ b/README.md @@ -421,8 +421,8 @@ The following sets of tools are available (all are on by default): - **get_me** - Get my user profile - No parameters required -- **get_my_teams** - Get my teams - - No parameters required +- **get_teams** - Get my teams + - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional)
diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap new file mode 100644 index 000000000..323c10f27 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -0,0 +1,17 @@ +{ + "annotations": { + "title": "Get my teams", + "readOnlyHint": true + }, + "description": "Get details of the teams the authenticated 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" +} \ No newline at end of file diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 469421bb6..c1b7cd89d 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -93,14 +93,14 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too return tool, handler } -func GetMyTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - tool := mcp.NewTool("get_my_teams", - mcp.WithDescription(t("TOOL_GET_MY_TEAMS_DESCRIPTION", "Get details of the teams the authenticated user is a member of.")), +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 authenticated user is a member of.")), mcp.WithString("user", - mcp.Description(t("TOOL_GET_MY_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated 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_MY_TEAMS_TITLE", "Get my teams"), + Title: t("TOOL_GET_TEAMS_TITLE", "Get my teams"), ReadOnlyHint: ToBoolPtr(true), }), ) diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index d1f9309ab..881624dc7 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -143,14 +143,14 @@ func Test_GetMe(t *testing.T) { } } -func Test_GetMyTeams(t *testing.T) { +func Test_GetTeams(t *testing.T) { t.Parallel() - tool, _ := GetMyTeams(nil, nil, translations.NullTranslationHelper) + tool, _ := GetTeams(nil, nil, translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "get_my_teams", tool.Name) - assert.True(t, *tool.Annotations.ReadOnlyHint, "get_my_teams tool should be read-only") + 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"), @@ -333,7 +333,7 @@ func Test_GetMyTeams(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, handler := GetMyTeams(tc.stubbedGetClientFn, tc.stubbedGetGQLClientFn, translations.NullTranslationHelper) + _, handler := GetTeams(tc.stubbedGetClientFn, tc.stubbedGetGQLClientFn, translations.NullTranslationHelper) request := createMCPRequest(tc.requestArgs) result, err := handler(context.Background(), request) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 5714c5502..5130b9118 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -162,7 +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(GetMyTeams(getClient, getGQLClient, t)), + toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)), ) gists := toolsets.NewToolset("gists", "GitHub Gist related tools"). From c04c6dc51f9d9b3715316047885d86a586086ee1 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 8 Aug 2025 16:37:47 +0100 Subject: [PATCH 07/23] remove old snap --- pkg/github/__toolsnaps__/get_my_teams.snap | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 pkg/github/__toolsnaps__/get_my_teams.snap diff --git a/pkg/github/__toolsnaps__/get_my_teams.snap b/pkg/github/__toolsnaps__/get_my_teams.snap deleted file mode 100644 index 8b2aef3e9..000000000 --- a/pkg/github/__toolsnaps__/get_my_teams.snap +++ /dev/null @@ -1,17 +0,0 @@ -{ - "annotations": { - "title": "Get my teams", - "readOnlyHint": true - }, - "description": "Get details of the teams the authenticated 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_my_teams" -} \ No newline at end of file From 896d34b7450b3ac4ecd099cc74d241fd4d37f3a0 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 8 Aug 2025 16:42:54 +0100 Subject: [PATCH 08/23] rm old comments --- pkg/github/context_tools_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 881624dc7..d980b614b 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -235,7 +235,6 @@ func Test_GetTeams(t *testing.T) { ), ), stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) { - // The GraphQL query constructed by the Go struct 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", @@ -250,7 +249,7 @@ func Test_GetTeams(t *testing.T) { }, { name: "successful get teams for specific user", - stubbedGetClientFn: nil, // No REST client needed when user is provided + 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{}{ From 2b8c2c2bc31066003a7d9b361fcfeea20ea64262 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 8 Aug 2025 16:51:35 +0100 Subject: [PATCH 09/23] update test teams to numbered examples --- pkg/github/context_tools_test.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index d980b614b..0106d3f9b 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -178,14 +178,14 @@ func Test_GetTeams(t *testing.T) { "teams": map[string]any{ "nodes": []map[string]any{ { - "name": "Frontend Team", - "slug": "frontend-team", - "description": "Team responsible for frontend development", + "name": "team1", + "slug": "team1", + "description": "Team 1", }, { - "name": "Backend Team", - "slug": "backend-team", - "description": "Team responsible for backend development", + "name": "team2", + "slug": "team2", + "description": "Team 2", }, }, }, @@ -195,9 +195,9 @@ func Test_GetTeams(t *testing.T) { "teams": map[string]any{ "nodes": []map[string]any{ { - "name": "DevOps Team", - "slug": "devops-team", - "description": "Team responsible for DevOps and infrastructure", + "name": "team3", + "slug": "team3", + "description": "Team 3", }, }, }, @@ -363,12 +363,12 @@ func Test_GetTeams(t *testing.T) { if tc.expectedTeamsCount > 0 { assert.Equal(t, "testorg1", organizations[0].Login) assert.Len(t, organizations[0].Teams.Nodes, 2) - assert.Equal(t, "Frontend Team", organizations[0].Teams.Nodes[0].Name) - assert.Equal(t, "frontend-team", organizations[0].Teams.Nodes[0].Slug) + 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, "DevOps Team", organizations[1].Teams.Nodes[0].Name) + assert.Equal(t, "team3", organizations[1].Teams.Nodes[0].Name) } }) } From b4602807dd878e7cb74cdb27cb8b1b3e2cc75734 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 8 Aug 2025 17:06:32 +0100 Subject: [PATCH 10/23] Update descriptions for allow finding teams of other users --- README.md | 2 +- pkg/github/__toolsnaps__/get_teams.snap | 4 ++-- pkg/github/context_tools.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a52e4266c..51c185c3b 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,7 @@ The following sets of tools are available (all are on by default): - **get_me** - Get my user profile - No parameters required -- **get_teams** - Get my teams +- **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_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 323c10f27..df119a943 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -1,9 +1,9 @@ { "annotations": { - "title": "Get my teams", + "title": "Get teams", "readOnlyHint": true }, - "description": "Get details of the teams the authenticated user is a member of.", + "description": "Get details of the teams the user is a member of", "inputSchema": { "properties": { "user": { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index c1b7cd89d..7f56363d8 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -95,12 +95,12 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too 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 authenticated user is a member of.")), + 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 my teams"), + Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), ReadOnlyHint: ToBoolPtr(true), }), ) From 2d183e1e70a67b6260cca1f51b51c87560005792 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 8 Aug 2025 17:52:47 +0100 Subject: [PATCH 11/23] return empty result over custom empty error --- pkg/github/context_tools.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 7f56363d8..4c172f5f1 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -157,19 +157,6 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations 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 }) From 8a332fcb479bf219715610248bd59f2627416072 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 8 Aug 2025 17:57:27 +0100 Subject: [PATCH 12/23] fix test expectations for no teams found --- pkg/github/context_tools.go | 2 +- pkg/github/context_tools_test.go | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 4c172f5f1..952bfa61f 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -157,7 +157,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations return mcp.NewToolResultError(err.Error()), nil } - return MarshalledTextResult(t), nil + return MarshalledTextResult(q.User.Organizations.Nodes), nil }) return tool, handler diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 0106d3f9b..c820dc454 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -285,8 +285,8 @@ func Test_GetTeams(t *testing.T) { return githubv4.NewClient(httpClient), nil }, requestArgs: map[string]any{}, - expectToolError: true, - expectedToolErrMsg: "no teams found for user", + expectToolError: false, + expectedTeamsCount: 0, }, { name: "getting client fails", @@ -366,9 +366,11 @@ func Test_GetTeams(t *testing.T) { 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) + if tc.expectedTeamsCount > 1 { + 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) + } } }) } From b28d98b430a992d3d100949ed23f186d803bfcce Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 09:40:29 +0100 Subject: [PATCH 13/23] flatten teams response to not include Nodes --- pkg/github/context_tools.go | 31 ++++++++++++++++++++++++++++++- pkg/github/context_tools_test.go | 23 ++++++++++++----------- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 952bfa61f..4739f739e 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -94,6 +94,17 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too } func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + type TeamInfo struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + } + + type OrganizationTeams struct { + Login string `json:"login"` + Teams []TeamInfo `json:"teams"` + } + 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", @@ -157,7 +168,25 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations return mcp.NewToolResultError(err.Error()), nil } - return MarshalledTextResult(q.User.Organizations.Nodes), nil + var organizations []OrganizationTeams + for _, org := range q.User.Organizations.Nodes { + orgTeams := OrganizationTeams{ + Login: 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 }) return tool, handler diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index c820dc454..0bdaf3402 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -347,12 +347,10 @@ func Test_GetTeams(t *testing.T) { 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"` + Teams []struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` } `json:"teams"` } err = json.Unmarshal([]byte(textContent.Text), &organizations) @@ -362,14 +360,17 @@ func Test_GetTeams(t *testing.T) { 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.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].Login) - assert.Len(t, organizations[1].Teams.Nodes, 1) - assert.Equal(t, "team3", organizations[1].Teams.Nodes[0].Name) + 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) } } }) From 3acaaeb9245fcb404d8b0512ab0cff09baeee389 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 09:46:51 +0100 Subject: [PATCH 14/23] update description to include clarification about teams you are a member of --- pkg/github/__toolsnaps__/get_teams.snap | 2 +- pkg/github/context_tools.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index df119a943..368f4c748 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -3,7 +3,7 @@ "title": "Get teams", "readOnlyHint": true }, - "description": "Get details of the teams the user is a member of", + "description": "Get details of the teams the user is a member of. You will only bne able to see teams in organizations you are a member of.", "inputSchema": { "properties": { "user": { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 4739f739e..689e37631 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -106,7 +106,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations } tool := mcp.NewTool("get_teams", - mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of")), + mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. You will only bne able to see teams in organizations you are 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.")), ), From 165d38c09dc57fa0f2ab92354a8cb382c300e8e6 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 09:47:35 +0100 Subject: [PATCH 15/23] fix typo in tool desc --- pkg/github/__toolsnaps__/get_teams.snap | 2 +- pkg/github/context_tools.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 368f4c748..6ee77a899 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -3,7 +3,7 @@ "title": "Get teams", "readOnlyHint": true }, - "description": "Get details of the teams the user is a member of. You will only bne able to see teams in organizations you are a member of.", + "description": "Get details of the teams the user is a member of. You will only be able to see teams in organizations you are a member of.", "inputSchema": { "properties": { "user": { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 689e37631..ff40f700c 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -106,7 +106,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations } tool := mcp.NewTool("get_teams", - mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. You will only bne able to see teams in organizations you are a member of.")), + mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. You will only be able to see teams in organizations you are 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.")), ), From de994ca4b5180643c42e87b5a380573ab5d88902 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 09:51:41 +0100 Subject: [PATCH 16/23] updated description to be more generic for accecss note --- pkg/github/__toolsnaps__/get_teams.snap | 2 +- pkg/github/context_tools.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 6ee77a899..39ed4db35 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -3,7 +3,7 @@ "title": "Get teams", "readOnlyHint": true }, - "description": "Get details of the teams the user is a member of. You will only be able to see teams in organizations you are a member of.", + "description": "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials", "inputSchema": { "properties": { "user": { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index ff40f700c..808c87386 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -106,7 +106,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations } tool := mcp.NewTool("get_teams", - mcp.WithDescription(t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. You will only be able to see teams in organizations you are a member of.")), + 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.")), ), From 4652d60bb886b7089d40944232652eeb06f05081 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 10:19:56 +0100 Subject: [PATCH 17/23] amended error handling --- pkg/github/context_tools.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 808c87386..d0083fc00 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -2,7 +2,6 @@ package github import ( "context" - "fmt" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -142,7 +141,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations gqlClient, err := getGQLClient(ctx) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil + return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil } var q struct { @@ -165,7 +164,8 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations "login": githubv4.String(username), } if err := gqlClient.Query(ctx, &q, vars); err != nil { - return mcp.NewToolResultError(err.Error()), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil + } var organizations []OrganizationTeams From ce8880fa50c1553f3f2e76a963b219a78c26e7b2 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 10:37:10 +0100 Subject: [PATCH 18/23] Update pkg/github/context_tools.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/github/context_tools.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index d0083fc00..6b223e386 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -165,7 +165,6 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations } if err := gqlClient.Query(ctx, &q, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find teams", err), nil - } var organizations []OrganizationTeams From f92b4e02888d7cda12d6a2c8d1c0a42508379dc0 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 15:43:15 +0100 Subject: [PATCH 19/23] add additional tool to get team members --- README.md | 4 + .../__toolsnaps__/get_team_members.snap | 25 ++++ pkg/github/context_tools.go | 57 ++++++++ pkg/github/context_tools_test.go | 128 ++++++++++++++++++ pkg/github/tools.go | 1 + 5 files changed, 215 insertions(+) create mode 100644 pkg/github/__toolsnaps__/get_team_members.snap diff --git a/README.md b/README.md index 51c185c3b..cac3d298d 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,10 @@ 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..779f17b4a --- /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.", + "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/context_tools.go b/pkg/github/context_tools.go index d0083fc00..4e303b3e7 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -191,3 +191,60 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations return tool, handler } + +func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { + tool := mcp.NewTool("get_team_members", + mcp.WithDescription(t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization.")), + 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), + }), + ) + + type args struct { + Org string `json:"org"` + TeamSlug string `json:"team_slug"` + } + handler := mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, a args) (*mcp.CallToolResult, error) { + 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(a.Org), + "teamSlug": githubv4.String(a.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 + }) + + return tool, handler +} diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 0bdaf3402..8f101732f 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -376,3 +376,131 @@ func Test_GetTeams(t *testing.T) { }) } } + +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 e24fb8eb0..3f32ba028 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -163,6 +163,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG 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"). From 15e5e1084f4e0ac41c6f1a5163c8d4e166a45d78 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 15:44:36 +0100 Subject: [PATCH 20/23] update tool desc for get_team_members to include warning about auth --- pkg/github/__toolsnaps__/get_team_members.snap | 2 +- pkg/github/context_tools.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap index 779f17b4a..2d91bb5ea 100644 --- a/pkg/github/__toolsnaps__/get_team_members.snap +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -3,7 +3,7 @@ "title": "Get team members", "readOnlyHint": true }, - "description": "Get member usernames of a specific team in an organization.", + "description": "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials", "inputSchema": { "properties": { "org": { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 34b9fa2ce..976603a9c 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -193,7 +193,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { tool := mcp.NewTool("get_team_members", - mcp.WithDescription(t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization.")), + 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(), From 8c590772f6c0ffa00bba76ddd7af17c65910b518 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 11 Aug 2025 15:48:18 +0100 Subject: [PATCH 21/23] added new scope info --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cac3d298d..fbfb7d6ab 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 From 2b7fccf795c8f8ab849d00e9ead8d746ed7affd8 Mon Sep 17 00:00:00 2001 From: LuluBeatson Date: Wed, 13 Aug 2025 11:59:52 +0100 Subject: [PATCH 22/23] refactor to parse params individually --- pkg/github/context_tools.go | 244 ++++++++++++++++++------------------ 1 file changed, 123 insertions(+), 121 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 976603a9c..1085e4784 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -104,146 +104,148 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations Teams []TeamInfo `json:"teams"` } - tool := 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.")), + 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), + }), ), - 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) + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + user, err := OptionalParam[string](request, "user") if err != nil { - return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil + return mcp.NewToolResultError(err.Error()), nil } - user, res, err := client.Users.Get(ctx, "") - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get user", - res, - err, - ), 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() } - username = user.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 - } + gqlClient, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil + } - var organizations []OrganizationTeams - for _, org := range q.User.Organizations.Nodes { - orgTeams := OrganizationTeams{ - Login: string(org.Login), - Teams: make([]TeamInfo, 0, len(org.Teams.Nodes)), + 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 } - for _, team := range org.Teams.Nodes { - orgTeams.Teams = append(orgTeams.Teams, TeamInfo{ - Name: string(team.Name), - Slug: string(team.Slug), - Description: string(team.Description), - }) + var organizations []OrganizationTeams + for _, org := range q.User.Organizations.Nodes { + orgTeams := OrganizationTeams{ + Login: 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) } - organizations = append(organizations, orgTeams) + return MarshalledTextResult(organizations), nil } - - return MarshalledTextResult(organizations), nil - }) - - return tool, handler } func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { - tool := 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(), + 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), + }), ), - 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 + } - type args struct { - Org string `json:"org"` - TeamSlug string `json:"team_slug"` - } - handler := mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, a args) (*mcp.CallToolResult, error) { - gqlClient, err := getGQLClient(ctx) - if err != nil { - return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil - } + teamSlug, err := RequiredParam[string](request, "team_slug") + if err != nil { + return mcp.NewToolResultError(err.Error()), 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(a.Org), - "teamSlug": githubv4.String(a.TeamSlug), - } - if err := gqlClient.Query(ctx, &q, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil - } + gqlClient, err := getGQLClient(ctx) + if err != nil { + return mcp.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil + } - var members []string - for _, member := range q.Organization.Team.Members.Nodes { - members = append(members, string(member.Login)) - } + 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 + } - return MarshalledTextResult(members), nil - }) + var members []string + for _, member := range q.Organization.Team.Members.Nodes { + members = append(members, string(member.Login)) + } - return tool, handler + return MarshalledTextResult(members), nil + } } From 567263286fb2ac7edb5edc553f1a64b7171ab99d Mon Sep 17 00:00:00 2001 From: LuluBeatson Date: Wed, 13 Aug 2025 12:03:33 +0100 Subject: [PATCH 23/23] GetTeams - rename "login" field to "org" --- pkg/github/context_tools.go | 22 +++++++++++----------- pkg/github/context_tools_test.go | 13 +++---------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 1085e4784..06642aa15 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -92,18 +92,18 @@ 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) { - type TeamInfo struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - } +type TeamInfo struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` +} - type OrganizationTeams struct { - Login string `json:"login"` - Teams []TeamInfo `json:"teams"` - } +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", @@ -171,7 +171,7 @@ func GetTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations var organizations []OrganizationTeams for _, org := range q.User.Organizations.Nodes { orgTeams := OrganizationTeams{ - Login: string(org.Login), + Org: string(org.Login), Teams: make([]TeamInfo, 0, len(org.Teams.Nodes)), } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 8f101732f..641707a47 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -345,28 +345,21 @@ func Test_GetTeams(t *testing.T) { return } - var organizations []struct { - Login string `json:"login"` - Teams []struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - } `json:"teams"` - } + 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].Login) + 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].Login) + 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)