From 32caa71f687c13a64a09bda13b2cf9dc0824cb50 Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Wed, 3 Jun 2026 12:05:13 +0200 Subject: [PATCH] Add tool for getting code quality findings --- README.md | 13 ++ docs/feature-flags.md | 2 +- docs/remote-server.md | 1 + docs/toolsets-and-icons.md | 1 + .../get_code_quality_finding.snap | 30 ++++ pkg/github/code_quality.go | 99 +++++++++++ pkg/github/code_quality_test.go | 155 ++++++++++++++++++ pkg/github/helper_test.go | 3 + pkg/github/tools.go | 13 +- pkg/octicons/icons/code-square-dark.png | Bin 0 -> 381 bytes pkg/octicons/icons/code-square-light.png | Bin 0 -> 511 bytes pkg/octicons/required_icons.txt | 1 + 12 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 pkg/github/__toolsnaps__/get_code_quality_finding.snap create mode 100644 pkg/github/code_quality.go create mode 100644 pkg/github/code_quality_test.go create mode 100644 pkg/octicons/icons/code-square-dark.png create mode 100644 pkg/octicons/icons/code-square-light.png diff --git a/README.md b/README.md index dff62321b8..aae1cd39e3 100644 --- a/README.md +++ b/README.md @@ -555,6 +555,7 @@ The following sets of tools are available: | --- | ----------------------- | ------------------------------------------------------------- | | person | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in | | workflow | `actions` | GitHub Actions workflows and CI/CD operations | +| code-square | `code_quality` | GitHub Code Quality related tools | | codescan | `code_security` | Code security related tools, such as GitHub Code Scanning | | copilot | `copilot` | Copilot related tools | | dependabot | `dependabot` | Dependabot tools | @@ -640,6 +641,18 @@ The following sets of tools are available:
+code-square Code Quality + +- **get_code_quality_finding** - Get code quality finding + - **Required OAuth Scopes**: `repo` + - `findingNumber`: The number of the finding. (number, required) + - `owner`: The owner of the repository. (string, required) + - `repo`: The name of the repository. (string, required) + +
+ +
+ codescan Code Security - **get_code_scanning_alert** - Get code scanning alert diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 0b75a61bac..4c8654ce0f 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -240,7 +240,7 @@ runtime behavior (such as output formatting) won't appear here. - `owner`: Repository owner (username or organization) (string, required) - `pullNumber`: The pull request number (number, required) - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames to request reviews from (string[], required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], required) - **resolve_review_thread** - Resolve Review Thread - **Required OAuth Scopes**: `repo` diff --git a/docs/remote-server.md b/docs/remote-server.md index aa083d2f29..4665ba8044 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -22,6 +22,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | apps
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) | | workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| code-square
`code_quality` | GitHub Code Quality related tools | https://api.githubcopilot.com/mcp/x/code_quality | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_quality/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%2Freadonly%22%7D) | | codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | | dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | diff --git a/docs/toolsets-and-icons.md b/docs/toolsets-and-icons.md index 9228248ecb..0e54b1f16a 100644 --- a/docs/toolsets-and-icons.md +++ b/docs/toolsets-and-icons.md @@ -151,6 +151,7 @@ icons := octicons.Icons("repo") | Users | `people` | | Organizations | `organization` | | Actions | `workflow` | +| Code Quality | `code-square` | | Code Security | `codescan` | | Secret Protection | `shield-lock` | | Dependabot | `dependabot` | diff --git a/pkg/github/__toolsnaps__/get_code_quality_finding.snap b/pkg/github/__toolsnaps__/get_code_quality_finding.snap new file mode 100644 index 0000000000..378efe835d --- /dev/null +++ b/pkg/github/__toolsnaps__/get_code_quality_finding.snap @@ -0,0 +1,30 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get code quality finding" + }, + "description": "Get details of a specific code quality finding in a GitHub repository.", + "inputSchema": { + "properties": { + "findingNumber": { + "description": "The number of the finding.", + "type": "number" + }, + "owner": { + "description": "The owner of the repository.", + "type": "string" + }, + "repo": { + "description": "The name of the repository.", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "findingNumber" + ], + "type": "object" + }, + "name": "get_code_quality_finding" +} \ No newline at end of file diff --git a/pkg/github/code_quality.go b/pkg/github/code_quality.go new file mode 100644 index 0000000000..41c791182b --- /dev/null +++ b/pkg/github/code_quality.go @@ -0,0 +1,99 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" +) + +func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataCodeQuality, + mcp.Tool{ + Name: "get_code_quality_finding", + Description: t("TOOL_GET_CODE_QUALITY_FINDING_DESCRIPTION", "Get details of a specific code quality finding in a GitHub repository."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_CODE_QUALITY_FINDING_USER_TITLE", "Get code quality finding"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "findingNumber": { + Type: "number", + Description: "The number of the finding.", + }, + }, + Required: []string{"owner", "repo", "findingNumber"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + findingNumber, err := RequiredInt(args, "findingNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", owner, repo, findingNumber) + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + finding := make(map[string]any) + + resp, err := client.Do(req, &finding) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get finding", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get finding", resp, body), nil, nil + } + + r, err := json.Marshal(finding) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal finding", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} diff --git a/pkg/github/code_quality_test.go b/pkg/github/code_quality_test.go new file mode 100644 index 0000000000..3971e5a0d6 --- /dev/null +++ b/pkg/github/code_quality_test.go @@ -0,0 +1,155 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" +) + +func Test_GetCodeQualityFinding(t *testing.T) { + // Verify tool definition once + toolDef := GetCodeQualityFinding(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "get_code_quality_finding", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + + // InputSchema is of type any, need to cast to *jsonschema.Schema + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "findingNumber") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "findingNumber"}) + + type codeQualityRule struct { + ID *string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Help *string `json:"help,omitempty"` + Severity *string `json:"severity,omitempty"` + Category *string `json:"category,omitempty"` + } + + type codeQualityLocation struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndLine *int `json:"end_line,omitempty"` + EndColumn *int `json:"end_column,omitempty"` + } + + type codeQualityMessage struct { + Text string `json:"text"` + Markdown string `json:"markdown"` + } + + type codeQualityFinding struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + URL *string `json:"url,omitempty"` + Rule *codeQualityRule `json:"rule,omitempty"` + Location *codeQualityLocation `json:"location,omitempty"` + Message *codeQualityMessage `json:"message,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + } + + // Setup mock finding for success case + mockFinding := &codeQualityFinding{ + Number: github.Ptr(42), + State: github.Ptr("open"), + Rule: &codeQualityRule{ + ID: github.Ptr("test-rule"), + Description: github.Ptr("Test Rule Description"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedFinding *codeQualityFinding + expectedErrMsg string + }{ + { + name: "successful finding fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: mockResponse(t, http.StatusOK, mockFinding), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "findingNumber": float64(42), + }, + expectError: false, + expectedFinding: mockFinding, + }, + { + name: "finding fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "findingNumber": float64(9999), + }, + expectError: true, + expectedErrMsg: "failed to get finding", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler with new signature + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedFinding codeQualityFinding + err = json.Unmarshal([]byte(textContent.Text), &returnedFinding) + assert.NoError(t, err) + assert.Equal(t, *tc.expectedFinding.Number, *returnedFinding.Number) + assert.Equal(t, *tc.expectedFinding.State, *returnedFinding.State) + assert.Equal(t, *tc.expectedFinding.Rule.ID, *returnedFinding.Rule.ID) + + }) + } +} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index fdac78ce3f..69921c6746 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -100,6 +100,9 @@ const ( GetReposReleasesLatestByOwnerByRepo = "GET /repos/{owner}/{repo}/releases/latest" GetReposReleasesTagsByOwnerByRepoByTag = "GET /repos/{owner}/{repo}/releases/tags/{tag}" + // Code quality endpoints + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}" + // Code scanning endpoints GetReposCodeScanningAlertsByOwnerByRepo = "GET /repos/{owner}/{repo}/code-scanning/alerts" GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber = "GET /repos/{owner}/{repo}/code-scanning/alerts/{alert_number}" diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d1d585b3fa..7f383714c7 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -5,10 +5,11 @@ import ( "slices" "strings" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" ) type GetClientFn func(context.Context) (*github.Client, error) @@ -76,6 +77,11 @@ var ( Description: "GitHub Actions workflows and CI/CD operations", Icon: "workflow", } + ToolsetMetadataCodeQuality = inventory.ToolsetMetadata{ + ID: "code_quality", + Description: "GitHub Code Quality related tools", + Icon: "code-square", + } ToolsetMetadataCodeSecurity = inventory.ToolsetMetadata{ ID: "code_security", Description: "Code security related tools, such as GitHub Code Scanning", @@ -234,6 +240,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { AssignCopilotToIssue(t), RequestCopilotReview(t), + // Code quality tools + GetCodeQualityFinding(t), + // Code security tools GetCodeScanningAlert(t), ListCodeScanningAlerts(t), diff --git a/pkg/octicons/icons/code-square-dark.png b/pkg/octicons/icons/code-square-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..8e2d8d0c98dfe41344826f44c9ff3156c943c305 GIT binary patch literal 381 zcmV-@0fPRCP)=4E!0X$7;v?>ZnJ(+{gerz%9_o zdDarJ8WFSo8>m)uhND(hd(TsO0|)6J0MvqdukL5OgX9%*0n|;P3CsZ3nbLLgnyF`W zfVu;m003ZV zw15h*0$i!f{XOae&;r(gy)-i%AVJQ7U0@DqsEe8KCEx;>2g(sqA8CUOiJRAzZDbYhVHA%dNrUnXci{nCQm#d`wiZJvihDgvkY{;xayJG9>XuQtsdq) b!v)hZ(m}N(HcU{+4N&hoDPl6d&I-jpJj` zCDSku?VP{$M*w@h^jJjSAbiG*cKZF+MUp!^YoEu4^+@59h`zP+#of68S+7`;Sn9w) zE!_+|o#E}8^V5%)+|ddUh#If6Ua>OQ1b<7v1PprZh8nsHWMwQLoI=6AU9-CW;67ia z0pwKTr-NR*F*5*Qc-nfWDo;@E5}60CTgoK4wYB1^5s%*s+*6h6u-mTBHNg!eXdXpz zv<+NGrm>O4W6Rh8uA?~G9tO>$X&%#DLocQ-vG-Ok(D2Ia0CDV002ovPDHLkV1h#$ B=w1K- literal 0 HcmV?d00001 diff --git a/pkg/octicons/required_icons.txt b/pkg/octicons/required_icons.txt index 7911b46eb8..15dc444956 100644 --- a/pkg/octicons/required_icons.txt +++ b/pkg/octicons/required_icons.txt @@ -19,6 +19,7 @@ bell book check-circle codescan +code-square comment-discussion copilot dependabot