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 eacaef241..9dc2225e7 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 000000000..8ed178463
--- /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 000000000..21746fbdd
--- /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 1a4a3b4d1..c01d2d728 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