Skip to content

Commit a70bcc5

Browse files
committed
add team tool with tests
1 parent 33a63a0 commit a70bcc5

File tree

2 files changed

+283
-0
lines changed

2 files changed

+283
-0
lines changed

pkg/github/context_tools.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package github
22

33
import (
44
"context"
5+
"fmt"
56
"time"
67

78
ghErrors "github.com/github/github-mcp-server/pkg/errors"
89
"github.com/github/github-mcp-server/pkg/translations"
910
"github.com/mark3labs/mcp-go/mcp"
1011
"github.com/mark3labs/mcp-go/server"
12+
"github.com/shurcooL/githubv4"
1113
)
1214

1315
// UserDetails contains additional fields about a GitHub user not already
@@ -90,3 +92,67 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too
9092

9193
return tool, handler
9294
}
95+
96+
func GetMyTeams(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
97+
tool := mcp.NewTool("get_my_teams",
98+
mcp.WithDescription(t("TOOL_GET_MY_TEAMS_DESCRIPTION", "Get details of the teams the authenticated user is a member of.")),
99+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
100+
Title: t("TOOL_GET_MY_TEAMS_TITLE", "Get my teams"),
101+
ReadOnlyHint: ToBoolPtr(true),
102+
}),
103+
)
104+
105+
type args struct{}
106+
handler := mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, _ args) (*mcp.CallToolResult, error) {
107+
client, err := getClient(ctx)
108+
if err != nil {
109+
return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil
110+
}
111+
112+
user, res, err := client.Users.Get(ctx, "")
113+
if err != nil {
114+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
115+
"failed to get user",
116+
res,
117+
err,
118+
), nil
119+
}
120+
121+
gqlClient, err := getGQLClient(ctx)
122+
if err != nil {
123+
return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
124+
}
125+
126+
var q struct {
127+
User struct {
128+
Organizations struct {
129+
Nodes []struct {
130+
Login githubv4.String
131+
Teams struct {
132+
Nodes []struct {
133+
Name githubv4.String
134+
Slug githubv4.String
135+
Description githubv4.String
136+
}
137+
} `graphql:"teams(first: 100, userLogins: [$login])"`
138+
}
139+
} `graphql:"organizations(first: 100)"`
140+
} `graphql:"user(login: $login)"`
141+
}
142+
vars := map[string]interface{}{
143+
"login": githubv4.String(user.GetLogin()),
144+
}
145+
if err := gqlClient.Query(ctx, &q, vars); err != nil {
146+
return mcp.NewToolResultError(err.Error()), nil
147+
}
148+
149+
t := q.User.Organizations.Nodes
150+
if len(t) == 0 {
151+
return mcp.NewToolResultError("no teams found for user"), nil
152+
}
153+
154+
return MarshalledTextResult(t), nil
155+
})
156+
157+
return tool, handler
158+
}

