Skip to content

Commit d5105a4

Browse files
author
cointem
committed
update: getOrgMembers
1 parent 6a57e75 commit d5105a4

File tree

4 files changed

+250
-2
lines changed

4 files changed

+250
-2
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"annotations": {
3+
"title": "Get organization members",
4+
"readOnlyHint": true
5+
},
6+
"description": "Get member users of a specific organization. Returns a list of user objects with fields: login, id, avatar_url, type. Limited to organizations accessible with current credentials",
7+
"inputSchema": {
8+
"properties": {
9+
"org": {
10+
"description": "Organization login (owner) to get members for.",
11+
"type": "string"
12+
}
13+
,"role": {
14+
"description": "Filter by role: all, admin, member",
15+
"type": "string"
16+
},
17+
"per_page": {
18+
"description": "Results per page (max 100)",
19+
"type": "number"
20+
},
21+
"page": {
22+
"description": "Page number for pagination",
23+
"type": "number"
24+
}
25+
},
26+
"required": [
27+
"org"
28+
],
29+
"type": "object"
30+
},
31+
"name": "get_org_members"
32+
}

pkg/github/context_tools.go

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

33
import (
44
"context"
5+
"fmt"
6+
"strings"
57
"time"
68

79
ghErrors "github.com/github/github-mcp-server/pkg/errors"
810
"github.com/github/github-mcp-server/pkg/translations"
11+
"github.com/go-viper/mapstructure/v2"
12+
"github.com/google/go-github/v79/github"
913
"github.com/mark3labs/mcp-go/mcp"
1014
"github.com/mark3labs/mcp-go/server"
1115
"github.com/shurcooL/githubv4"
@@ -249,3 +253,99 @@ func GetTeamMembers(getGQLClient GetGQLClientFn, t translations.TranslationHelpe
249253
return MarshalledTextResult(members), nil
250254
}
251255
}
256+
257+
func getOrgMembers(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
258+
return mcp.NewTool("get_org_members",
259+
mcp.WithDescription(t("TOOL_GET_ORG_MEMBERS_DESCRIPTION", "Get member users of a specific organization. Returns a list of user objects with fields: login, id, avatar_url, type. Limited to organizations accessible with current credentials")),
260+
mcp.WithString("org",
261+
mcp.Description(t("TOOL_GET_ORG_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) to get members for.")),
262+
mcp.Required(),
263+
),
264+
mcp.WithString("role",
265+
mcp.Description("Filter by role: all, admin, member"),
266+
),
267+
mcp.WithNumber("per_page",
268+
mcp.Description("Results per page (max 100)"),
269+
),
270+
mcp.WithNumber("page",
271+
mcp.Description("Page number for pagination"),
272+
),
273+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
274+
Title: t("TOOL_GET_ORG_MEMBERS_TITLE", "Get organization members"),
275+
ReadOnlyHint: ToBoolPtr(true),
276+
}),
277+
),
278+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
279+
// Decode params into struct to support optional numbers
280+
var params struct {
281+
Org string `mapstructure:"org"`
282+
Role string `mapstructure:"role"`
283+
PerPage int32 `mapstructure:"per_page"`
284+
Page int32 `mapstructure:"page"`
285+
}
286+
if err := mapstructure.Decode(request.Params.Arguments, &params); err != nil {
287+
return mcp.NewToolResultError(err.Error()), nil
288+
}
289+
org := params.Org
290+
role := params.Role
291+
perPage := params.PerPage
292+
page := params.Page
293+
if org == "" {
294+
return mcp.NewToolResultError("org is required"), nil
295+
}
296+
297+
// Defaults
298+
if perPage <= 0 {
299+
perPage = 30
300+
}
301+
if perPage > 100 {
302+
perPage = 100
303+
}
304+
if page <= 0 {
305+
page = 1
306+
}
307+
client, err := getClient(ctx)
308+
if err != nil {
309+
return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil
310+
}
311+
312+
// Map role string to REST role filter expected by GitHub API ("all","admin","member").
313+
roleFilter := ""
314+
if role != "" && strings.ToLower(role) != "all" {
315+
roleFilter = strings.ToLower(role)
316+
}
317+
318+
// Use Organizations.ListMembers with pagination (page/per_page)
319+
opts := &github.ListMembersOptions{
320+
Role: roleFilter,
321+
ListOptions: github.ListOptions{
322+
PerPage: int(perPage),
323+
Page: int(page),
324+
},
325+
}
326+
327+
users, resp, err := client.Organizations.ListMembers(ctx, org, opts)
328+
if err != nil {
329+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to get organization members", resp, err), nil
330+
}
331+
332+
type outUser struct {
333+
Login string `json:"login"`
334+
ID string `json:"id"`
335+
AvatarURL string `json:"avatar_url"`
336+
Type string `json:"type"`
337+
}
338+
339+
var members []outUser
340+
for _, u := range users {
341+
members = append(members, outUser{
342+
Login: u.GetLogin(),
343+
ID: fmt.Sprintf("%v", u.GetID()),
344+
AvatarURL: u.GetAvatarURL(),
345+
Type: u.GetType(),
346+
})
347+
}
348+
349+
return MarshalledTextResult(members), nil
350+
}
351+
}

pkg/github/context_tools_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"net/http"
78
"testing"
89
"time"
910

