From 2661d1f7c03a396125f2641154d663e0ab478475 Mon Sep 17 00:00:00 2001 From: Nikita <190351315+riseandignite@users.noreply.github.com> Date: Fri, 25 Apr 2025 19:30:57 +0800 Subject: [PATCH] feat: add organizations toolset --- README.md | 27 ++-- pkg/github/organizations.go | 111 +++++++++++++++ pkg/github/organizations_test.go | 223 +++++++++++++++++++++++++++++++ pkg/github/tools.go | 6 + 4 files changed, 359 insertions(+), 8 deletions(-) create mode 100644 pkg/github/organizations.go create mode 100644 pkg/github/organizations_test.go diff --git a/README.md b/README.md index eacaef24..9dc2225e 100644 --- a/README.md +++ b/README.md @@ -145,14 +145,15 @@ The GitHub MCP Server supports enabling or disabling specific groups of function The following sets of tools are available (all are on by default): -| Toolset | Description | -| ----------------------- | ------------------------------------------------------------- | -| `repos` | Repository-related tools (file operations, branches, commits) | -| `issues` | Issue-related tools (create, read, update, comment) | -| `users` | Anything relating to GitHub Users | -| `pull_requests` | Pull request operations (create, merge, review) | -| `code_security` | Code scanning alerts and security features | -| `experiments` | Experimental features (not considered stable) | +| Toolset | Description | +| --------------- | ------------------------------------------------------------- | +| `repos` | Repository-related tools (file operations, branches, commits) | +| `issues` | Issue-related tools (create, read, update, comment) | +| `users` | Anything relating to GitHub Users | +| `pull_requests` | Pull request operations (create, merge, review) | +| `code_security` | Code scanning alerts and security features | +| `organizations` | Organization-related tools (list, get) | +| `experiments` | Experimental features (not considered stable) | #### Specifying Toolsets @@ -269,6 +270,16 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - **get_me** - Get details of the authenticated user - No parameters required +### Organizations + +- **list_organizations** - List organizations the authenticated user is a member of + + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + +- **get_organization** - Get information about an organization + - `org`: Organization name (string, required) + ### Issues - **get_issue** - Gets the contents of an issue within a repository diff --git a/pkg/github/organizations.go b/pkg/github/organizations.go new file mode 100644 index 00000000..8ed17846 --- /dev/null +++ b/pkg/github/organizations.go @@ -0,0 +1,111 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v69/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// ListOrganizations creates a tool to list organizations a user is part of. +func ListOrganizations(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_organizations", + mcp.WithDescription(t("TOOL_LIST_ORGANIZATIONS_DESCRIPTION", "List organizations the authenticated user is a member of")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ORGANIZATIONS_USER_TITLE", "List organizations"), + ReadOnlyHint: true, + }), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.ListOptions{ + Page: pagination.page, + PerPage: pagination.perPage, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Call the GitHub API to list orgs + orgs, resp, err := client.Organizations.List(ctx, "", opts) + if err != nil { + return nil, fmt.Errorf("failed to list organizations: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list organizations: %s", string(body))), nil + } + + r, err := json.Marshal(orgs) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +// GetOrganization creates a tool to get details for a specific organization. +func GetOrganization(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_organization", + mcp.WithDescription(t("TOOL_GET_ORGANIZATION_DESCRIPTION", "Get information about an organization")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_ORGANIZATION_USER_TITLE", "Get organization"), + ReadOnlyHint: true, + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("Organization name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + orgName, err := requiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Call the GitHub API to get org details + org, resp, err := client.Organizations.Get(ctx, orgName) + if err != nil { + return nil, fmt.Errorf("failed to get organization: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != 200 { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get organization: %s", string(body))), nil + } + + r, err := json.Marshal(org) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} \ No newline at end of file diff --git a/pkg/github/organizations_test.go b/pkg/github/organizations_test.go new file mode 100644 index 00000000..21746fbd --- /dev/null +++ b/pkg/github/organizations_test.go @@ -0,0 +1,223 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v69/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ListOrganizations(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListOrganizations(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_organizations", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + + // Setup mock orgs for success case + mockOrgs := []*github.Organization{ + { + Login: github.Ptr("org1"), + NodeID: github.Ptr("node1"), + AvatarURL: github.Ptr("https://github.com/images/org1.png"), + HTMLURL: github.Ptr("https://github.com/org1"), + }, + { + Login: github.Ptr("org2"), + NodeID: github.Ptr("node2"), + AvatarURL: github.Ptr("https://github.com/images/org2.png"), + HTMLURL: github.Ptr("https://github.com/org2"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedOrgs []*github.Organization + expectedErrMsg string + }{ + { + name: "successful orgs fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetUserOrgs, + mockOrgs, + ), + ), + requestArgs: map[string]interface{}{ + "page": float64(1), + "perPage": float64(10), + }, + expectError: false, + expectedOrgs: mockOrgs, + }, + { + name: "orgs fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetUserOrgs, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Unauthorized"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "page": float64(1), + "perPage": float64(10), + }, + expectError: true, + expectedErrMsg: "failed to list organizations", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListOrganizations(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedOrgs []*github.Organization + err = json.Unmarshal([]byte(textContent.Text), &returnedOrgs) + require.NoError(t, err) + require.Equal(t, len(tc.expectedOrgs), len(returnedOrgs)) + + for i, expectedOrg := range tc.expectedOrgs { + assert.Equal(t, *expectedOrg.Login, *returnedOrgs[i].Login) + assert.Equal(t, *expectedOrg.NodeID, *returnedOrgs[i].NodeID) + assert.Equal(t, *expectedOrg.HTMLURL, *returnedOrgs[i].HTMLURL) + } + }) + } +} + +func Test_GetOrganization(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetOrganization(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_organization", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"}) + + // Setup mock org for success case + mockOrg := &github.Organization{ + Login: github.Ptr("testorg"), + NodeID: github.Ptr("node123"), + Name: github.Ptr("Test Organization"), + Description: github.Ptr("This is a test organization"), + AvatarURL: github.Ptr("https://github.com/images/testorg.png"), + HTMLURL: github.Ptr("https://github.com/testorg"), + Location: github.Ptr("San Francisco"), + Blog: github.Ptr("https://testorg.com"), + Email: github.Ptr("info@testorg.com"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedOrg *github.Organization + expectedErrMsg string + }{ + { + name: "successful org fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetOrgsByOrg, + mockOrg, + ), + ), + requestArgs: map[string]interface{}{ + "org": "testorg", + }, + expectError: false, + expectedOrg: mockOrg, + }, + { + name: "org fetch fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetOrgsByOrg, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "nonexistentorg", + }, + expectError: true, + expectedErrMsg: "failed to get organization", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetOrganization(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedOrg github.Organization + err = json.Unmarshal([]byte(textContent.Text), &returnedOrg) + require.NoError(t, err) + assert.Equal(t, *tc.expectedOrg.Login, *returnedOrg.Login) + assert.Equal(t, *tc.expectedOrg.Name, *returnedOrg.Name) + assert.Equal(t, *tc.expectedOrg.Description, *returnedOrg.Description) + assert.Equal(t, *tc.expectedOrg.HTMLURL, *returnedOrg.HTMLURL) + }) + } +} + diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1a4a3b4d..c01d2d72 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -78,6 +78,11 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)), toolsets.NewServerTool(ListSecretScanningAlerts(getClient, t)), ) + organizations := toolsets.NewToolset("organizations", "GitHub Organization related tools"). + AddReadTools( + toolsets.NewServerTool(ListOrganizations(getClient, t)), + toolsets.NewServerTool(GetOrganization(getClient, t)), + ) // Keep experiments alive so the system doesn't error out when it's always enabled experiments := toolsets.NewToolset("experiments", "Experimental features that are not considered stable yet") @@ -88,6 +93,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, tsg.AddToolset(pullRequests) tsg.AddToolset(codeSecurity) tsg.AddToolset(secretProtection) + tsg.AddToolset(organizations) tsg.AddToolset(experiments) // Enable the requested features