From 88be042d909249a88260e7da7705d815083209e0 Mon Sep 17 00:00:00 2001 From: "Issei.M" Date: Wed, 8 Oct 2025 19:16:57 +0900 Subject: [PATCH] Create org invitation tool --- .../__toolsnaps__/create_org_invitation.snap | 46 ++++ pkg/github/orgs.go | 186 ++++++++++++++ pkg/github/orgs_test.go | 227 ++++++++++++++++++ pkg/github/tools.go | 3 + 4 files changed, 462 insertions(+) create mode 100644 pkg/github/__toolsnaps__/create_org_invitation.snap create mode 100644 pkg/github/orgs.go create mode 100644 pkg/github/orgs_test.go diff --git a/pkg/github/__toolsnaps__/create_org_invitation.snap b/pkg/github/__toolsnaps__/create_org_invitation.snap new file mode 100644 index 000000000..ddbfaada0 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_org_invitation.snap @@ -0,0 +1,46 @@ +{ + "annotations": { + "title": "Create Organization Invitation", + "readOnlyHint": false + }, + "description": "Invite a user to join an organization by GitHub user ID or email address. Requires organization owner permissions. This endpoint triggers notifications and may be subject to rate limiting.", + "inputSchema": { + "properties": { + "email": { + "description": "Email address of the person you are inviting. Required unless invitee_id is provided.", + "type": "string" + }, + "invitee_id": { + "description": "GitHub user ID for the person you are inviting. Required unless email is provided.", + "type": "number" + }, + "org": { + "description": "The organization name (not case sensitive)", + "type": "string" + }, + "role": { + "default": "direct_member", + "description": "The role for the new member", + "enum": [ + "admin", + "direct_member", + "billing_manager", + "reinstate" + ], + "type": "string" + }, + "team_ids": { + "description": "Team IDs to invite new members to", + "items": { + "type": "number" + }, + "type": "array" + } + }, + "required": [ + "org" + ], + "type": "object" + }, + "name": "create_org_invitation" +} \ No newline at end of file diff --git a/pkg/github/orgs.go b/pkg/github/orgs.go new file mode 100644 index 000000000..965ec8c08 --- /dev/null +++ b/pkg/github/orgs.go @@ -0,0 +1,186 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/google/go-github/v74/github" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/github/github-mcp-server/pkg/translations" +) + +// CreateOrgInvitation creates a new invitation for a user to join an organization +func CreateOrgInvitation(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_org_invitation", + mcp.WithDescription(t("TOOL_CREATE_ORG_INVITATION_DESCRIPTION", "Invite a user to join an organization by GitHub user ID or email address. Requires organization owner permissions. This endpoint triggers notifications and may be subject to rate limiting.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_ORG_INVITATION", "Create Organization Invitation"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("org", + mcp.Required(), + mcp.Description("The organization name (not case sensitive)"), + ), + mcp.WithNumber("invitee_id", + mcp.Description("GitHub user ID for the person you are inviting. Required unless email is provided."), + ), + mcp.WithString("email", + mcp.Description("Email address of the person you are inviting. Required unless invitee_id is provided."), + ), + mcp.WithString("role", + mcp.Description("The role for the new member"), + mcp.Enum("admin", "direct_member", "billing_manager", "reinstate"), + mcp.DefaultString("direct_member"), + ), + mcp.WithArray("team_ids", + mcp.Description("Team IDs to invite new members to"), + mcp.Items(map[string]any{ + "type": "number", + }), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + org, err := RequiredParam[string](request, "org") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + inviteeID, err := OptionalParam[float64](request, "invitee_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + email, err := OptionalParam[string](request, "email") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Validate that at least one of invitee_id or email is provided + if inviteeID == 0 && email == "" { + return mcp.NewToolResultError("either invitee_id or email must be provided"), nil + } + + role, err := OptionalParam[string](request, "role") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if role == "" { + role = "direct_member" + } + + var teamIDs []int64 + if rawTeamIDs, ok := request.GetArguments()["team_ids"]; ok { + switch v := rawTeamIDs.(type) { + case nil: + // nothing to do + case []any: + for _, item := range v { + id, parseErr := parseTeamID(item) + if parseErr != nil { + return mcp.NewToolResultError(parseErr.Error()), nil + } + teamIDs = append(teamIDs, id) + } + case []float64: + for _, item := range v { + teamIDs = append(teamIDs, int64(item)) + } + default: + return mcp.NewToolResultError("team_ids must be an array of numbers"), nil + } + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Create the invitation request + invitation := &github.CreateOrgInvitationOptions{ + Role: github.Ptr(role), + TeamID: teamIDs, + } + + if inviteeID != 0 { + invitation.InviteeID = github.Ptr(int64(inviteeID)) + } + + if email != "" { + invitation.Email = github.Ptr(email) + } + + createdInvitation, resp, err := client.Organizations.CreateOrgInvitation(ctx, org, invitation) + if err != nil { + return nil, fmt.Errorf("failed to create organization invitation: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + 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 create organization invitation: %s", string(body))), nil + } + + // Return a minimal response with relevant information + type InvitationResponse struct { + ID int64 `json:"id"` + Login string `json:"login,omitempty"` + Email string `json:"email,omitempty"` + Role string `json:"role"` + InvitationTeamsURL string `json:"invitation_teams_url"` + CreatedAt string `json:"created_at"` + InviterLogin string `json:"inviter_login,omitempty"` + } + + response := InvitationResponse{ + ID: createdInvitation.GetID(), + Login: createdInvitation.GetLogin(), + Email: createdInvitation.GetEmail(), + Role: createdInvitation.GetRole(), + InvitationTeamsURL: createdInvitation.GetInvitationTeamURL(), + CreatedAt: createdInvitation.GetCreatedAt().Format("2006-01-02T15:04:05Z07:00"), + } + + if createdInvitation.Inviter != nil { + response.InviterLogin = createdInvitation.Inviter.GetLogin() + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func parseTeamID(value any) (int64, error) { + switch v := value.(type) { + case float64: + // JSON numbers decode to float64; ensure they are whole numbers + if v != float64(int64(v)) { + return 0, fmt.Errorf("team_id must be an integer value") + } + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case string: + id, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid team_id") + } + return id, nil + default: + return 0, fmt.Errorf("invalid team_id") + } +} diff --git a/pkg/github/orgs_test.go b/pkg/github/orgs_test.go new file mode 100644 index 000000000..5c5cb1290 --- /dev/null +++ b/pkg/github/orgs_test.go @@ -0,0 +1,227 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v74/github" + "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CreateOrgInvitation(t *testing.T) { + // Verify tool definition + mockClient := github.NewClient(nil) + tool, _ := CreateOrgInvitation(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_org_invitation", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "org") + assert.Contains(t, tool.InputSchema.Properties, "invitee_id") + assert.Contains(t, tool.InputSchema.Properties, "email") + assert.Contains(t, tool.InputSchema.Properties, "role") + assert.Contains(t, tool.InputSchema.Properties, "team_ids") + + // Verify required parameters + assert.Contains(t, tool.InputSchema.Required, "org") + + // Setup mock data for test cases + createdAt := time.Now() + createdInvitation := &github.Invitation{ + ID: github.Ptr(int64(1)), + Login: github.Ptr("octocat"), + Email: github.Ptr("octocat@github.com"), + Role: github.Ptr("direct_member"), + CreatedAt: &github.Timestamp{Time: createdAt}, + InvitationTeamURL: github.Ptr("https://api.github.com/organizations/1/invitations/1/teams"), + Inviter: &github.User{ + Login: github.Ptr("admin"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectedInvitation *github.Invitation + }{ + { + name: "create invitation with email successfully", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostOrgsInvitationsByOrg, + mockResponse(t, http.StatusCreated, createdInvitation), + ), + ), + requestArgs: map[string]interface{}{ + "org": "test-org", + "email": "octocat@github.com", + "role": "direct_member", + }, + expectError: false, + expectedInvitation: createdInvitation, + }, + { + name: "create invitation with invitee_id successfully", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostOrgsInvitationsByOrg, + mockResponse(t, http.StatusCreated, createdInvitation), + ), + ), + requestArgs: map[string]interface{}{ + "org": "test-org", + "invitee_id": float64(123456), + "role": "admin", + }, + expectError: false, + expectedInvitation: createdInvitation, + }, + { + name: "create invitation with team_ids", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostOrgsInvitationsByOrg, + mockResponse(t, http.StatusCreated, createdInvitation), + ), + ), + requestArgs: map[string]interface{}{ + "org": "test-org", + "email": "octocat@github.com", + "role": "direct_member", + "team_ids": []interface{}{float64(12), float64(26)}, + }, + expectError: false, + expectedInvitation: createdInvitation, + }, + { + name: "missing required org parameter", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "email": "octocat@github.com", + }, + expectError: true, + expectedErrMsg: "missing required parameter: org", + }, + { + name: "missing both invitee_id and email", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "org": "test-org", + }, + expectError: true, + expectedErrMsg: "either invitee_id or email must be provided", + }, + { + name: "invalid team_ids format", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "org": "test-org", + "email": "octocat@github.com", + "team_ids": []interface{}{"12", "abc"}, + }, + expectError: true, + expectedErrMsg: "invalid team_id", + }, + { + name: "api returns unauthorized error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostOrgsInvitationsByOrg, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message": "Must have admin rights to organization"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "test-org", + "email": "octocat@github.com", + }, + expectError: true, + expectedErrMsg: "failed to create organization invitation", + }, + { + name: "api returns validation error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostOrgsInvitationsByOrg, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "org": "test-org", + "email": "invalid-email", + }, + expectError: true, + expectedErrMsg: "failed to create organization invitation", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreateOrgInvitation(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + // For errors returned as part of the result, not as an error + assert.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, result) + + // Parse the result and get the text content + textContent := getTextResult(t, result) + + // Unmarshal and verify the invitation result + var invitation struct { + ID int64 `json:"id"` + Login string `json:"login,omitempty"` + Email string `json:"email,omitempty"` + Role string `json:"role"` + InvitationTeamsURL string `json:"invitation_teams_url"` + CreatedAt string `json:"created_at"` + InviterLogin string `json:"inviter_login,omitempty"` + } + err = json.Unmarshal([]byte(textContent.Text), &invitation) + require.NoError(t, err) + + assert.Equal(t, tc.expectedInvitation.GetID(), invitation.ID) + assert.Equal(t, tc.expectedInvitation.GetRole(), invitation.Role) + if tc.expectedInvitation.GetEmail() != "" { + assert.Equal(t, tc.expectedInvitation.GetEmail(), invitation.Email) + } + if tc.expectedInvitation.GetLogin() != "" { + assert.Equal(t, tc.expectedInvitation.GetLogin(), invitation.Login) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a0b1690c9..3a766ee1e 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -219,6 +219,9 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG orgs := toolsets.NewToolset(ToolsetMetadataOrgs.ID, ToolsetMetadataOrgs.Description). AddReadTools( toolsets.NewServerTool(SearchOrgs(getClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(CreateOrgInvitation(getClient, t)), ) pullRequests := toolsets.NewToolset(ToolsetMetadataPullRequests.ID, ToolsetMetadataPullRequests.Description). AddReadTools(