@@ -497,3 +498,115 @@ func Test_GetTeamMembers(t *testing.T) {
497498
})
498499
}
499500
}
501+
502+
func Test_GetOrgMembers(t *testing.T) {
503+
t.Parallel()
504+
505+
tool, _ := getOrgMembers(nil, translations.NullTranslationHelper)
506+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
507+
508+
assert.Equal(t, "get_org_members", tool.Name)
509+
assert.True(t, *tool.Annotations.ReadOnlyHint, "get_org_members tool should be read-only")
510+
511+
// Mocked REST users as returned by GitHub REST API
512+
mockUsers := []map[string]any{
513+
{"login": "user1", "id": 11, "avatar_url": "https://example.com/avatars/1", "type": "User"},
514+
{"login": "user2", "id": 22, "avatar_url": "https://example.com/avatars/2", "type": "User"},
515+
}
516+
517+
tests := []struct {
518+
name string
519+
mockedClient *http.Client
520+
stubGetClient GetClientFn
521+
requestArgs map[string]any
522+
expectToolErr bool
523+
expectErrMsg string
524+
expectCount int
525+
}{
526+
{
527+
name: "successful get org members",
528+
mockedClient: mock.NewMockedHTTPClient(
529+
mock.WithRequestMatchHandler(
530+
mock.EndpointPattern{Pattern: "/orgs/{org}/members", Method: http.MethodGet},
531+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
532+
w.WriteHeader(http.StatusOK)
533+
_, _ = w.Write(mock.MustMarshal(mockUsers))
534+
}),
535+
),
536+
),
537+
requestArgs: map[string]any{"org": "testorg", "role": "all", "per_page": 30, "page": 1},
538+
expectCount: 2,
539+
},
540+
{
541+
name: "org with no members",
542+
mockedClient: mock.NewMockedHTTPClient(
543+
mock.WithRequestMatchHandler(
544+
mock.EndpointPattern{Pattern: "/orgs/{org}/members", Method: http.MethodGet},
545+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
546+
w.WriteHeader(http.StatusOK)
547+
_, _ = w.Write(mock.MustMarshal([]map[string]any{}))
548+
}),
549+
),
550+
),
551+
requestArgs: map[string]any{"org": "testorg", "role": "all", "per_page": 30, "page": 1},
552+
expectCount: 0,
553+
},
554+
{
555+
name: "getting client fails",
556+
mockedClient: nil,
557+
stubGetClient: stubGetClientFnErr("expected test error"),
558+
requestArgs: map[string]any{"org": "testorg"},
559+
expectToolErr: true,
560+
expectErrMsg: "failed to get GitHub client: expected test error",
561+
},
562+
{
563+
name: "api error",
564+
mockedClient: mock.NewMockedHTTPClient(
565+
mock.WithRequestMatchHandler(
566+
mock.EndpointPattern{Pattern: "/orgs/{org}/members", Method: http.MethodGet},
567+
mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
568+
),
569+
),
570+
requestArgs: map[string]any{"org": "testorg"},
571+
expectToolErr: true,
572+
expectErrMsg: "Failed to get organization members",
573+
},
574+
}
575+
576+
for _, tc := range tests {
577+
t.Run(tc.name, func(t *testing.T) {
578+
var stubFn GetClientFn
579+
if tc.stubGetClient != nil {
580+
stubFn = tc.stubGetClient
581+
} else if tc.mockedClient != nil {
582+
stubFn = stubGetClientFromHTTPFn(tc.mockedClient)
583+
} else {
584+
stubFn = nil
585+
}
586+
587+
_, handler := getOrgMembers(stubFn, translations.NullTranslationHelper)
588+
589+
request := createMCPRequest(tc.requestArgs)
590+
result, err := handler(context.Background(), request)
591+
require.NoError(t, err)
592+
textContent := getTextResult(t, result)
593+
594+
if tc.expectToolErr {
595+
assert.True(t, result.IsError)
596+
assert.Contains(t, textContent.Text, tc.expectErrMsg)
597+
return
598+
}
599+
600+
var members []struct {
601+
Login string `json:"login"`
602+
ID string `json:"id"`
603+
AvatarURL string `json:"avatar_url"`
604+
Type string `json:"type"`
605+
}
606+
err = json.Unmarshal([]byte(textContent.Text), &members)
607+
require.NoError(t, err)
608+
609+
assert.Len(t, members, tc.expectCount)
610+
})
611+
}
612+
}

pkg/github/tools.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import (
1414
"github.com/shurcooL/githubv4"
1515
)
1616

17-
type GetClientFn func(context.Context) (*github.Client, error)
18-
type GetGQLClientFn func(context.Context) (*githubv4.Client, error)
17+
type (
18+
GetClientFn func(context.Context) (*github.Client, error)
19+
GetGQLClientFn func(context.Context) (*githubv4.Client, error)
20+
)
1921

2022
// ToolsetMetadata holds metadata for a toolset including its ID and description
2123
type ToolsetMetadata struct {
@@ -312,6 +314,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
312314
toolsets.NewServerTool(GetMe(getClient, t)),
313315
toolsets.NewServerTool(GetTeams(getClient, getGQLClient, t)),
314316
toolsets.NewServerTool(GetTeamMembers(getGQLClient, t)),
317+
toolsets.NewServerTool(getOrgMembers(getClient, t)),
315318
)
316319

317320
gists := toolsets.NewToolset(ToolsetMetadataGists.ID, ToolsetMetadataGists.Description).

0 commit comments

Comments
 (0)