diff --git a/README.md b/README.md index f0c1a7401..ed4bc854e 100644 --- a/README.md +++ b/README.md @@ -671,6 +671,12 @@ The following sets of tools are available: - **get_me** - Get my user profile - No parameters required +- **get_org_members** - Get organization members + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` + - `org`: Organization login (owner) to get members for. (string, required) + - `role`: Filter by role: all, admin, member (string, optional) + - **get_team_members** - Get team members - **Required OAuth Scopes**: `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` @@ -682,6 +688,11 @@ The following sets of tools are available: - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional) +- **list_outside_collaborators** - List outside collaborators + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` + - `org`: The organization name (string, required) +
diff --git a/go.mod b/go.mod index c6c6e2967..5a50e646a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/github/github-mcp-server -go 1.24.0 +go 1.24.4 + +toolchain go1.24.11 require ( github.com/google/go-github/v79 v79.0.0 @@ -13,6 +15,12 @@ require ( github.com/stretchr/testify v1.11.1 ) +require ( + github.com/google/go-github/v73 v73.0.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + golang.org/x/time v0.11.0 // indirect +) + require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -27,6 +35,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 github.com/mailru/easyjson v0.7.7 // indirect + github.com/migueleliasweb/go-github-mock v1.5.0 github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index d525cb0a1..c7a6abd14 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= +github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= github.com/google/go-github/v79 v79.0.0 h1:MdodQojuFPBhmtwHiBcIGLw/e/wei2PvFX9ndxK0X4Y= github.com/google/go-github/v79 v79.0.0/go.mod h1:OAFbNhq7fQwohojb06iIIQAB9CBGYLq999myfUFnrS4= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -32,6 +34,8 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= @@ -55,6 +59,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/migueleliasweb/go-github-mock v1.5.0 h1:dIr6vgVz8QY9sDiDopWxk6pDw4d7K/xIcCk/NQe4ajM= +github.com/migueleliasweb/go-github-mock v1.5.0/go.mod h1:/DUmhXkxrgVlDOVBqGoUXkV4w0ms5n1jDQHotYm135o= github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= @@ -138,6 +144,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/pkg/github/__toolsnaps__/get_org_members.snap b/pkg/github/__toolsnaps__/get_org_members.snap new file mode 100644 index 000000000..6c0b71525 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_org_members.snap @@ -0,0 +1,24 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get organization 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", + "inputSchema": { + "properties": { + "org": { + "description": "Organization login (owner) to get members for.", + "type": "string" + }, + "role": { + "description": "Filter by role: all, admin, member", + "type": "string" + } + }, + "required": [ + "org" + ], + "type": "object" + }, + "name": "get_org_members" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_outside_collaborators.snap b/pkg/github/__toolsnaps__/list_outside_collaborators.snap new file mode 100644 index 000000000..60bbbdfa1 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_outside_collaborators.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List outside collaborators" + }, + "description": "List all outside collaborators of an organization (users with access to organization repositories but not members).", + "inputSchema": { + "properties": { + "org": { + "description": "The organization name", + "type": "string" + } + }, + "required": [ + "org" + ], + "type": "object" + }, + "name": "list_outside_collaborators" +} \ No newline at end of file diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 29fa2925d..35379c850 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "strings" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -111,6 +112,14 @@ type OrganizationTeams struct { Teams []TeamInfo `json:"teams"` } +type OutUser struct { + Login string `json:"login"` + ID string `json:"id"` + AvatarURL string `json:"avatar_url"` + Type string `json:"type"` + SiteAdmin bool `json:"site_admin"` +} + func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataContext, @@ -279,3 +288,137 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) } + +func GetOrgMembers(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataContext, + mcp.Tool{ + Name: "get_org_members", + Description: 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"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_ORG_MEMBERS_TITLE", "Get organization members"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "org": { + Type: "string", + Description: t("TOOL_GET_ORG_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) to get members for."), + }, + "role": { + Type: "string", + Description: "Filter by role: all, admin, member", + }, + }, + Required: []string{"org"}, + }, + }, + []scopes.Scope{scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + role, err := OptionalParam[string](args, "role") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GQL client", err), nil, nil + } + + roleFilter := strings.ToLower(strings.TrimSpace(role)) + if roleFilter == "all" { + roleFilter = "" + } + + var q struct { + Organization struct { + MembersWithRole struct { + Edges []struct { + Role githubv4.String + Node struct { + Login githubv4.String + } + } + } `graphql:"membersWithRole(first: 100)"` + } `graphql:"organization(login: $org)"` + } + vars := map[string]any{ + "org": githubv4.String(org), + } + + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get organization members", err), nil, nil + } + + members := make([]struct { + Login string `json:"login"` + Role string `json:"role"` + }, 0, len(q.Organization.MembersWithRole.Edges)) + for _, member := range q.Organization.MembersWithRole.Edges { + if roleFilter != "" && strings.ToLower(string(member.Role)) != roleFilter { + continue + } + members = append(members, struct { + Login string `json:"login"` + Role string `json:"role"` + }{Login: string(member.Node.Login), Role: string(member.Role)}) + } + + return MarshalledTextResult(members), nil, nil + }, + ) +} + +func ListOutsideCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataContext, + mcp.Tool{ + Name: "list_outside_collaborators", + Description: t("TOOL_LIST_OUTSIDE_COLLABORATORS_DESCRIPTION", "List all outside collaborators of an organization (users with access to organization repositories but not members)."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_OUTSIDE_COLLABORATORS_TITLE", "List outside collaborators"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "org": { + Type: "string", + Description: t("TOOL_LIST_OUTSIDE_COLLABORATORS_ORG_DESCRIPTION", "The organization name"), + }, + }, + Required: []string{"org"}, + }, + }, + []scopes.Scope{scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + users, resp, err := client.Organizations.ListOutsideCollaborators(ctx, org, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "Failed to list outside collaborators", resp, err), nil, nil + } + + collaborators := make([]string, 0, len(users)) + for _, user := range users { + collaborators = append(collaborators, user.GetLogin()) + } + + return MarshalledTextResult(collaborators), nil, nil + }, + ) +} diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 3f4261e71..6773f31b2 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -11,6 +11,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" + "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -513,3 +514,244 @@ func Test_GetTeamMembers(t *testing.T) { }) } } + +func Test_GetOrgMembers(t *testing.T) { + t.Parallel() + + serverTool := GetOrgMembers(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_org_members", tool.Name) + assert.True(t, tool.Annotations.ReadOnlyHint, "get_org_members tool should be read-only") + + var membersQuery struct { + Organization struct { + MembersWithRole struct { + Edges []struct { + Role githubv4.String + Node struct { + Login githubv4.String + } + } + } `graphql:"membersWithRole(first: 100)"` + } `graphql:"organization(login: $org)"` + } + vars := map[string]any{ + "org": githubv4.String("testorg"), + } + + mockMembersResponse := githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "membersWithRole": map[string]any{ + "edges": []map[string]any{ + { + "role": "ADMIN", + "node": map[string]any{ + "login": "user1", + }, + }, + { + "role": "MEMBER", + "node": map[string]any{ + "login": "user2", + }, + }, + }, + }, + }, + }) + + mockNoMembersResponse := githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "membersWithRole": map[string]any{ + "edges": []map[string]any{}, + }, + }, + }) + + gqlClientWithMembers := func(response githubv4mock.GQLResponse) *githubv4.Client { + matcher := githubv4mock.NewQueryMatcher(membersQuery, vars, response) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + return githubv4.NewClient(httpClient) + } + + tests := []struct { + name string + makeDeps func() ToolDependencies + requestArgs map[string]any + expectToolErr bool + expectErrMsg string + expectCount int + }{ + { + name: "successful get org members", + makeDeps: func() ToolDependencies { + return BaseDeps{GQLClient: gqlClientWithMembers(mockMembersResponse)} + }, + requestArgs: map[string]any{"org": "testorg", "role": "all"}, + expectCount: 2, + }, + { + name: "org with no members", + makeDeps: func() ToolDependencies { + return BaseDeps{GQLClient: gqlClientWithMembers(mockNoMembersResponse)} + }, + requestArgs: map[string]any{"org": "testorg", "role": "all"}, + expectCount: 0, + }, + { + name: "getting client fails", + makeDeps: func() ToolDependencies { return stubDeps{gqlClientFn: stubGQLClientFnErr("expected test error")} }, + requestArgs: map[string]any{"org": "testorg"}, + expectToolErr: true, + expectErrMsg: "failed to get GitHub GQL client: expected test error", + }, + { + name: "api error", + makeDeps: func() ToolDependencies { + return BaseDeps{GQLClient: gqlClientWithMembers(githubv4mock.ErrorResponse("boom"))} + }, + requestArgs: map[string]any{"org": "testorg"}, + expectToolErr: true, + expectErrMsg: "Failed to get organization members", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := tc.makeDeps() + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectToolErr { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectErrMsg) + return + } + + textContent := getTextResult(t, result) + + var members []struct { + Login string `json:"login"` + Role string `json:"role"` + } + err = json.Unmarshal([]byte(textContent.Text), &members) + require.NoError(t, err) + + assert.Len(t, members, tc.expectCount) + }) + } +} + +func Test_ListOutsideCollaborators(t *testing.T) { + t.Parallel() + + serverTool := ListOutsideCollaborators(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_outside_collaborators", tool.Name) + assert.True(t, tool.Annotations.ReadOnlyHint, "list_outside_collaborators tool should be read-only") + + mockUsers := []map[string]any{ + {"login": "ext1"}, + {"login": "ext2"}, + } + + tests := []struct { + name string + makeDeps func() ToolDependencies + requestArgs map[string]any + expectToolErr bool + expectErrMsg string + expectCount int + }{ + { + name: "successful list outside collaborators", + makeDeps: func() ToolDependencies { + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/outside_collaborators", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(mockUsers)) + }), + ), + ) + return BaseDeps{Client: github.NewClient(httpClient)} + }, + requestArgs: map[string]any{"org": "testorg"}, + expectCount: 2, + }, + { + name: "no collaborators", + makeDeps: func() ToolDependencies { + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/outside_collaborators", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal([]map[string]any{})) + }), + ), + ) + return BaseDeps{Client: github.NewClient(httpClient)} + }, + requestArgs: map[string]any{"org": "testorg"}, + expectCount: 0, + }, + { + name: "getting client fails", + makeDeps: func() ToolDependencies { return stubDeps{clientFn: stubClientFnErr("expected test error")} }, + requestArgs: map[string]any{"org": "testorg"}, + expectToolErr: true, + expectErrMsg: "failed to get GitHub client: expected test error", + }, + { + name: "api error", + makeDeps: func() ToolDependencies { + httpClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/outside_collaborators", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ) + return BaseDeps{Client: github.NewClient(httpClient)} + }, + requestArgs: map[string]any{"org": "testorg"}, + expectToolErr: true, + expectErrMsg: "Failed to list outside collaborators", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := tc.makeDeps() + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectToolErr { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectErrMsg) + return + } + + textContent := getTextResult(t, result) + + var collabs []string + err = json.Unmarshal([]byte(textContent.Text), &collabs) + require.NoError(t, err) + + assert.Len(t, collabs, tc.expectCount) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 676976140..172a21890 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -11,6 +11,7 @@ import ( ) type GetClientFn func(context.Context) (*github.Client, error) + type GetGQLClientFn func(context.Context) (*githubv4.Client, error) // Toolset metadata constants - these define all available toolsets and their descriptions. @@ -160,6 +161,8 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetMe(t), GetTeams(t), GetTeamMembers(t), + GetOrgMembers(t), + ListOutsideCollaborators(t), // Repository tools SearchRepositories(t),