pkg/github/context_tools_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ package github
33
import (
44
"context"
55
"encoding/json"
6+
"fmt"
67
"testing"
78
"time"
89

10+
"github.com/github/github-mcp-server/internal/githubv4mock"
911
"github.com/github/github-mcp-server/internal/toolsnaps"
1012
"github.com/github/github-mcp-server/pkg/translations"
1113
"github.com/google/go-github/v73/github"
1214
"github.com/migueleliasweb/go-github-mock/src/mock"
15+
"github.com/shurcooL/githubv4"
1316
"github.com/stretchr/testify/assert"
1417
"github.com/stretchr/testify/require"
1518
)
@@ -139,3 +142,217 @@ func Test_GetMe(t *testing.T) {
139142
})
140143
}
141144
}
145+
146+
func Test_GetMyTeams(t *testing.T) {
147+
t.Parallel()
148+
149+
tool, _ := GetMyTeams(nil, nil, translations.NullTranslationHelper)
150+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
151+
152+
assert.Equal(t, "get_my_teams", tool.Name)
153+
assert.True(t, *tool.Annotations.ReadOnlyHint, "get_my_teams tool should be read-only")
154+
155+
mockUser := &github.User{
156+
Login: github.Ptr("testuser"),
157+
Name: github.Ptr("Test User"),
158+
Email: github.Ptr("test@example.com"),
159+
Bio: github.Ptr("GitHub user for testing"),
160+
Company: github.Ptr("Test Company"),
161+
Location: github.Ptr("Test Location"),
162+
HTMLURL: github.Ptr("https://github.com/testuser"),
163+
CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
164+
Type: github.Ptr("User"),
165+
Hireable: github.Ptr(true),
166+
TwitterUsername: github.Ptr("testuser_twitter"),
167+
Plan: &github.Plan{
168+
Name: github.Ptr("pro"),
169+
},
170+
}
171+
172+
mockTeamsResponse := githubv4mock.DataResponse(map[string]any{
173+
"user": map[string]any{
174+
"organizations": map[string]any{
175+
"nodes": []map[string]any{
176+
{
177+
"login": "testorg1",
178+
"teams": map[string]any{
179+
"nodes": []map[string]any{
180+
{
181+
"name": "Frontend Team",
182+
"slug": "frontend-team",
183+
"description": "Team responsible for frontend development",
184+
},
185+
{
186+
"name": "Backend Team",
187+
"slug": "backend-team",
188+
"description": "Team responsible for backend development",
189+
},
190+
},
191+
},
192+
},
193+
{
194+
"login": "testorg2",
195+
"teams": map[string]any{
196+
"nodes": []map[string]any{
197+
{
198+
"name": "DevOps Team",
199+
"slug": "devops-team",
200+
"description": "Team responsible for DevOps and infrastructure",
201+
},
202+
},
203+
},
204+
},
205+
},
206+
},
207+
},
208+
})
209+
210+
mockNoTeamsResponse := githubv4mock.DataResponse(map[string]any{
211+
"user": map[string]any{
212+
"organizations": map[string]any{
213+
"nodes": []map[string]any{},
214+
},
215+
},
216+
})
217+
218+
tests := []struct {
219+
name string
220+
stubbedGetClientFn GetClientFn
221+
stubbedGetGQLClientFn GetGQLClientFn
222+
requestArgs map[string]any
223+
expectToolError bool
224+
expectedToolErrMsg string
225+
expectedTeamsCount int
226+
}{
227+
{
228+
name: "successful get teams",
229+
stubbedGetClientFn: stubGetClientFromHTTPFn(
230+
mock.NewMockedHTTPClient(
231+
mock.WithRequestMatch(
232+
mock.GetUser,
233+
mockUser,
234+
),
235+
),
236+
),
237+
stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
238+
// The GraphQL query constructed by the Go struct
239+
queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}"
240+
vars := map[string]interface{}{
241+
"login": "testuser",
242+
}
243+
matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockTeamsResponse)
244+
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
245+
return githubv4.NewClient(httpClient), nil
246+
},
247+
requestArgs: map[string]any{},
248+
expectToolError: false,
249+
expectedTeamsCount: 2,
250+
},
251+
{
252+
name: "no teams found",
253+
stubbedGetClientFn: stubGetClientFromHTTPFn(
254+
mock.NewMockedHTTPClient(
255+
mock.WithRequestMatch(
256+
mock.GetUser,
257+
mockUser,
258+
),
259+
),
260+
),
261+
stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
262+
queryStr := "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}"
263+
vars := map[string]interface{}{
264+
"login": "testuser",
265+
}
266+
matcher := githubv4mock.NewQueryMatcher(queryStr, vars, mockNoTeamsResponse)
267+
httpClient := githubv4mock.NewMockedHTTPClient(matcher)
268+
return githubv4.NewClient(httpClient), nil
269+
},
270+
requestArgs: map[string]any{},
271+
expectToolError: true,
272+
expectedToolErrMsg: "no teams found for user",
273+
},
274+
{
275+
name: "getting client fails",
276+
stubbedGetClientFn: stubGetClientFnErr("expected test error"),
277+
stubbedGetGQLClientFn: nil,
278+
requestArgs: map[string]any{},
279+
expectToolError: true,
280+
expectedToolErrMsg: "failed to get GitHub client: expected test error",
281+
},
282+
{
283+
name: "get user fails",
284+
stubbedGetClientFn: stubGetClientFromHTTPFn(
285+
mock.NewMockedHTTPClient(
286+
mock.WithRequestMatchHandler(
287+
mock.GetUser,
288+
badRequestHandler("expected test failure"),
289+
),
290+
),
291+
),
292+
stubbedGetGQLClientFn: nil,
293+
requestArgs: map[string]any{},
294+
expectToolError: true,
295+
expectedToolErrMsg: "expected test failure",
296+
},
297+
{
298+
name: "getting GraphQL client fails",
299+
stubbedGetClientFn: stubGetClientFromHTTPFn(
300+
mock.NewMockedHTTPClient(
301+
mock.WithRequestMatch(
302+
mock.GetUser,
303+
mockUser,
304+
),
305+
),
306+
),
307+
stubbedGetGQLClientFn: func(_ context.Context) (*githubv4.Client, error) {
308+
return nil, fmt.Errorf("GraphQL client error")
309+
},
310+
requestArgs: map[string]any{},
311+
expectToolError: true,
312+
expectedToolErrMsg: "failed to get GitHub GQL client: GraphQL client error",
313+
},
314+
}
315+
316+
for _, tc := range tests {
317+
t.Run(tc.name, func(t *testing.T) {
318+
_, handler := GetMyTeams(tc.stubbedGetClientFn, tc.stubbedGetGQLClientFn, translations.NullTranslationHelper)
319+
320+
request := createMCPRequest(tc.requestArgs)
321+
result, err := handler(context.Background(), request)
322+
require.NoError(t, err)
323+
textContent := getTextResult(t, result)
324+
325+
if tc.expectToolError {
326+
assert.True(t, result.IsError, "expected tool call result to be an error")
327+
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
328+
return
329+
}
330+
331+
var organizations []struct {
332+
Login string `json:"login"`
333+
Teams struct {
334+
Nodes []struct {
335+
Name string `json:"name"`
336+
Slug string `json:"slug"`
337+
Description string `json:"description"`
338+
} `json:"nodes"`
339+
} `json:"teams"`
340+
}
341+
err = json.Unmarshal([]byte(textContent.Text), &organizations)
342+
require.NoError(t, err)
343+
344+
assert.Len(t, organizations, tc.expectedTeamsCount)
345+
346+
if tc.expectedTeamsCount > 0 {
347+
assert.Equal(t, "testorg1", organizations[0].Login)
348+
assert.Len(t, organizations[0].Teams.Nodes, 2)
349+
assert.Equal(t, "Frontend Team", organizations[0].Teams.Nodes[0].Name)
350+
assert.Equal(t, "frontend-team", organizations[0].Teams.Nodes[0].Slug)
351+
352+
assert.Equal(t, "testorg2", organizations[1].Login)
353+
assert.Len(t, organizations[1].Teams.Nodes, 1)
354+
assert.Equal(t, "DevOps Team", organizations[1].Teams.Nodes[0].Name)
355+
}
356+
})
357+
}
358+
}

0 commit comments

Comments
 (0)