diff --git a/README.md b/README.md index 7398778..26c04b4 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,15 @@ When using custom certificates, you can modify your MCP configuration to mount t - `codeSnippet` - Code snippet or full file content - _Required String_ - `language` - Optional language of the code snippet - _String_ +### Dependency Risks + +**Note: Dependency risks are only available when connecting to SonarQube Server 2025.4 Enterprise or higher with SonarQube Advanced Security enabled.** + +- **search_dependency_risks** - Search for software composition analysis issues (dependency risks) of a project, paired with releases that appear in the analyzed project, application, or portfolio. + - `projectKey` - Project key - _String_ + - `branchKey` - Optional branch key - _String_ + - `pullRequestKey` - Optional pull request key - _String_ + ### Languages - **list_languages** - List all programming languages supported in this instance diff --git a/src/main/java/org/sonarsource/sonarqube/mcp/SonarQubeMcpServer.java b/src/main/java/org/sonarsource/sonarqube/mcp/SonarQubeMcpServer.java index 0ca0ca5..17f8543 100644 --- a/src/main/java/org/sonarsource/sonarqube/mcp/SonarQubeMcpServer.java +++ b/src/main/java/org/sonarsource/sonarqube/mcp/SonarQubeMcpServer.java @@ -45,6 +45,7 @@ import org.sonarsource.sonarqube.mcp.tools.qualitygates.ProjectStatusTool; import org.sonarsource.sonarqube.mcp.tools.rules.ListRuleRepositoriesTool; import org.sonarsource.sonarqube.mcp.tools.rules.ShowRuleTool; +import org.sonarsource.sonarqube.mcp.tools.dependencyrisks.SearchDependencyRisksTool; import org.sonarsource.sonarqube.mcp.tools.sources.GetRawSourceTool; import org.sonarsource.sonarqube.mcp.tools.sources.GetScmInfoTool; import org.sonarsource.sonarqube.mcp.tools.system.SystemHealthTool; @@ -91,6 +92,16 @@ public SonarQubeMcpServer(StdioServerTransportProvider transportProvider, Map issuesReleases, List branches, Integer countWithoutFilters, Page page) { + + public record IssueRelease(String key, String severity, String originalSeverity, String manualSeverity, Boolean showIncreasedSeverityWarning, + Release release, String type, String quality, String status, String createdAt, Assignee assignee, + Integer commentCount, String vulnerabilityId, List cweIds, String cvssScore, Boolean withdrawn, + String spdxLicenseId, List transitions, List actions) { + } + + public record Release(String key, String branchUuid, String packageUrl, String packageManager, String packageName, String version, + String licenseExpression, Boolean known, Boolean knownPackage, Boolean newlyIntroduced, Boolean directSummary, + String scopeSummary, Boolean productionScopeSummary, List dependencyFilePaths) { + } + + public record Assignee(String login, String name, String avatar, Boolean active) { + } + + public record Branch(String uuid, String key, Boolean isPullRequest, String projectKey, String projectName) { + } + + public record Page(Integer pageIndex, Integer pageSize, Integer total) { + } + +} diff --git a/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/sca/response/package-info.java b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/sca/response/package-info.java new file mode 100644 index 0000000..a3b8dae --- /dev/null +++ b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/sca/response/package-info.java @@ -0,0 +1,20 @@ +/* + * SonarQube MCP Server + * Copyright (C) 2025 SonarSource + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +@ParametersAreNonnullByDefault +package org.sonarsource.sonarqube.mcp.serverapi.sca.response; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/SettingsApi.java b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/SettingsApi.java new file mode 100644 index 0000000..d61ab00 --- /dev/null +++ b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/SettingsApi.java @@ -0,0 +1,40 @@ +/* + * SonarQube MCP Server + * Copyright (C) 2025 SonarSource + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonarsource.sonarqube.mcp.serverapi.settings; + +import com.google.gson.Gson; +import org.sonarsource.sonarqube.mcp.serverapi.ServerApiHelper; +import org.sonarsource.sonarqube.mcp.serverapi.settings.response.ValuesResponse; + +public class SettingsApi { + + public static final String SETTINGS_PATH = "/api/settings/values"; + + private final ServerApiHelper helper; + + public SettingsApi(ServerApiHelper helper) { + this.helper = helper; + } + + public ValuesResponse getSettings() { + try (var response = helper.get(SETTINGS_PATH)) { + var responseStr = response.bodyAsString(); + return new Gson().fromJson(responseStr, ValuesResponse.class); + } + } + +} diff --git a/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/package-info.java b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/package-info.java new file mode 100644 index 0000000..2db9d9a --- /dev/null +++ b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/package-info.java @@ -0,0 +1,20 @@ +/* + * SonarQube MCP Server + * Copyright (C) 2025 SonarSource + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +@ParametersAreNonnullByDefault +package org.sonarsource.sonarqube.mcp.serverapi.settings; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/response/ValuesResponse.java b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/response/ValuesResponse.java new file mode 100644 index 0000000..a6d2b30 --- /dev/null +++ b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/response/ValuesResponse.java @@ -0,0 +1,64 @@ +/* + * SonarQube MCP Server + * Copyright (C) 2025 SonarSource + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonarsource.sonarqube.mcp.serverapi.settings.response; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nullable; + +public record ValuesResponse( + List settings, + @Nullable List setSecuredSettings +) { + + public record Setting( + String key, + @Nullable String value, + @Nullable List values, + @Nullable List> fieldValues, + boolean inherited + ) {} + + /** + * Helper method to get a specific setting value by key + * @param key the setting key to look for + * @return the setting value if found, null otherwise + */ + public String getSettingValue(String key) { + if (settings == null) { + return null; + } + + return settings.stream() + .filter(setting -> key.equals(setting.key())) + .map(Setting::value) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + /** + * Helper method to check if a boolean setting is enabled + * @param key the setting key to check + * @return true if the setting exists and is set to "true", false otherwise + */ + public boolean isBooleanSettingEnabled(String key) { + return "true".equalsIgnoreCase(getSettingValue(key)); + } + +} diff --git a/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/response/package-info.java b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/response/package-info.java new file mode 100644 index 0000000..173f2d8 --- /dev/null +++ b/src/main/java/org/sonarsource/sonarqube/mcp/serverapi/settings/response/package-info.java @@ -0,0 +1,20 @@ +/* + * SonarQube MCP Server + * Copyright (C) 2025 SonarSource + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +@ParametersAreNonnullByDefault +package org.sonarsource.sonarqube.mcp.serverapi.settings.response; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/src/main/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/SearchDependencyRisksTool.java b/src/main/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/SearchDependencyRisksTool.java new file mode 100644 index 0000000..25645e8 --- /dev/null +++ b/src/main/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/SearchDependencyRisksTool.java @@ -0,0 +1,135 @@ +/* + * SonarQube MCP Server + * Copyright (C) 2025 SonarSource + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonarsource.sonarqube.mcp.tools.dependencyrisks; + +import javax.annotation.Nullable; +import org.sonarsource.sonarqube.mcp.serverapi.ServerApi; +import org.sonarsource.sonarqube.mcp.serverapi.sca.response.DependencyRisksResponse; +import org.sonarsource.sonarqube.mcp.tools.SchemaToolBuilder; +import org.sonarsource.sonarqube.mcp.tools.Tool; + +public class SearchDependencyRisksTool extends Tool { + + public static final String TOOL_NAME = "search_dependency_risks"; + public static final String PROJECT_KEY_PROPERTY = "projectKey"; + public static final String BRANCH_KEY_PROPERTY = "branchKey"; + public static final String PULL_REQUEST_KEY_PROPERTY = "pullRequestKey"; + + private final ServerApi serverApi; + + public SearchDependencyRisksTool(ServerApi serverApi) { + super(new SchemaToolBuilder() + .setName(TOOL_NAME) + .setDescription("Search for software composition analysis issues (dependency risks) of a project, " + + "paired with releases that appear in the analyzed project, application, or portfolio.") + .addRequiredStringProperty(PROJECT_KEY_PROPERTY, "The project key") + .addStringProperty(BRANCH_KEY_PROPERTY, "The branch key") + .addStringProperty(PULL_REQUEST_KEY_PROPERTY, "The pull request key") + .build()); + this.serverApi = serverApi; + } + + @Override + public Tool.Result execute(Tool.Arguments arguments) { + var projectKey = arguments.getStringOrThrow(PROJECT_KEY_PROPERTY); + var branchKey = arguments.getOptionalString(BRANCH_KEY_PROPERTY); + var pullRequestKey = arguments.getOptionalString(PULL_REQUEST_KEY_PROPERTY); + + var response = serverApi.scaApi().getDependencyRisks(projectKey, branchKey, pullRequestKey); + return Tool.Result.success(buildResponseFromDependencyRisksResponse(response)); + } + + private static String buildResponseFromDependencyRisksResponse(DependencyRisksResponse response) { + var issuesReleases = response.issuesReleases(); + + if (issuesReleases.isEmpty()) { + return "No dependency risks were found."; + } + + var stringBuilder = new StringBuilder(); + stringBuilder.append("Found ").append(issuesReleases.size()).append(" dependency risks.\n"); + + appendPaginationInfo(stringBuilder, response.page()); + + for (var issueRelease : issuesReleases) { + appendIssueReleaseInfo(stringBuilder, issueRelease); + } + + return stringBuilder.toString().trim(); + } + + private static void appendPaginationInfo(StringBuilder stringBuilder, @Nullable DependencyRisksResponse.Page page) { + if (page != null) { + var totalPages = (int) Math.ceil((double) page.total() / page.pageSize()); + stringBuilder.append("This response is paginated and this is the page ").append(page.pageIndex()) + .append(" out of ").append(totalPages).append(" total pages. There is a maximum of ") + .append(page.pageSize()).append(" items per page.\n"); + } + } + + private static void appendIssueReleaseInfo(StringBuilder stringBuilder, DependencyRisksResponse.IssueRelease issueRelease) { + stringBuilder.append("Issue key: ").append(issueRelease.key()) + .append(" | Severity: ").append(issueRelease.severity()) + .append(" | Type: ").append(issueRelease.type()) + .append(" | Quality: ").append(issueRelease.quality()) + .append(" | Status: ").append(issueRelease.status()); + + appendOptionalFields(stringBuilder, issueRelease); + appendReleaseInfo(stringBuilder, issueRelease.release()); + appendAssigneeInfo(stringBuilder, issueRelease.assignee()); + + stringBuilder.append(" | Created: ").append(issueRelease.createdAt()); + stringBuilder.append("\n"); + } + + private static void appendOptionalFields(StringBuilder stringBuilder, DependencyRisksResponse.IssueRelease issueRelease) { + if (issueRelease.vulnerabilityId() != null) { + stringBuilder.append(" | Vulnerability ID: ").append(issueRelease.vulnerabilityId()); + } + + if (issueRelease.cvssScore() != null) { + stringBuilder.append(" | CVSS Score: ").append(issueRelease.cvssScore()); + } + } + + private static void appendReleaseInfo(StringBuilder stringBuilder, @Nullable DependencyRisksResponse.Release release) { + if (release != null) { + stringBuilder.append(" | Package: ").append(release.packageName()) + .append(" | Version: ").append(release.version()) + .append(" | Package Manager: ").append(release.packageManager()); + + if (release.newlyIntroduced() != null && release.newlyIntroduced()) { + stringBuilder.append(" | Newly Introduced: Yes"); + } + + if (release.directSummary() != null && release.directSummary()) { + stringBuilder.append(" | Direct Dependency: Yes"); + } + + if (release.productionScopeSummary() != null && release.productionScopeSummary()) { + stringBuilder.append(" | Production Scope: Yes"); + } + } + } + + private static void appendAssigneeInfo(StringBuilder stringBuilder, @Nullable DependencyRisksResponse.Assignee assignee) { + if (assignee != null) { + stringBuilder.append(" | Assignee: ").append(assignee.name()); + } + } + +} diff --git a/src/main/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/package-info.java b/src/main/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/package-info.java new file mode 100644 index 0000000..4ec4187 --- /dev/null +++ b/src/main/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/package-info.java @@ -0,0 +1,20 @@ +/* + * SonarQube MCP Server + * Copyright (C) 2025 SonarSource + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +@ParametersAreNonnullByDefault +package org.sonarsource.sonarqube.mcp.tools.dependencyrisks; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/src/test/java/org/sonarsource/sonarqube/mcp/SonarQubeVersionCheckerTest.java b/src/test/java/org/sonarsource/sonarqube/mcp/SonarQubeVersionCheckerTest.java index 05d5b00..1acf6ab 100644 --- a/src/test/java/org/sonarsource/sonarqube/mcp/SonarQubeVersionCheckerTest.java +++ b/src/test/java/org/sonarsource/sonarqube/mcp/SonarQubeVersionCheckerTest.java @@ -18,6 +18,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.sonarsource.sonarqube.mcp.serverapi.ServerApi; import org.sonarsource.sonarqube.mcp.serverapi.system.SystemApi; import org.sonarsource.sonarqube.mcp.serverapi.system.response.StatusResponse; @@ -66,4 +68,35 @@ void it_should_throw_if_sonarqube_server_version_is_not_supported() { .isInstanceOf(IllegalStateException.class) .hasMessage("SonarQube server version is not supported, minimal version is SQS 2025.1 or SQCB 25.1"); } + + @Test + void it_should_return_false_for_sonarqube_cloud() { + when(serverApi.isSonarQubeCloud()).thenReturn(true); + + var result = versionChecker.isSonarQubeServerVersionHigherOrEqualsThan("2025.3"); + + assertThat(result).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"2025.4", "2025.3", "10.9.1", "2025.3-SNAPSHOT"}) + void it_should_return_true_when_valid(String input) { + when(serverApi.isSonarQubeCloud()).thenReturn(false); + when(systemApi.getStatus()).thenReturn(new StatusResponse("id", "2025.4", "UP")); + + var result = versionChecker.isSonarQubeServerVersionHigherOrEqualsThan(input); + + assertThat(result).isTrue(); + } + + @Test + void it_should_return_false_when_server_version_is_lower_than_min_version() { + when(serverApi.isSonarQubeCloud()).thenReturn(false); + when(systemApi.getStatus()).thenReturn(new StatusResponse("id", "2025.2", "UP")); + + var result = versionChecker.isSonarQubeServerVersionHigherOrEqualsThan("2025.3"); + + assertThat(result).isFalse(); + } + } diff --git a/src/test/java/org/sonarsource/sonarqube/mcp/harness/SonarQubeMcpServerTestHarness.java b/src/test/java/org/sonarsource/sonarqube/mcp/harness/SonarQubeMcpServerTestHarness.java index 29831ea..0e8dc1e 100644 --- a/src/test/java/org/sonarsource/sonarqube/mcp/harness/SonarQubeMcpServerTestHarness.java +++ b/src/test/java/org/sonarsource/sonarqube/mcp/harness/SonarQubeMcpServerTestHarness.java @@ -40,6 +40,7 @@ import org.junit.jupiter.api.extension.support.TypeBasedParameterResolver; import org.sonarsource.sonarqube.mcp.SonarQubeMcpServer; import org.sonarsource.sonarqube.mcp.serverapi.plugins.PluginsApi; +import org.sonarsource.sonarqube.mcp.serverapi.settings.SettingsApi; import org.sonarsource.sonarqube.mcp.serverapi.system.SystemApi; import org.sonarsource.sonarqube.mcp.transport.StdioServerTransportProvider; @@ -76,6 +77,19 @@ public void beforeEach(ExtensionContext context) throws Exception { """))); mockSonarQubeServer.stubFor(get(PluginsApi.DOWNLOAD_PLUGINS_PATH + "?plugin=php") .willReturn(aResponse().withBody(Files.readAllBytes(Paths.get("build/sonarqube-mcp-server/plugins/sonar-php-plugin-3.45.0.12991.jar"))))); + + mockSonarQubeServer.stubFor(get(SettingsApi.SETTINGS_PATH).willReturn(okJson(""" + { + "settings": [ + { + "key": "sonar.sca.enabled", + "value": "true", + "inherited": false + } + ], + "setSecuredSettings": [] + } + """))); } @Override @@ -116,14 +130,18 @@ public SonarQubeMcpTestClient newClient() { public SonarQubeMcpTestClient newClient(Map overriddenEnv) { if (!overriddenEnv.containsKey("SONARQUBE_ORG")) { + var version = "2025.4"; + if (overriddenEnv.containsKey("SONARQUBE_VERSION")) { + version = overriddenEnv.get("SONARQUBE_VERSION"); + } mockSonarQubeServer.stubFor(get(SystemApi.STATUS_PATH) .willReturn(aResponse().withResponseBody( - Body.fromJsonBytes(""" + Body.fromJsonBytes(String.format(""" { "id": "20150504120436", - "version": "2025.1", + "version": "%s", "status": "UP" - }""".getBytes(StandardCharsets.UTF_8))))); + }""", version).getBytes(StandardCharsets.UTF_8))))); } if (overriddenEnv.containsKey("STORAGE_PATH")) { tempStoragePath = Paths.get(overriddenEnv.get("STORAGE_PATH")); diff --git a/src/test/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/SearchDependencyRisksToolTests.java b/src/test/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/SearchDependencyRisksToolTests.java new file mode 100644 index 0000000..c6a8396 --- /dev/null +++ b/src/test/java/org/sonarsource/sonarqube/mcp/tools/dependencyrisks/SearchDependencyRisksToolTests.java @@ -0,0 +1,410 @@ +/* + * SonarQube MCP Server + * Copyright (C) 2025 SonarSource + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonarsource.sonarqube.mcp.tools.dependencyrisks; + +import com.github.tomakehurst.wiremock.http.Body; +import io.modelcontextprotocol.spec.McpSchema; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.sonarsource.sonarqube.mcp.harness.ReceivedRequest; +import org.sonarsource.sonarqube.mcp.harness.SonarQubeMcpServerTest; +import org.sonarsource.sonarqube.mcp.harness.SonarQubeMcpServerTestHarness; +import org.sonarsource.sonarqube.mcp.serverapi.sca.ScaApi; +import org.sonarsource.sonarqube.mcp.serverapi.settings.SettingsApi; +import org.sonarsource.sonarqube.mcp.tools.system.SystemHealthTool; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SearchDependencyRisksToolTests { + + @Nested + class WithSonarCloudServer { + + @SonarQubeMcpServerTest + void it_should_not_be_available_for_sonarcloud(SonarQubeMcpServerTestHarness harness) { + var mcpClient = harness.newClient(Map.of( + "SONARQUBE_CLOUD_URL", harness.getMockSonarQubeServer().baseUrl(), + "SONARQUBE_ORG", "org")); + + var exception = assertThrows(io.modelcontextprotocol.spec.McpError.class, () -> mcpClient.callTool(SystemHealthTool.TOOL_NAME)); + + assertThat(exception.getMessage()).isEqualTo("Tool not found: " + SystemHealthTool.TOOL_NAME); + } + } + + @Nested + class WithSonarQubeServer { + + @SonarQubeMcpServerTest + void it_should_return_an_error_if_the_request_fails_due_to_token_permission(SonarQubeMcpServerTestHarness harness) { + harness.getMockSonarQubeServer().stubFor(get(ScaApi.DEPENDENCY_RISKS_PATH + "?projectKey=my-project").willReturn(aResponse().withStatus(403))); + var mcpClient = harness.newClient(); + + var result = mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of(SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project")); + + assertThat(result) + .isEqualTo(new McpSchema.CallToolResult("An error occurred during the tool execution: SonarQube answered with Forbidden", true)); + } + + @SonarQubeMcpServerTest + void it_should_return_an_error_if_project_key_is_missing(SonarQubeMcpServerTestHarness harness) { + var mcpClient = harness.newClient(); + + var result = mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME); + + assertThat(result) + .isEqualTo(new McpSchema.CallToolResult("An error occurred during the tool execution: Missing required argument: projectKey", true)); + } + + @SonarQubeMcpServerTest + void it_should_not_find_tool_if_version_not_sufficient(SonarQubeMcpServerTestHarness harness) { + var mcpClient = harness.newClient(Map.of("SONARQUBE_VERSION", "2025.1")); + + var exception = assertThrows(io.modelcontextprotocol.spec.McpError.class, () -> { + mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of(SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project")); + }); + + assertThat(exception.getMessage()).isEqualTo("Tool not found: " + SearchDependencyRisksTool.TOOL_NAME); + } + + @SonarQubeMcpServerTest + void it_should_not_find_tool_if_sca_is_disabled(SonarQubeMcpServerTestHarness harness) { + harness.getMockSonarQubeServer().stubFor(get(SettingsApi.SETTINGS_PATH).willReturn(aResponse().withResponseBody( + Body.fromJsonBytes(""" + { + "settings": [ + { + "key": "sonar.sca.enabled", + "value": "false", + "inherited": false + } + ], + "setSecuredSettings": [] + } + """.getBytes(StandardCharsets.UTF_8)) + ))); + + var mcpClient = harness.newClient(); + + var exception = assertThrows(io.modelcontextprotocol.spec.McpError.class, () -> { + mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of(SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project")); + }); + + assertThat(exception.getMessage()).isEqualTo("Tool not found: " + SearchDependencyRisksTool.TOOL_NAME); + } + + @SonarQubeMcpServerTest + void it_should_not_find_tool_if_sca_setting_is_missing(SonarQubeMcpServerTestHarness harness) { + // Mock settings API to return empty settings (no sonar.sca.enabled setting) + harness.getMockSonarQubeServer().stubFor(get(SettingsApi.SETTINGS_PATH).willReturn(aResponse().withResponseBody( + Body.fromJsonBytes(""" + { + "settings": [], + "setSecuredSettings": [] + } + """.getBytes(StandardCharsets.UTF_8)) + ))); + + var mcpClient = harness.newClient(); + + var exception = assertThrows(io.modelcontextprotocol.spec.McpError.class, () -> { + mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of(SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project")); + }); + + assertThat(exception.getMessage()).isEqualTo("Tool not found: " + SearchDependencyRisksTool.TOOL_NAME); + } + + @SonarQubeMcpServerTest + void it_should_fetch_dependency_risks_for_project_key_only(SonarQubeMcpServerTestHarness harness) { + harness.getMockSonarQubeServer().stubFor(get(ScaApi.DEPENDENCY_RISKS_PATH + "?projectKey=my-project") + .willReturn(aResponse().withResponseBody( + Body.fromJsonBytes(""" + { + "issuesReleases": [%s], + "branches": [], + "countWithoutFilters": 1, + "page": { + "pageIndex": 1, + "pageSize": 100, + "total": 1 + } + } + """.formatted(generateIssueRelease()).getBytes(StandardCharsets.UTF_8)) + ))); + var mcpClient = harness.newClient(); + + var result = mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of(SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project")); + + assertThat(result) + .isEqualTo(new McpSchema.CallToolResult(""" + Found 1 dependency risks. + This response is paginated and this is the page 1 out of 1 total pages. There is a maximum of 100 items per page. + Issue key: issue-123 | Severity: HIGH | Type: VULNERABILITY | Quality: SECURITY | Status: OPEN | Vulnerability ID: CVE-2023-1234 | CVSS Score: 7.5 | Package: lodash | Version: 1.2.3 | Package Manager: npm | Newly Introduced: Yes | Direct Dependency: Yes | Production Scope: Yes | Assignee: John Doe | Created: 2024-01-15T10:30:00Z + """.trim(), false)); + assertThat(harness.getMockSonarQubeServer().getReceivedRequests()) + .contains(new ReceivedRequest("Bearer token", "")); + } + + @SonarQubeMcpServerTest + void it_should_fetch_dependency_risks_with_branch_key(SonarQubeMcpServerTestHarness harness) { + harness.getMockSonarQubeServer().stubFor(get(ScaApi.DEPENDENCY_RISKS_PATH + "?projectKey=my-project&branchKey=feature%2Fnew-feature") + .willReturn(aResponse().withResponseBody( + Body.fromJsonBytes(""" + { + "issuesReleases": [%s], + "branches": [], + "countWithoutFilters": 1, + "page": { + "pageIndex": 1, + "pageSize": 100, + "total": 1 + } + } + """.formatted(generateIssueRelease()).getBytes(StandardCharsets.UTF_8)) + ))); + var mcpClient = harness.newClient(); + + var result = mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of( + SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project", + SearchDependencyRisksTool.BRANCH_KEY_PROPERTY, "feature/new-feature")); + + assertThat(result) + .isEqualTo(new McpSchema.CallToolResult(""" + Found 1 dependency risks. + This response is paginated and this is the page 1 out of 1 total pages. There is a maximum of 100 items per page. + Issue key: issue-123 | Severity: HIGH | Type: VULNERABILITY | Quality: SECURITY | Status: OPEN | Vulnerability ID: CVE-2023-1234 | CVSS Score: 7.5 | Package: lodash | Version: 1.2.3 | Package Manager: npm | Newly Introduced: Yes | Direct Dependency: Yes | Production Scope: Yes | Assignee: John Doe | Created: 2024-01-15T10:30:00Z + """.trim(), false)); + assertThat(harness.getMockSonarQubeServer().getReceivedRequests()) + .contains(new ReceivedRequest("Bearer token", "")); + } + + @SonarQubeMcpServerTest + void it_should_fetch_dependency_risks_with_pull_request_key(SonarQubeMcpServerTestHarness harness) { + harness.getMockSonarQubeServer().stubFor(get(ScaApi.DEPENDENCY_RISKS_PATH + "?projectKey=my-project&pullRequestKey=123") + .willReturn(aResponse().withResponseBody( + Body.fromJsonBytes(""" + { + "issuesReleases": [%s], + "branches": [], + "countWithoutFilters": 1, + "page": { + "pageIndex": 1, + "pageSize": 100, + "total": 1 + } + } + """.formatted(generateIssueRelease()).getBytes(StandardCharsets.UTF_8)) + ))); + var mcpClient = harness.newClient(); + + var result = mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of( + SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project", + SearchDependencyRisksTool.PULL_REQUEST_KEY_PROPERTY, "123")); + + assertThat(result) + .isEqualTo(new McpSchema.CallToolResult(""" + Found 1 dependency risks. + This response is paginated and this is the page 1 out of 1 total pages. There is a maximum of 100 items per page. + Issue key: issue-123 | Severity: HIGH | Type: VULNERABILITY | Quality: SECURITY | Status: OPEN | Vulnerability ID: CVE-2023-1234 | CVSS Score: 7.5 | Package: lodash | Version: 1.2.3 | Package Manager: npm | Newly Introduced: Yes | Direct Dependency: Yes | Production Scope: Yes | Assignee: John Doe | Created: 2024-01-15T10:30:00Z + """.trim(), false)); + assertThat(harness.getMockSonarQubeServer().getReceivedRequests()) + .contains(new ReceivedRequest("Bearer token", "")); + } + + @SonarQubeMcpServerTest + void it_should_handle_empty_dependency_risks_response(SonarQubeMcpServerTestHarness harness) { + harness.getMockSonarQubeServer().stubFor(get(ScaApi.DEPENDENCY_RISKS_PATH + "?projectKey=my-project") + .willReturn(aResponse().withResponseBody( + Body.fromJsonBytes(""" + { + "issuesReleases": [], + "branches": [], + "countWithoutFilters": 0, + "page": { + "pageIndex": 1, + "pageSize": 100, + "total": 0 + } + } + """.getBytes(StandardCharsets.UTF_8)) + ))); + var mcpClient = harness.newClient(); + + var result = mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of(SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project")); + + assertThat(result) + .isEqualTo(new McpSchema.CallToolResult("No dependency risks were found.", false)); + assertThat(harness.getMockSonarQubeServer().getReceivedRequests()) + .contains(new ReceivedRequest("Bearer token", "")); + } + + @SonarQubeMcpServerTest + void it_should_handle_dependency_risks_with_minimal_data(SonarQubeMcpServerTestHarness harness) { + harness.getMockSonarQubeServer().stubFor(get(ScaApi.DEPENDENCY_RISKS_PATH + "?projectKey=my-project") + .willReturn(aResponse().withResponseBody( + Body.fromJsonBytes(""" + { + "issuesReleases": [%s], + "branches": [], + "countWithoutFilters": 1, + "page": { + "pageIndex": 1, + "pageSize": 100, + "total": 1 + } + } + """.formatted(generateMinimalIssueRelease()).getBytes(StandardCharsets.UTF_8)) + ))); + var mcpClient = harness.newClient(); + + var result = mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of(SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project")); + + assertThat(result) + .isEqualTo(new McpSchema.CallToolResult(""" + Found 1 dependency risks. + This response is paginated and this is the page 1 out of 1 total pages. There is a maximum of 100 items per page. + Issue key: issue-456 | Severity: MEDIUM | Type: PROHIBITED_LICENSE | Quality: MAINTAINABILITY | Status: OPEN | Package: package-name | Version: 2.0.0 | Package Manager: maven | Created: 2024-01-20T14:45:00Z + """.trim(), false)); + assertThat(harness.getMockSonarQubeServer().getReceivedRequests()) + .contains(new ReceivedRequest("Bearer token", "")); + } + + @SonarQubeMcpServerTest + void it_should_handle_multiple_dependency_risks(SonarQubeMcpServerTestHarness harness) { + harness.getMockSonarQubeServer().stubFor(get(ScaApi.DEPENDENCY_RISKS_PATH + "?projectKey=my-project") + .willReturn(aResponse().withResponseBody( + Body.fromJsonBytes(""" + { + "issuesReleases": [%s, %s], + "branches": [], + "countWithoutFilters": 2, + "page": { + "pageIndex": 1, + "pageSize": 100, + "total": 2 + } + } + """.formatted(generateIssueRelease(), generateMinimalIssueRelease()).getBytes(StandardCharsets.UTF_8)) + ))); + var mcpClient = harness.newClient(); + + var result = mcpClient.callTool(SearchDependencyRisksTool.TOOL_NAME, Map.of(SearchDependencyRisksTool.PROJECT_KEY_PROPERTY, "my-project")); + + assertThat(result) + .isEqualTo(new McpSchema.CallToolResult(""" + Found 2 dependency risks. + This response is paginated and this is the page 1 out of 1 total pages. There is a maximum of 100 items per page. + Issue key: issue-123 | Severity: HIGH | Type: VULNERABILITY | Quality: SECURITY | Status: OPEN | Vulnerability ID: CVE-2023-1234 | CVSS Score: 7.5 | Package: lodash | Version: 1.2.3 | Package Manager: npm | Newly Introduced: Yes | Direct Dependency: Yes | Production Scope: Yes | Assignee: John Doe | Created: 2024-01-15T10:30:00Z + Issue key: issue-456 | Severity: MEDIUM | Type: PROHIBITED_LICENSE | Quality: MAINTAINABILITY | Status: OPEN | Package: package-name | Version: 2.0.0 | Package Manager: maven | Created: 2024-01-20T14:45:00Z + """.trim(), false)); + assertThat(harness.getMockSonarQubeServer().getReceivedRequests()) + .contains(new ReceivedRequest("Bearer token", "")); + } + } + + private static String generateIssueRelease() { + return """ + { + "key": "issue-123", + "severity": "HIGH", + "originalSeverity": "HIGH", + "manualSeverity": null, + "showIncreasedSeverityWarning": false, + "release": { + "key": "release-123", + "branchUuid": "branch-uuid", + "packageUrl": "pkg:npm/lodash@1.2.3", + "packageManager": "npm", + "packageName": "lodash", + "version": "1.2.3", + "licenseExpression": "MIT", + "known": true, + "knownPackage": true, + "newlyIntroduced": true, + "directSummary": true, + "scopeSummary": "production", + "productionScopeSummary": true, + "dependencyFilePaths": ["package.json"] + }, + "type": "VULNERABILITY", + "quality": "SECURITY", + "status": "OPEN", + "createdAt": "2024-01-15T10:30:00Z", + "assignee": { + "login": "john.doe", + "name": "John Doe", + "avatar": "avatar.png", + "active": true + }, + "commentCount": 2, + "vulnerabilityId": "CVE-2023-1234", + "cweIds": ["CWE-89"], + "cvssScore": "7.5", + "withdrawn": false, + "spdxLicenseId": "MIT", + "transitions": ["CONFIRM", "ACCEPT"], + "actions": ["COMMENT", "ASSIGN"] + } + """; + } + + private static String generateMinimalIssueRelease() { + return """ + { + "key": "issue-456", + "severity": "MEDIUM", + "originalSeverity": "MEDIUM", + "manualSeverity": null, + "showIncreasedSeverityWarning": false, + "release": { + "key": "release-456", + "branchUuid": "branch-uuid-2", + "packageUrl": "pkg:maven/com.example/package-name@2.0.0", + "packageManager": "maven", + "packageName": "package-name", + "version": "2.0.0", + "licenseExpression": "Apache-2.0", + "known": true, + "knownPackage": true, + "newlyIntroduced": false, + "directSummary": false, + "scopeSummary": "test", + "productionScopeSummary": false, + "dependencyFilePaths": ["pom.xml"] + }, + "type": "PROHIBITED_LICENSE", + "quality": "MAINTAINABILITY", + "status": "OPEN", + "createdAt": "2024-01-20T14:45:00Z", + "assignee": null, + "commentCount": 0, + "vulnerabilityId": null, + "cweIds": [], + "cvssScore": null, + "withdrawn": false, + "spdxLicenseId": "Apache-2.0", + "transitions": ["CONFIRM"], + "actions": ["COMMENT"] + } + """; + } + +} diff --git a/src/test/java/org/sonarsource/sonarqube/mcp/tools/system/SystemHealthToolTests.java b/src/test/java/org/sonarsource/sonarqube/mcp/tools/system/SystemHealthToolTests.java index 18e1213..4f6e6fd 100644 --- a/src/test/java/org/sonarsource/sonarqube/mcp/tools/system/SystemHealthToolTests.java +++ b/src/test/java/org/sonarsource/sonarqube/mcp/tools/system/SystemHealthToolTests.java @@ -49,6 +49,7 @@ void it_should_not_be_available_for_sonarcloud(SonarQubeMcpServerTestHarness har assertThat(exception.getMessage()).isEqualTo("Tool not found: " + SystemHealthTool.TOOL_NAME); } + } @Nested