diff --git a/README.md b/README.md index a6e740e66..2679d761d 100644 --- a/README.md +++ b/README.md @@ -401,18 +401,15 @@ The following sets of tools are available (all are on by default): Code Security -- **get_code_scanning_alert** - Get code scanning alert - - `alertNumber`: The number of the alert. (number, required) - - `owner`: The owner of the repository. (string, required) - - `repo`: The name of the repository. (string, required) - -- **list_code_scanning_alerts** - List code scanning alerts - - `owner`: The owner of the repository. (string, required) - - `ref`: The Git reference for the results you want to list. (string, optional) - - `repo`: The name of the repository. (string, required) - - `severity`: Filter code scanning alerts by severity (string, optional) - - `state`: Filter code scanning alerts by state. Defaults to open (string, optional) - - `tool_name`: The name of the tool used for code scanning. (string, optional) +- **manage_code_scanning_alerts** - Manage Code Scanning Alerts + - `alertNumber`: The number of the alert (required for 'get' operation) (number, optional) + - `operation`: Operation to perform: 'list', 'get' (string, required) + - `owner`: The owner of the repository (string, required) + - `ref`: The Git reference for the results you want to list (used for 'list' operation) (string, optional) + - `repo`: The name of the repository (string, required) + - `severity`: Filter code scanning alerts by severity (used for 'list' operation) (string, optional) + - `state`: Filter code scanning alerts by state (used for 'list' operation) (string, optional) + - `tool_name`: The name of the tool used for code scanning (used for 'list' operation) (string, optional) @@ -484,23 +481,17 @@ The following sets of tools are available (all are on by default): Gists -- **create_gist** - Create Gist - - `content`: Content for simple single-file gist creation (string, required) - - `description`: Description of the gist (string, optional) - - `filename`: Filename for simple single-file gist creation (string, required) - - `public`: Whether the gist is public (boolean, optional) - -- **list_gists** - List Gists +- **manage_gist** - Manage Gist + - `content`: Content for gist file (required for 'create' and 'update' operations) (string, optional) + - `description`: Description of the gist (used for 'create' and 'update' operations) (string, optional) + - `filename`: Filename for gist file (required for 'create' and 'update' operations) (string, optional) + - `gist_id`: ID of the gist (required for 'update' and 'get' operations) (string, optional) + - `operation`: Operation to perform: 'list', 'create', 'update', 'get' (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `since`: Only gists updated after this time (ISO 8601 timestamp) (string, optional) - - `username`: GitHub username (omit for authenticated user's gists) (string, optional) - -- **update_gist** - Update Gist - - `content`: Content for the file (string, required) - - `description`: Updated description of the gist (string, optional) - - `filename`: Filename to update or create (string, required) - - `gist_id`: ID of the gist to update (string, required) + - `public`: Whether the gist is public (used for 'create' operation) (boolean, optional) + - `since`: Only gists updated after this time (ISO 8601 timestamp, used for 'list' operation) (string, optional) + - `username`: GitHub username (omit for authenticated user's gists, used for 'list' and 'get' operations) (string, optional) @@ -508,12 +499,6 @@ The following sets of tools are available (all are on by default): Issues -- **add_issue_comment** - Add comment to issue - - `body`: Comment content (string, required) - - `issue_number`: Issue number to comment on (number, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - **add_sub_issue** - Add sub-issue - `issue_number`: The number of the parent issue (number, required) - `owner`: Repository owner (string, required) @@ -526,21 +511,6 @@ The following sets of tools are available (all are on by default): - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **create_issue** - Open new issue - - `assignees`: Usernames to assign to this issue (string[], optional) - - `body`: Issue body content (string, optional) - - `labels`: Labels to apply to this issue (string[], optional) - - `milestone`: Milestone number (number, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `title`: Issue title (string, required) - - `type`: Type of this issue (string, optional) - -- **get_issue** - Get issue details - - `issue_number`: The number of the issue (number, required) - - `owner`: The owner of the repository (string, required) - - `repo`: The name of the repository (string, required) - - **get_issue_comments** - Get issue comments - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) @@ -551,17 +521,6 @@ The following sets of tools are available (all are on by default): - **list_issue_types** - List available issue types - `owner`: The organization owner of the repository (string, required) -- **list_issues** - List issues - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - - `labels`: Filter by labels (string[], optional) - - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) - - `owner`: Repository owner (string, required) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - - `repo`: Repository name (string, required) - - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - - **list_sub_issues** - List sub-issues - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) @@ -569,6 +528,26 @@ The following sets of tools are available (all are on by default): - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - `repo`: Repository name (string, required) +- **manage_issue** - Manage Issue + - `assignees`: Usernames to assign to this issue (used for 'create', 'update' operations) (string[], optional) + - `body`: Issue body content (used for 'create', 'update', 'add_comment' operations) (string, optional) + - `direction`: Order direction for list operation: 'ASC', 'DESC' (string, optional) + - `issue_number`: Issue number (required for 'get', 'update', 'add_comment' operations) (number, optional) + - `labels`: Labels to apply to this issue (used for 'create', 'update' operations) (string[], optional) + - `list_labels`: Filter by labels for list operation (string[], optional) + - `list_state`: Filter by state for list operation: 'OPEN', 'CLOSED' (string, optional) + - `milestone`: Milestone number (used for 'create', 'update' operations) (number, optional) + - `operation`: Operation to perform: 'list', 'get', 'create', 'update', 'add_comment' (string, required) + - `orderBy`: Order issues by field for list operation: 'CREATED_AT', 'UPDATED_AT', 'COMMENTS' (string, optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `since`: Filter by date (ISO 8601 timestamp, used for 'list' operation) (string, optional) + - `state`: Issue state (used for 'update' operation): 'open' or 'closed' (string, optional) + - `title`: Issue title (required for 'create' operation) (string, optional) + - `type`: Type of this issue (used for 'create' operation) (string, optional) + - **remove_sub_issue** - Remove sub-issue - `issue_number`: The number of the parent issue (number, required) - `owner`: Repository owner (string, required) @@ -592,18 +571,6 @@ The following sets of tools are available (all are on by default): - `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional) - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) -- **update_issue** - Edit issue - - `assignees`: New assignees (string[], optional) - - `body`: New description (string, optional) - - `issue_number`: Issue number to update (number, required) - - `labels`: New labels (string[], optional) - - `milestone`: New milestone number (number, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `state`: New state (string, optional) - - `title`: New title (string, optional) - - `type`: New issue type (string, optional) -
@@ -685,26 +652,11 @@ The following sets of tools are available (all are on by default): - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **create_pull_request** - Open new pull request - - `base`: Branch to merge into (string, required) - - `body`: PR description (string, optional) - - `draft`: Create as draft PR (boolean, optional) - - `head`: Branch containing changes (string, required) - - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `title`: PR title (string, required) - - **delete_pending_pull_request_review** - Delete the requester's latest pending pull request review - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **get_pull_request** - Get pull request details - - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number (number, required) - - `repo`: Repository name (string, required) - - **get_pull_request_comments** - Get pull request comments - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) @@ -732,24 +684,28 @@ The following sets of tools are available (all are on by default): - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **list_pull_requests** - List pull requests - - `base`: Filter by base branch (string, optional) - - `direction`: Sort direction (string, optional) - - `head`: Filter by head user/org and branch (string, optional) +- **manage_pull_request** - Manage Pull Request + - `base`: The name of the branch you want the changes pulled into (required for 'create' operation) (string, optional) + - `body`: Pull request body content (used for 'create', 'update' operations) (string, optional) + - `commit_message`: Extra detail to append to merge commit message (used for 'merge' operation) (string, optional) + - `commit_title`: Title for the merge commit (used for 'merge' operation) (string, optional) + - `direction`: Sort direction for list operation: 'asc', 'desc' (string, optional) + - `draft`: Whether to create as draft (used for 'create' operation) (boolean, optional) + - `head`: The name of the branch where your changes are implemented (required for 'create' operation) (string, optional) + - `list_base`: Filter by base branch for list operation (string, optional) + - `list_head`: Filter by head user/org and branch for list operation (string, optional) + - `list_state`: Filter by state for list operation: 'open', 'closed', 'all' (string, optional) + - `maintainer_can_modify`: Whether maintainers can modify the pull request (used for 'create' operation) (boolean, optional) + - `merge_method`: Merge method to use (used for 'merge' operation): 'merge', 'squash', 'rebase' (string, optional) + - `operation`: Operation to perform: 'list', 'get', 'create', 'update', 'merge' (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `pullNumber`: Pull request number (required for 'get', 'update', 'merge' operations) (number, optional) - `repo`: Repository name (string, required) - - `sort`: Sort by (string, optional) - - `state`: Filter by state (string, optional) - -- **merge_pull_request** - Merge pull request - - `commit_message`: Extra detail for merge commit (string, optional) - - `commit_title`: Title for merge commit (string, optional) - - `merge_method`: Merge method (string, optional) - - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number (number, required) - - `repo`: Repository name (string, required) + - `sort`: Sort by for list operation: 'created', 'updated', 'popularity', 'long-running' (string, optional) + - `state`: State of the pull request (used for 'update' operation): 'open' or 'closed' (string, optional) + - `title`: Pull request title (required for 'create' operation) (string, optional) - **request_copilot_review** - Request Copilot review - `owner`: Repository owner (string, required) @@ -772,18 +728,6 @@ The following sets of tools are available (all are on by default): - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **update_pull_request** - Edit pull request - - `base`: New base branch name (string, optional) - - `body`: New description (string, optional) - - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) - - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - - `owner`: Repository owner (string, required) - - `pullNumber`: Pull request number to update (number, required) - - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames to request reviews from (string[], optional) - - `state`: New state (string, optional) - - `title`: New title (string, optional) - - **update_pull_request_branch** - Update pull request branch - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) - `owner`: Repository owner (string, required) @@ -811,12 +755,6 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - `sha`: Required if updating an existing file. The blob SHA of the file being replaced. (string, optional) -- **create_repository** - Create repository - - `autoInit`: Initialize with README (boolean, optional) - - `description`: Repository description (string, optional) - - `name`: Repository name (string, required) - - `private`: Whether repo should be private (boolean, optional) - - **delete_file** - Delete file - `branch`: Branch to delete the file from (string, required) - `message`: Commit message (string, required) @@ -824,11 +762,6 @@ The following sets of tools are available (all are on by default): - `path`: Path to the file to delete (string, required) - `repo`: Repository name (string, required) -- **fork_repository** - Fork repository - - `organization`: Organization to fork to (string, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - **get_commit** - Get commit details - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -836,13 +769,6 @@ The following sets of tools are available (all are on by default): - `repo`: Repository name (string, required) - `sha`: Commit SHA, branch name, or tag name (string, required) -- **get_file_contents** - Get file or directory contents - - `owner`: Repository owner (username or organization) (string, required) - - `path`: Path to file/directory (directories must end with a slash '/') (string, optional) - - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) - - `repo`: Repository name (string, required) - - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) - - **get_latest_release** - Get latest release - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -883,6 +809,20 @@ The following sets of tools are available (all are on by default): - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) +- **manage_repository** - Manage Repository + - `autoInit`: Initialize with README (used for 'create' operation) (boolean, optional) + - `default_branch_only`: Fork only default branch (used for 'fork' operation) (string, optional) + - `description`: Repository description (used for 'create' operation) (string, optional) + - `name`: Repository name (required for 'create' operation) (string, optional) + - `operation`: Operation to perform: 'create', 'fork', 'get_file_contents' (string, required) + - `organization`: Organization to fork to (used for 'fork' operation) (string, optional) + - `owner`: Repository owner (required for 'fork', 'get_file_contents' operations) (string, optional) + - `path`: Path to file/directory (required for 'get_file_contents' operation) (string, optional) + - `private`: Whether repo should be private (used for 'create' operation) (boolean, optional) + - `ref`: Git reference such as branch, tag, or commit SHA (used for 'get_file_contents' operation) (string, optional) + - `repo`: Repository name (required for 'fork', 'get_file_contents' operations) (string, optional) + - `sha`: Commit SHA (used for 'get_file_contents' operation) (string, optional) + - **push_files** - Push files to repository - `branch`: Branch to push to (string, required) - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required) @@ -908,17 +848,14 @@ The following sets of tools are available (all are on by default): Secret Protection -- **get_secret_scanning_alert** - Get secret scanning alert - - `alertNumber`: The number of the alert. (number, required) - - `owner`: The owner of the repository. (string, required) - - `repo`: The name of the repository. (string, required) - -- **list_secret_scanning_alerts** - List secret scanning alerts - - `owner`: The owner of the repository. (string, required) - - `repo`: The name of the repository. (string, required) - - `resolution`: Filter by resolution (string, optional) - - `secret_type`: A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter. (string, optional) - - `state`: Filter by state (string, optional) +- **manage_secret_scanning_alerts** - Manage Secret Scanning Alerts + - `alertNumber`: The number of the alert (required for 'get' operation) (number, optional) + - `operation`: Operation to perform: 'list', 'get' (string, required) + - `owner`: The owner of the repository (string, required) + - `repo`: The name of the repository (string, required) + - `resolution`: Filter by resolution (used for 'list' operation) (string, optional) + - `secret_type`: A comma-separated list of secret types to return (used for 'list' operation) (string, optional) + - `state`: Filter by state (used for 'list' operation) (string, optional)
diff --git a/github-mcp-server b/github-mcp-server index 864242c24..ea1b191b2 100755 Binary files a/github-mcp-server and b/github-mcp-server differ diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 47eaa4be0..61960c52e 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -14,6 +14,166 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// ManageCodeScanningAlerts creates a consolidated tool to perform operations on code scanning alerts +func ManageCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("manage_code_scanning_alerts", + mcp.WithDescription(t("TOOL_MANAGE_CODE_SCANNING_ALERTS_DESCRIPTION", "Manage code scanning alerts with various operations: list, get")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MANAGE_CODE_SCANNING_ALERTS", "Manage Code Scanning Alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("operation", + mcp.Required(), + mcp.Description("Operation to perform: 'list', 'get'"), + mcp.Enum("list", "get"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository"), + ), + // Parameters for get operation + mcp.WithNumber("alertNumber", + mcp.Description("The number of the alert (required for 'get' operation)"), + ), + // Parameters for list operation + mcp.WithString("state", + mcp.Description("Filter code scanning alerts by state (used for 'list' operation)"), + mcp.DefaultString("open"), + mcp.Enum("open", "closed", "dismissed", "fixed"), + ), + mcp.WithString("ref", + mcp.Description("The Git reference for the results you want to list (used for 'list' operation)"), + ), + mcp.WithString("severity", + mcp.Description("Filter code scanning alerts by severity (used for 'list' operation)"), + mcp.Enum("critical", "high", "medium", "low", "warning", "note", "error"), + ), + mcp.WithString("tool_name", + mcp.Description("The name of the tool used for code scanning (used for 'list' operation)"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + operation, err := RequiredParam[string](request, "operation") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + switch operation { + case "list": + return handleListCodeScanningAlerts(ctx, getClient, request) + case "get": + return handleGetCodeScanningAlert(ctx, getClient, request) + default: + return mcp.NewToolResultError(fmt.Sprintf("unsupported operation: %s", operation)), nil + } + } +} + +func handleGetCodeScanningAlert(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + alertNumber, err := RequiredInt(request, "alertNumber") + 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) + } + + alert, resp, err := client.CodeScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get alert", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 alert: %s", string(body))), nil + } + + r, err := json.Marshal(alert) + if err != nil { + return nil, fmt.Errorf("failed to marshal alert: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleListCodeScanningAlerts(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ref, err := OptionalParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + severity, err := OptionalParam[string](request, "severity") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + toolName, err := OptionalParam[string](request, "tool_name") + 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) + } + alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName}) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list alerts", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 alerts: %s", string(body))), nil + } + + r, err := json.Marshal(alerts) + if err != nil { + return nil, fmt.Errorf("failed to marshal alerts: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_code_scanning_alert", mcp.WithDescription(t("TOOL_GET_CODE_SCANNING_ALERT_DESCRIPTION", "Get details of a specific code scanning alert in a GitHub repository.")), diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 5d4cc732d..db8b59b8e 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -247,3 +247,70 @@ func Test_ListCodeScanningAlerts(t *testing.T) { }) } } + +func Test_ManageCodeScanningAlerts(t *testing.T) { +// Verify tool definition +mockClient := github.NewClient(nil) +tool, _ := ManageCodeScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +assert.Equal(t, "manage_code_scanning_alerts", tool.Name) +assert.NotEmpty(t, tool.Description) +assert.Contains(t, tool.InputSchema.Properties, "operation") +assert.Contains(t, tool.InputSchema.Required, "operation") + +t.Run("list operation", func(t *testing.T) { +// Setup mock alerts for success case +mockAlerts := []*github.Alert{ +{ +Number: github.Ptr(1), +Rule: &github.Rule{ +ID: github.Ptr("rule1"), +Severity: github.Ptr("error"), +Description: github.Ptr("Test rule"), +}, +State: github.Ptr("open"), +}, +} + +mockedHTTPClient := mock.NewMockedHTTPClient( +mock.WithRequestMatch(mock.GetReposCodeScanningAlertsByOwnerByRepo, mockAlerts), +) +client := github.NewClient(mockedHTTPClient) +_, handler := ManageCodeScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + +request := createMCPRequest(map[string]interface{}{ +"operation": "list", +"owner": "testowner", +"repo": "testrepo", +}) + +result, err := handler(context.Background(), request) +require.NoError(t, err) +assert.False(t, result.IsError) + +var alerts []*github.Alert +textContent := getTextResult(t, result) +err = json.Unmarshal([]byte(textContent.Text), &alerts) +require.NoError(t, err) +assert.Len(t, alerts, 1) +assert.Equal(t, 1, *alerts[0].Number) +}) + +t.Run("unsupported operation", func(t *testing.T) { +mockedHTTPClient := mock.NewMockedHTTPClient() +client := github.NewClient(mockedHTTPClient) +_, handler := ManageCodeScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + +request := createMCPRequest(map[string]interface{}{ +"operation": "delete", +"owner": "testowner", +"repo": "testrepo", +}) + +result, err := handler(context.Background(), request) +require.NoError(t, err) +assert.True(t, result.IsError) +textContent := getTextResult(t, result) +assert.Contains(t, textContent.Text, "unsupported operation: delete") +}) +} diff --git a/pkg/github/gists.go b/pkg/github/gists.go index fce34f6a8..1068ea463 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -13,6 +13,277 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// ManageGist creates a consolidated tool to perform CRUD operations on gists +func ManageGist(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("manage_gist", + mcp.WithDescription(t("TOOL_MANAGE_GIST_DESCRIPTION", "Manage gists with various operations: list, create, update, get")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MANAGE_GIST", "Manage Gist"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("operation", + mcp.Required(), + mcp.Description("Operation to perform: 'list', 'create', 'update', 'get'"), + mcp.Enum("list", "create", "update", "get"), + ), + // Parameters for list operation + mcp.WithString("username", + mcp.Description("GitHub username (omit for authenticated user's gists, used for 'list' and 'get' operations)"), + ), + mcp.WithString("since", + mcp.Description("Only gists updated after this time (ISO 8601 timestamp, used for 'list' operation)"), + ), + // Parameters for create/update operations + mcp.WithString("gist_id", + mcp.Description("ID of the gist (required for 'update' and 'get' operations)"), + ), + mcp.WithString("description", + mcp.Description("Description of the gist (used for 'create' and 'update' operations)"), + ), + mcp.WithString("filename", + mcp.Description("Filename for gist file (required for 'create' and 'update' operations)"), + ), + mcp.WithString("content", + mcp.Description("Content for gist file (required for 'create' and 'update' operations)"), + ), + mcp.WithBoolean("public", + mcp.Description("Whether the gist is public (used for 'create' operation)"), + mcp.DefaultBool(false), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + operation, err := RequiredParam[string](request, "operation") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + switch operation { + case "list": + return handleListGists(ctx, getClient, request) + case "create": + return handleCreateGist(ctx, getClient, request) + case "update": + return handleUpdateGist(ctx, getClient, request) + case "get": + return handleGetGist(ctx, getClient, request) + default: + return mcp.NewToolResultError(fmt.Sprintf("unsupported operation: %s", operation)), nil + } + } +} + +func handleListGists(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + username, err := OptionalParam[string](request, "username") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.GistListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + // Parse since timestamp if provided + if since != "" { + sinceTime, err := parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil + } + opts.Since = sinceTime + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + gists, resp, err := client.Gists.List(ctx, username, opts) + if err != nil { + return nil, fmt.Errorf("failed to list gists: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 gists: %s", string(body))), nil + } + + r, err := json.Marshal(gists) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleCreateGist(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filename, err := RequiredParam[string](request, "filename") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + content, err := RequiredParam[string](request, "content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + public, err := OptionalParam[bool](request, "public") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + files := make(map[github.GistFilename]github.GistFile) + files[github.GistFilename(filename)] = github.GistFile{ + Filename: github.Ptr(filename), + Content: github.Ptr(content), + } + + gist := &github.Gist{ + Files: files, + Public: github.Ptr(public), + Description: github.Ptr(description), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + createdGist, resp, err := client.Gists.Create(ctx, gist) + if err != nil { + return nil, fmt.Errorf("failed to create gist: %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 gist: %s", string(body))), nil + } + + r, err := json.Marshal(createdGist) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleUpdateGist(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + gistID, err := RequiredParam[string](request, "gist_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + filename, err := RequiredParam[string](request, "filename") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + content, err := RequiredParam[string](request, "content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + files := make(map[github.GistFilename]github.GistFile) + files[github.GistFilename(filename)] = github.GistFile{ + Filename: github.Ptr(filename), + Content: github.Ptr(content), + } + + gist := &github.Gist{ + Files: files, + Description: github.Ptr(description), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) + if err != nil { + return nil, fmt.Errorf("failed to update gist: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 update gist: %s", string(body))), nil + } + + r, err := json.Marshal(updatedGist) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleGetGist(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + gistID, err := RequiredParam[string](request, "gist_id") + 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) + } + + gist, resp, err := client.Gists.Get(ctx, gistID) + if err != nil { + return nil, fmt.Errorf("failed to get gist: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 gist: %s", string(body))), nil + } + + r, err := json.Marshal(gist) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + // ListGists creates a tool to list gists for a user func ListGists(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_gists", diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 49d63a252..31aa78aa3 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -505,3 +505,71 @@ func Test_UpdateGist(t *testing.T) { }) } } + +func Test_ManageGist(t *testing.T) { +// Verify tool definition +mockClient := github.NewClient(nil) +tool, _ := ManageGist(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +assert.Equal(t, "manage_gist", tool.Name) +assert.NotEmpty(t, tool.Description) +assert.Contains(t, tool.InputSchema.Properties, "operation") +assert.Contains(t, tool.InputSchema.Required, "operation") + +t.Run("list operation", func(t *testing.T) { +// Setup mock gists for success case +mockGists := []*github.Gist{ +{ +ID: github.Ptr("gist1"), +Description: github.Ptr("First Gist"), +HTMLURL: github.Ptr("https://gist.github.com/user/gist1"), +Public: github.Ptr(true), +CreatedAt: &github.Timestamp{Time: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)}, +Owner: &github.User{Login: github.Ptr("user")}, +Files: map[github.GistFilename]github.GistFile{ +"file1.txt": { +Filename: github.Ptr("file1.txt"), +Content: github.Ptr("content of file 1"), +}, +}, +}, +} + +mockedHTTPClient := mock.NewMockedHTTPClient( +mock.WithRequestMatchPages(mock.GetGists, mockGists, 1), +) +client := github.NewClient(mockedHTTPClient) +_, handler := ManageGist(stubGetClientFn(client), translations.NullTranslationHelper) + +request := createMCPRequest(map[string]interface{}{ +"operation": "list", +}) + +result, err := handler(context.Background(), request) +require.NoError(t, err) +assert.False(t, result.IsError) + +var gists []*github.Gist +textContent := getTextResult(t, result) +err = json.Unmarshal([]byte(textContent.Text), &gists) +require.NoError(t, err) +assert.Len(t, gists, 1) +assert.Equal(t, "gist1", *gists[0].ID) +}) + +t.Run("unsupported operation", func(t *testing.T) { +mockedHTTPClient := mock.NewMockedHTTPClient() +client := github.NewClient(mockedHTTPClient) +_, handler := ManageGist(stubGetClientFn(client), translations.NullTranslationHelper) + +request := createMCPRequest(map[string]interface{}{ +"operation": "delete", +}) + +result, err := handler(context.Background(), request) +require.NoError(t, err) +assert.True(t, result.IsError) +textContent := getTextResult(t, result) +assert.Contains(t, textContent.Text, "unsupported operation: delete") +}) +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 89375ae90..97343427d 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -18,6 +18,494 @@ import ( "github.com/shurcooL/githubv4" ) +// ManageIssue creates a consolidated tool to perform CRUD operations on issues +func ManageIssue(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("manage_issue", + mcp.WithDescription(t("TOOL_MANAGE_ISSUE_DESCRIPTION", "Manage issues with various operations: list, get, create, update, add_comment")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MANAGE_ISSUE", "Manage Issue"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("operation", + mcp.Required(), + mcp.Description("Operation to perform: 'list', 'get', 'create', 'update', 'add_comment'"), + mcp.Enum("list", "get", "create", "update", "add_comment"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + // Parameters for get, update, add_comment operations + mcp.WithNumber("issue_number", + mcp.Description("Issue number (required for 'get', 'update', 'add_comment' operations)"), + ), + // Parameters for create operation + mcp.WithString("title", + mcp.Description("Issue title (required for 'create' operation)"), + ), + mcp.WithString("body", + mcp.Description("Issue body content (used for 'create', 'update', 'add_comment' operations)"), + ), + mcp.WithArray("assignees", + mcp.Description("Usernames to assign to this issue (used for 'create', 'update' operations)"), + mcp.Items( + map[string]any{ + "type": "string", + }, + ), + ), + mcp.WithArray("labels", + mcp.Description("Labels to apply to this issue (used for 'create', 'update' operations)"), + mcp.Items( + map[string]any{ + "type": "string", + }, + ), + ), + mcp.WithNumber("milestone", + mcp.Description("Milestone number (used for 'create', 'update' operations)"), + ), + mcp.WithString("type", + mcp.Description("Type of this issue (used for 'create' operation)"), + ), + mcp.WithString("state", + mcp.Description("Issue state (used for 'update' operation): 'open' or 'closed'"), + mcp.Enum("open", "closed"), + ), + // Parameters for list operation + mcp.WithString("since", + mcp.Description("Filter by date (ISO 8601 timestamp, used for 'list' operation)"), + ), + mcp.WithString("list_state", + mcp.Description("Filter by state for list operation: 'OPEN', 'CLOSED'"), + mcp.Enum("OPEN", "CLOSED"), + ), + mcp.WithArray("list_labels", + mcp.Description("Filter by labels for list operation"), + mcp.Items( + map[string]any{ + "type": "string", + }, + ), + ), + mcp.WithString("direction", + mcp.Description("Order direction for list operation: 'ASC', 'DESC'"), + mcp.Enum("ASC", "DESC"), + ), + mcp.WithString("orderBy", + mcp.Description("Order issues by field for list operation: 'CREATED_AT', 'UPDATED_AT', 'COMMENTS'"), + mcp.Enum("CREATED_AT", "UPDATED_AT", "COMMENTS"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + operation, err := RequiredParam[string](request, "operation") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + switch operation { + case "list": + return handleListIssues(ctx, getGQLClient, request) + case "get": + return handleGetIssue(ctx, getClient, request) + case "create": + return handleCreateIssue(ctx, getClient, request) + case "update": + return handleUpdateIssue(ctx, getClient, request) + case "add_comment": + return handleAddIssueComment(ctx, getClient, request) + default: + return mcp.NewToolResultError(fmt.Sprintf("unsupported operation: %s", operation)), nil + } + } +} + +func handleListIssues(ctx context.Context, getGQLClient GetGQLClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + since, err := OptionalParam[string](request, "since") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + state, err := OptionalParam[string](request, "list_state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + labels, err := OptionalStringArrayParam(request, "list_labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + orderBy, err := OptionalParam[string](request, "orderBy") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getGQLClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) + } + + // Set defaults + first := 30 + if pagination.PerPage > 0 { + first = pagination.PerPage + } + + // Set up variables for GraphQL query + variables := map[string]interface{}{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "first": githubv4.Int(first), + } + + // Handle pagination cursor + if pagination.After != "" { + variables["after"] = githubv4.String(pagination.After) + } + + // Handle state filtering + if state != "" { + variables["states"] = []githubv4.IssueState{githubv4.IssueState(state)} + } + + // Handle ordering + if direction != "" && orderBy != "" { + variables["direction"] = githubv4.OrderDirection(direction) + variables["orderBy"] = githubv4.IssueOrderField(orderBy) + } + + // Handle since filtering + var sinceTime *githubv4.DateTime + if since != "" { + parsedTime, err := parseISOTimestamp(since) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("invalid since timestamp: %v", err)), nil + } + dt := githubv4.DateTime{Time: parsedTime} + sinceTime = &dt + variables["since"] = dt + } + + // Choose the right query based on whether we have labels and/or since filtering + hasLabels := len(labels) > 0 + hasSince := sinceTime != nil + + if hasLabels { + variables["labels"] = labels + } + + // Execute the appropriate query + var result IssueQueryResult + switch { + case hasLabels && hasSince: + result = &ListIssuesQueryTypeWithLabelsWithSince{} + case hasLabels && !hasSince: + result = &ListIssuesQueryTypeWithLabels{} + case !hasLabels && hasSince: + result = &ListIssuesQueryWithSince{} + default: + result = &ListIssuesQuery{} + } + + err = client.Query(ctx, result, variables) + if err != nil { + return nil, fmt.Errorf("failed to query issues: %w", err) + } + + fragment := result.GetIssueFragment() + + // Convert GraphQL fragments to GitHub issue structs + issues := make([]*github.Issue, len(fragment.Nodes)) + for i, node := range fragment.Nodes { + issues[i] = fragmentToIssue(node) + } + + // Prepare response with pagination info + response := map[string]interface{}{ + "issues": issues, + "pageInfo": map[string]interface{}{ + "hasNextPage": bool(fragment.PageInfo.HasNextPage), + "hasPreviousPage": bool(fragment.PageInfo.HasPreviousPage), + "startCursor": string(fragment.PageInfo.StartCursor), + "endCursor": string(fragment.PageInfo.EndCursor), + }, + "totalCount": fragment.TotalCount, + } + + 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 handleGetIssue(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + 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) + } + + issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) + if err != nil { + return nil, fmt.Errorf("failed to get issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(issue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleCreateIssue(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + title, err := RequiredParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Optional parameters + body, err := OptionalParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + assignees, err := OptionalStringArrayParam(request, "assignees") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + labels, err := OptionalStringArrayParam(request, "labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + milestone, err := OptionalIntParam(request, "milestone") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var milestoneNum *int + if milestone != 0 { + milestoneNum = &milestone + } + + issueType, err := OptionalParam[string](request, "type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Create the issue request + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + Milestone: milestoneNum, + } + + // Add custom type field if specified + if issueType != "" { + if issueRequest.Body == nil { + issueRequest.Body = github.Ptr("") + } + *issueRequest.Body = fmt.Sprintf("\n%s", issueType, *issueRequest.Body) + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) + if err != nil { + return nil, fmt.Errorf("failed to create issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(issue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleUpdateIssue(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Optional parameters + title, err := OptionalParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + body, err := OptionalParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + assignees, err := OptionalStringArrayParam(request, "assignees") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + labels, err := OptionalStringArrayParam(request, "labels") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + milestone, err := OptionalIntParam(request, "milestone") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Create the issue request + issueRequest := &github.IssueRequest{} + + if title != "" { + issueRequest.Title = github.Ptr(title) + } + if body != "" { + issueRequest.Body = github.Ptr(body) + } + if state != "" { + issueRequest.State = github.Ptr(state) + } + if len(assignees) > 0 { + issueRequest.Assignees = &assignees + } + if len(labels) > 0 { + issueRequest.Labels = &labels + } + if milestone != 0 { + issueRequest.Milestone = &milestone + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + issue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) + if err != nil { + return nil, fmt.Errorf("failed to update issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(issue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleAddIssueComment(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + body, err := RequiredParam[string](request, "body") + 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) + } + + comment := &github.IssueComment{ + Body: github.Ptr(body), + } + + createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment) + if err != nil { + return nil, fmt.Errorf("failed to add comment: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(createdComment) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + // IssueFragment represents a fragment of an issue node in the GraphQL API. type IssueFragment struct { Number githubv4.Int diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 63c5594d3..348579d17 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -17,6 +17,437 @@ import ( "github.com/github/github-mcp-server/pkg/translations" ) +// ManagePullRequest creates a consolidated tool to perform CRUD operations on pull requests +func ManagePullRequest(getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("manage_pull_request", + mcp.WithDescription(t("TOOL_MANAGE_PULL_REQUEST_DESCRIPTION", "Manage pull requests with various operations: list, get, create, update, merge")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MANAGE_PULL_REQUEST", "Manage Pull Request"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("operation", + mcp.Required(), + mcp.Description("Operation to perform: 'list', 'get', 'create', 'update', 'merge'"), + mcp.Enum("list", "get", "create", "update", "merge"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + // Parameters for get, update, merge operations + mcp.WithNumber("pullNumber", + mcp.Description("Pull request number (required for 'get', 'update', 'merge' operations)"), + ), + // Parameters for create operation + mcp.WithString("title", + mcp.Description("Pull request title (required for 'create' operation)"), + ), + mcp.WithString("body", + mcp.Description("Pull request body content (used for 'create', 'update' operations)"), + ), + mcp.WithString("head", + mcp.Description("The name of the branch where your changes are implemented (required for 'create' operation)"), + ), + mcp.WithString("base", + mcp.Description("The name of the branch you want the changes pulled into (required for 'create' operation)"), + ), + mcp.WithBoolean("draft", + mcp.Description("Whether to create as draft (used for 'create' operation)"), + mcp.DefaultBool(false), + ), + mcp.WithBoolean("maintainer_can_modify", + mcp.Description("Whether maintainers can modify the pull request (used for 'create' operation)"), + mcp.DefaultBool(true), + ), + // Parameters for update operation + mcp.WithString("state", + mcp.Description("State of the pull request (used for 'update' operation): 'open' or 'closed'"), + mcp.Enum("open", "closed"), + ), + // Parameters for merge operation + mcp.WithString("commit_title", + mcp.Description("Title for the merge commit (used for 'merge' operation)"), + ), + mcp.WithString("commit_message", + mcp.Description("Extra detail to append to merge commit message (used for 'merge' operation)"), + ), + mcp.WithString("merge_method", + mcp.Description("Merge method to use (used for 'merge' operation): 'merge', 'squash', 'rebase'"), + mcp.Enum("merge", "squash", "rebase"), + mcp.DefaultString("merge"), + ), + // Parameters for list operation + mcp.WithString("list_state", + mcp.Description("Filter by state for list operation: 'open', 'closed', 'all'"), + mcp.Enum("open", "closed", "all"), + mcp.DefaultString("open"), + ), + mcp.WithString("list_head", + mcp.Description("Filter by head user/org and branch for list operation"), + ), + mcp.WithString("list_base", + mcp.Description("Filter by base branch for list operation"), + ), + mcp.WithString("sort", + mcp.Description("Sort by for list operation: 'created', 'updated', 'popularity', 'long-running'"), + mcp.Enum("created", "updated", "popularity", "long-running"), + mcp.DefaultString("created"), + ), + mcp.WithString("direction", + mcp.Description("Sort direction for list operation: 'asc', 'desc'"), + mcp.Enum("asc", "desc"), + mcp.DefaultString("desc"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + operation, err := RequiredParam[string](request, "operation") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + switch operation { + case "list": + return handleListPullRequests(ctx, getClient, request) + case "get": + return handleGetPullRequest(ctx, getClient, request) + case "create": + return handleCreatePullRequest(ctx, getClient, request) + case "update": + return handleUpdatePullRequest(ctx, getClient, getGQLClient, request) + case "merge": + return handleMergePullRequest(ctx, getClient, request) + default: + return mcp.NewToolResultError(fmt.Sprintf("unsupported operation: %s", operation)), nil + } + } +} + +func handleListPullRequests(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + state, err := OptionalParam[string](request, "list_state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if state == "" { + state = "open" + } + + head, err := OptionalParam[string](request, "list_head") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + base, err := OptionalParam[string](request, "list_base") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + sort, err := OptionalParam[string](request, "sort") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if sort == "" { + sort = "created" + } + + direction, err := OptionalParam[string](request, "direction") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if direction == "" { + direction = "desc" + } + + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.PullRequestListOptions{ + State: state, + Head: head, + Base: base, + Sort: sort, + Direction: direction, + ListOptions: 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) + } + + prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list pull requests", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(prs) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleGetPullRequest(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pullNumber, err := RequiredInt(request, "pullNumber") + 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) + } + pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 pull request: %s", string(body))), nil + } + + r, err := json.Marshal(pr) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleCreatePullRequest(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + title, err := RequiredParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + head, err := RequiredParam[string](request, "head") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + base, err := RequiredParam[string](request, "base") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + body, err := OptionalParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + draft, err := OptionalParam[bool](request, "draft") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + maintainerCanModify, err := OptionalParam[bool](request, "maintainer_can_modify") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Create the pull request + newPR := &github.NewPullRequest{ + Title: github.Ptr(title), + Head: github.Ptr(head), + Base: github.Ptr(base), + Body: github.Ptr(body), + Draft: github.Ptr(draft), + MaintainerCanModify: github.Ptr(maintainerCanModify), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create pull request", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(pr) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleUpdatePullRequest(ctx context.Context, getClient GetClientFn, getGQLClient GetGQLClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + title, err := OptionalParam[string](request, "title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + body, err := OptionalParam[string](request, "body") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + pull := &github.PullRequest{} + if title != "" { + pull.Title = github.Ptr(title) + } + if body != "" { + pull.Body = github.Ptr(body) + } + if state != "" { + pull.State = github.Ptr(state) + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + updatedPR, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, pull) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to update pull request", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(updatedPR) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleMergePullRequest(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pullNumber, err := RequiredInt(request, "pullNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + commitTitle, err := OptionalParam[string](request, "commit_title") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + commitMessage, err := OptionalParam[string](request, "commit_message") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + mergeMethod, err := OptionalParam[string](request, "merge_method") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if mergeMethod == "" { + mergeMethod = "merge" + } + + options := &github.PullRequestOptions{ + CommitTitle: commitTitle, + MergeMethod: mergeMethod, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + result, resp, err := client.PullRequests.Merge(ctx, owner, repo, pullNumber, commitMessage, options) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to merge pull request", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + // GetPullRequest creates a tool to get details of a specific pull request. func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) { return mcp.NewTool("get_pull_request", diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index de2c6d01f..9ddc818db 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -18,6 +18,299 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// ManageRepository creates a consolidated tool to perform operations on repositories +func ManageRepository(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("manage_repository", + mcp.WithDescription(t("TOOL_MANAGE_REPOSITORY_DESCRIPTION", "Manage repositories with various operations: create, fork, get_file_contents")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MANAGE_REPOSITORY", "Manage Repository"), + ReadOnlyHint: ToBoolPtr(false), + }), + mcp.WithString("operation", + mcp.Required(), + mcp.Description("Operation to perform: 'create', 'fork', 'get_file_contents'"), + mcp.Enum("create", "fork", "get_file_contents"), + ), + // Parameters for create operation + mcp.WithString("name", + mcp.Description("Repository name (required for 'create' operation)"), + ), + mcp.WithString("description", + mcp.Description("Repository description (used for 'create' operation)"), + ), + mcp.WithBoolean("private", + mcp.Description("Whether repo should be private (used for 'create' operation)"), + ), + mcp.WithBoolean("autoInit", + mcp.Description("Initialize with README (used for 'create' operation)"), + ), + // Parameters for fork operation + mcp.WithString("owner", + mcp.Description("Repository owner (required for 'fork', 'get_file_contents' operations)"), + ), + mcp.WithString("repo", + mcp.Description("Repository name (required for 'fork', 'get_file_contents' operations)"), + ), + mcp.WithString("organization", + mcp.Description("Organization to fork to (used for 'fork' operation)"), + ), + mcp.WithString("default_branch_only", + mcp.Description("Fork only default branch (used for 'fork' operation)"), + ), + // Parameters for get_file_contents operation + mcp.WithString("path", + mcp.Description("Path to file/directory (required for 'get_file_contents' operation)"), + mcp.DefaultString("/"), + ), + mcp.WithString("ref", + mcp.Description("Git reference such as branch, tag, or commit SHA (used for 'get_file_contents' operation)"), + ), + mcp.WithString("sha", + mcp.Description("Commit SHA (used for 'get_file_contents' operation)"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + operation, err := RequiredParam[string](request, "operation") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + switch operation { + case "create": + return handleCreateRepository(ctx, getClient, request) + case "fork": + return handleForkRepository(ctx, getClient, request) + case "get_file_contents": + return handleGetFileContents(ctx, getClient, getRawClient, request) + default: + return mcp.NewToolResultError(fmt.Sprintf("unsupported operation: %s", operation)), nil + } + } +} + +func handleCreateRepository(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, err := RequiredParam[string](request, "name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + private, err := OptionalParam[bool](request, "private") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + autoInit, err := OptionalParam[bool](request, "autoInit") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo := &github.Repository{ + Name: github.Ptr(name), + Description: github.Ptr(description), + Private: github.Ptr(private), + AutoInit: github.Ptr(autoInit), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + createdRepo, resp, err := client.Repositories.Create(ctx, "", repo) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create repository", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(createdRepo) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleForkRepository(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + organization, err := OptionalParam[string](request, "organization") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + defaultBranchOnly, err := OptionalParam[bool](request, "default_branch_only") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.RepositoryCreateForkOptions{ + Organization: organization, + DefaultBranchOnly: defaultBranchOnly, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + fork, resp, err := client.Repositories.CreateFork(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to fork repository", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(fork) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleGetFileContents(ctx context.Context, getClient GetClientFn, getRawClient raw.GetRawClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + path, err := OptionalParam[string](request, "path") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if path == "" { + path = "/" + } + ref, err := OptionalParam[string](request, "ref") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + sha, err := OptionalParam[string](request, "sha") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Use the existing GetFileContents logic + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + opts := &github.RepositoryContentGetOptions{} + if ref != "" { + opts.Ref = ref + } + if sha != "" { + opts.Ref = sha + } + + // Handle directory listing + if strings.HasSuffix(path, "/") { + _, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, strings.TrimSuffix(path, "/"), opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get directory contents", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(dirContent) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } + + // Handle file content + fileContent, _, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get file contents", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if fileContent == nil { + return mcp.NewToolResultError("file not found"), nil + } + + // For non-text files, return metadata only + if fileContent.Encoding != nil && *fileContent.Encoding == "base64" { + // Try to decode and check if it's text + if fileContent.Content != nil { + decoded, err := base64.StdEncoding.DecodeString(*fileContent.Content) + if err == nil && isTextFile(decoded) { + response := map[string]interface{}{ + "name": fileContent.GetName(), + "path": fileContent.GetPath(), + "sha": fileContent.GetSHA(), + "size": fileContent.GetSize(), + "url": fileContent.GetURL(), + "html_url": fileContent.GetHTMLURL(), + "type": fileContent.GetType(), + "content": string(decoded), + "encoding": "text", + } + + r, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + return mcp.NewToolResultText(string(r)), nil + } + } + } + + // Return metadata for binary files or when content can't be decoded + r, err := json.Marshal(fileContent) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +// isTextFile checks if the content is likely to be text +func isTextFile(content []byte) bool { + // Check for null bytes which indicate binary content + for _, b := range content[:min(len(content), 1024)] { + if b == 0 { + return false + } + } + return true +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_commit", mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")), diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index c140c34ad..9e0fe982b 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -14,6 +14,158 @@ import ( "github.com/mark3labs/mcp-go/server" ) +// ManageSecretScanningAlerts creates a consolidated tool to perform operations on secret scanning alerts +func ManageSecretScanningAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("manage_secret_scanning_alerts", + mcp.WithDescription(t("TOOL_MANAGE_SECRET_SCANNING_ALERTS_DESCRIPTION", "Manage secret scanning alerts with various operations: list, get")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_MANAGE_SECRET_SCANNING_ALERTS", "Manage Secret Scanning Alerts"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("operation", + mcp.Required(), + mcp.Description("Operation to perform: 'list', 'get'"), + mcp.Enum("list", "get"), + ), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The owner of the repository"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("The name of the repository"), + ), + // Parameters for get operation + mcp.WithNumber("alertNumber", + mcp.Description("The number of the alert (required for 'get' operation)"), + ), + // Parameters for list operation + mcp.WithString("state", + mcp.Description("Filter by state (used for 'list' operation)"), + mcp.Enum("open", "resolved"), + ), + mcp.WithString("secret_type", + mcp.Description("A comma-separated list of secret types to return (used for 'list' operation)"), + ), + mcp.WithString("resolution", + mcp.Description("Filter by resolution (used for 'list' operation)"), + mcp.Enum("false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + operation, err := RequiredParam[string](request, "operation") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + switch operation { + case "list": + return handleListSecretScanningAlerts(ctx, getClient, request) + case "get": + return handleGetSecretScanningAlert(ctx, getClient, request) + default: + return mcp.NewToolResultError(fmt.Sprintf("unsupported operation: %s", operation)), nil + } + } +} + +func handleGetSecretScanningAlert(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + alertNumber, err := RequiredInt(request, "alertNumber") + 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) + } + + alert, resp, err := client.SecretScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 alert: %s", string(body))), nil + } + + r, err := json.Marshal(alert) + if err != nil { + return nil, fmt.Errorf("failed to marshal alert: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + +func handleListSecretScanningAlerts(ctx context.Context, getClient GetClientFn, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := RequiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + state, err := OptionalParam[string](request, "state") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + secretType, err := OptionalParam[string](request, "secret_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + resolution, err := OptionalParam[string](request, "resolution") + 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) + } + alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution}) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + 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 alerts: %s", string(body))), nil + } + + r, err := json.Marshal(alerts) + if err != nil { + return nil, fmt.Errorf("failed to marshal alerts: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil +} + func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool( "get_secret_scanning_alert", diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index ce33fe318..37c36d951 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -247,3 +247,67 @@ func Test_ListSecretScanningAlerts(t *testing.T) { }) } } + + +func Test_ManageSecretScanningAlerts(t *testing.T) { +// Verify tool definition +mockClient := github.NewClient(nil) +tool, _ := ManageSecretScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper) + +assert.Equal(t, "manage_secret_scanning_alerts", tool.Name) +assert.NotEmpty(t, tool.Description) +assert.Contains(t, tool.InputSchema.Properties, "operation") +assert.Contains(t, tool.InputSchema.Required, "operation") + +t.Run("list operation", func(t *testing.T) { +// Setup mock alerts for success case +mockAlerts := []*github.SecretScanningAlert{ +{ +Number: github.Ptr(1), +State: github.Ptr("open"), +HTMLURL: github.Ptr("https://github.com/owner/repo/security/secret-scanning/1"), +}, +} + +mockedHTTPClient := mock.NewMockedHTTPClient( +mock.WithRequestMatch(mock.GetReposSecretScanningAlertsByOwnerByRepo, mockAlerts), +) +client := github.NewClient(mockedHTTPClient) +_, handler := ManageSecretScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + +request := createMCPRequest(map[string]interface{}{ +"operation": "list", +"owner": "testowner", +"repo": "testrepo", +}) + +result, err := handler(context.Background(), request) +require.NoError(t, err) +assert.False(t, result.IsError) + +var alerts []*github.SecretScanningAlert +textContent := getTextResult(t, result) +err = json.Unmarshal([]byte(textContent.Text), &alerts) +require.NoError(t, err) +assert.Len(t, alerts, 1) +assert.Equal(t, 1, *alerts[0].Number) +}) + +t.Run("unsupported operation", func(t *testing.T) { +mockedHTTPClient := mock.NewMockedHTTPClient() +client := github.NewClient(mockedHTTPClient) +_, handler := ManageSecretScanningAlerts(stubGetClientFn(client), translations.NullTranslationHelper) + +request := createMCPRequest(map[string]interface{}{ +"operation": "delete", +"owner": "testowner", +"repo": "testrepo", +}) + +result, err := handler(context.Background(), request) +require.NoError(t, err) +assert.True(t, result.IsError) +textContent := getTextResult(t, result) +assert.Contains(t, textContent.Text, "unsupported operation: delete") +}) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 728d78097..78ce41fbd 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -24,7 +24,6 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG repos := toolsets.NewToolset("repos", "GitHub Repository related tools"). AddReadTools( toolsets.NewServerTool(SearchRepositories(getClient, t)), - toolsets.NewServerTool(GetFileContents(getClient, getRawClient, t)), toolsets.NewServerTool(ListCommits(getClient, t)), toolsets.NewServerTool(SearchCode(getClient, t)), toolsets.NewServerTool(GetCommit(getClient, t)), @@ -36,9 +35,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetReleaseByTag(getClient, t)), ). AddWriteTools( + toolsets.NewServerTool(ManageRepository(getClient, getRawClient, t)), toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), - toolsets.NewServerTool(CreateRepository(getClient, t)), - toolsets.NewServerTool(ForkRepository(getClient, t)), toolsets.NewServerTool(CreateBranch(getClient, t)), toolsets.NewServerTool(PushFiles(getClient, t)), toolsets.NewServerTool(DeleteFile(getClient, t)), @@ -52,17 +50,13 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ) issues := toolsets.NewToolset("issues", "GitHub Issues related tools"). AddReadTools( - toolsets.NewServerTool(GetIssue(getClient, t)), toolsets.NewServerTool(SearchIssues(getClient, t)), - toolsets.NewServerTool(ListIssues(getGQLClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), toolsets.NewServerTool(ListIssueTypes(getClient, t)), toolsets.NewServerTool(ListSubIssues(getClient, t)), ). AddWriteTools( - toolsets.NewServerTool(CreateIssue(getClient, t)), - toolsets.NewServerTool(AddIssueComment(getClient, t)), - toolsets.NewServerTool(UpdateIssue(getClient, t)), + toolsets.NewServerTool(ManageIssue(getClient, getGQLClient, t)), toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), toolsets.NewServerTool(AddSubIssue(getClient, t)), toolsets.NewServerTool(RemoveSubIssue(getClient, t)), @@ -81,8 +75,6 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ) pullRequests := toolsets.NewToolset("pull_requests", "GitHub Pull Request related tools"). AddReadTools( - toolsets.NewServerTool(GetPullRequest(getClient, t)), - toolsets.NewServerTool(ListPullRequests(getClient, t)), toolsets.NewServerTool(GetPullRequestFiles(getClient, t)), toolsets.NewServerTool(SearchPullRequests(getClient, t)), toolsets.NewServerTool(GetPullRequestStatus(getClient, t)), @@ -91,10 +83,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetPullRequestDiff(getClient, t)), ). AddWriteTools( - toolsets.NewServerTool(MergePullRequest(getClient, t)), + toolsets.NewServerTool(ManagePullRequest(getClient, getGQLClient, t)), toolsets.NewServerTool(UpdatePullRequestBranch(getClient, t)), - toolsets.NewServerTool(CreatePullRequest(getClient, t)), - toolsets.NewServerTool(UpdatePullRequest(getClient, getGQLClient, t)), toolsets.NewServerTool(RequestCopilotReview(getClient, t)), // Reviews @@ -106,13 +96,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ) codeSecurity := toolsets.NewToolset("code_security", "Code security related tools, such as GitHub Code Scanning"). AddReadTools( - toolsets.NewServerTool(GetCodeScanningAlert(getClient, t)), - toolsets.NewServerTool(ListCodeScanningAlerts(getClient, t)), + toolsets.NewServerTool(ManageCodeScanningAlerts(getClient, t)), ) secretProtection := toolsets.NewToolset("secret_protection", "Secret protection related tools, such as GitHub Secret Scanning"). AddReadTools( - toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)), - toolsets.NewServerTool(ListSecretScanningAlerts(getClient, t)), + toolsets.NewServerTool(ManageSecretScanningAlerts(getClient, t)), ) dependabot := toolsets.NewToolset("dependabot", "Dependabot tools"). AddReadTools( @@ -179,12 +167,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG ) gists := toolsets.NewToolset("gists", "GitHub Gist related tools"). - AddReadTools( - toolsets.NewServerTool(ListGists(getClient, t)), - ). AddWriteTools( - toolsets.NewServerTool(CreateGist(getClient, t)), - toolsets.NewServerTool(UpdateGist(getClient, t)), + toolsets.NewServerTool(ManageGist(getClient, t)), ) // Add toolsets to the group