diff --git a/README.md b/README.md index 0812466..1cb0d3e 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,7 @@ The plugin provides the following built-in tools for interacting with Jenkins: - `getReplayScripts`: Return the main script and loaded scripts of a replayable Pipeline build. Use this to inspect or modify script before calling `replayBuild`. Fails for non-Pipeline jobs. Optional `buildNumber`; defaults to the last build. - `replayBuild`: Run a Pipeline build again with a modified script. Provide `mainScript` (required) and optionally `loadedScripts`. Optional `buildNumber`; defaults to the last build. Fails if the build is not replayable or replay is not allowed (e.g. permissions or sandbox). - `getTestResults`: Retrieve test results of a specific build or the last build. +- `cancelBuild`: Cancel a running build. #### SCM Integration - `getJobScm`: Retrieve SCM configurations of a Jenkins job. diff --git a/pom.xml b/pom.xml index 7336f60..2fc7559 100644 --- a/pom.xml +++ b/pom.xml @@ -176,6 +176,11 @@ workflow-cps true + + org.jenkins-ci.plugins.workflow + workflow-job + true + @@ -238,11 +243,6 @@ workflow-durable-task-step test - - org.jenkins-ci.plugins.workflow - workflow-job - test - org.jenkins-ci.plugins.workflow workflow-step-api diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServer.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServer.java index 0ffbbc3..c0b3b0e 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServer.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServer.java @@ -30,6 +30,7 @@ import static io.jenkins.plugins.mcp.server.extensions.util.ParameterValueFactory.createParameterValue; import hudson.Extension; +import hudson.model.AbstractBuild; import hudson.model.AbstractItem; import hudson.model.Action; import hudson.model.AdministrativeMonitor; @@ -50,6 +51,8 @@ import io.jenkins.plugins.mcp.server.annotation.ToolParam; import io.jenkins.plugins.mcp.server.tool.JenkinsMcpContext; import jakarta.annotation.Nullable; +import jakarta.servlet.ServletException; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -65,6 +68,7 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.jenkinsci.plugins.workflow.cps.replay.ReplayAction; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; import org.kohsuke.stapler.export.Exported; @Extension @@ -73,6 +77,35 @@ public class DefaultMcpServer implements McpServerExtension { public static final String FULL_NAME = "fullName"; + public static boolean isWorkflowJobPluginInstalled() { + var plugin = Jenkins.get().getPluginManager().getPlugin("workflow-job"); + return plugin != null && plugin.isActive(); + } + + @Tool(description = "Cancel specific build") + public boolean cancelBuild( + @ToolParam(description = "Job full name of the Jenkins job (e.g., 'folder/job-name')") String jobFullName, + @ToolParam(description = "Build number") Integer buildNumber) + throws ServletException, IOException { + var job = Jenkins.get().getItemByFullName(jobFullName, Job.class); + if (job == null || !job.hasPermission(Item.CANCEL)) { + return false; + } + + var build = job.getBuildByNumber(buildNumber); + if (build == null || !build.isBuilding()) { + return false; + } + if (build instanceof AbstractBuild ab) { + ab.doStop(); + } else if (isWorkflowJobPluginInstalled() && build instanceof WorkflowRun wr) { + wr.doStop(); + } else { + return false; + } + return true; + } + @Tool( description = "Get a specific build or the last build of a Jenkins job", annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false)) diff --git a/src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java b/src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java index d49c2c2..d7bb2e4 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java @@ -69,7 +69,8 @@ void testListTools(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClient "getStatus", "getTestResults", "getFlakyFailures", - "getQueueItem"); + "getQueueItem", + "cancelBuild"); } } diff --git a/src/test/java/io/jenkins/plugins/mcp/server/extensions/CancelBuildTest.java b/src/test/java/io/jenkins/plugins/mcp/server/extensions/CancelBuildTest.java new file mode 100644 index 0000000..8ba1964 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/CancelBuildTest.java @@ -0,0 +1,170 @@ +/* + * + * The MIT License + * + * Copyright (c) 2025, Gong Yi. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + */ + +package io.jenkins.plugins.mcp.server.extensions; + +import static io.jenkins.plugins.mcp.server.junit.TestUtils.MIN_1; +import static org.assertj.core.api.Assertions.assertThat; + +import hudson.model.FreeStyleProject; +import hudson.model.Item; +import io.jenkins.plugins.mcp.server.junit.JenkinsMcpClientBuilder; +import io.jenkins.plugins.mcp.server.junit.McpClientTest; +import io.jenkins.plugins.mcp.server.junit.TestUtils; +import io.modelcontextprotocol.spec.McpSchema; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import jenkins.model.Jenkins; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; +import org.jvnet.hudson.test.SleepBuilder; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +@WithJenkins +class CancelBuildTest { + + static Stream cancelBuildTestParameters() { + Stream baseArgs = Stream.of( + // run already finished + Arguments.of("canceller", 1, "pipeline", false, true), + // run successfully cancelled + Arguments.of("canceller", 2, "pipeline", true, false), + // run not existing + Arguments.of("canceller", 3, "pipeline", false, true), + // job not existing + Arguments.of("canceller", 1, "missing", false, true), + // missing permission to cancel + Arguments.of("reader", 2, "pipeline", false, true), + // missing permission to see job + Arguments.of("unknown", 2, "pipeline", false, true)); + return TestUtils.appendMcpClientArgs(baseArgs); + } + + @ParameterizedTest + @MethodSource("cancelBuildTestParameters") + void testMcpToolCallCancelBuildPipeline( + String user, + int buildNumber, + String jobNameToCancel, + boolean expectedResults, + boolean expectedRunning, + JenkinsMcpClientBuilder jenkinsMcpClientBuilder, + JenkinsRule jenkins) + throws Exception { + enableSecurity(jenkins); + WorkflowJob project = jenkins.createProject(WorkflowJob.class, "pipeline"); + project.setDefinition(new CpsFlowDefinition("", true)); + var finishedBuild = project.scheduleBuild2(0).get(); + assertThat(finishedBuild.isBuilding()).isFalse(); + project.setDefinition(new CpsFlowDefinition("sleep 30", true)); + var runningBuild = project.scheduleBuild2(0).waitForStart(); + + String authString = user + ":" + user; + String encodedAuth = Base64.getEncoder().encodeToString(authString.getBytes()); + try (var client = jenkinsMcpClientBuilder + .jenkins(jenkins) + .requestCustomizer((builder, method, endpoint, body, context) -> + builder.setHeader("Authorization", "Basic " + encodedAuth)) + .build()) { + { + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "cancelBuild", Map.of("jobFullName", jobNameToCancel, "buildNumber", buildNumber), null); + + var response = client.callTool(request); + assertThat(response.isError()).isFalse(); + assertThat(response.content().get(0).type()).isEqualTo("text"); + assertThat(response.content()) + .first() + .isInstanceOfSatisfying(McpSchema.TextContent.class, textContent -> { + assertThat(textContent.type()).isEqualTo("text"); + assertThat(textContent.text()).contains(String.valueOf(expectedResults)); + }); + TimeUnit.SECONDS.sleep(2); + assertThat(runningBuild.isBuilding()).isEqualTo(expectedRunning); + runningBuild.doStop(); + } + } + jenkins.waitUntilNoActivityUpTo(MIN_1); + } + + @McpClientTest + void testMcpToolCallCancelBuildFreestyle(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClientBuilder) + throws Exception { + enableSecurity(jenkins); + FreeStyleProject project = jenkins.createFreeStyleProject("freestyle"); + project.getBuildersList().add(new SleepBuilder(30000)); + var build = project.scheduleBuild2(0).waitForStart(); + + String username = "admin"; + String password = "admin"; + String authString = username + ":" + password; + String encodedAuth = Base64.getEncoder().encodeToString(authString.getBytes()); + try (var client = jenkinsMcpClientBuilder + .jenkins(jenkins) + .requestCustomizer((builder, method, endpoint, body, context) -> + builder.setHeader("Authorization", "Basic " + encodedAuth)) + .build()) { + { + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "cancelBuild", Map.of("jobFullName", "freestyle", "buildNumber", 1), null); + + var response = client.callTool(request); + assertThat(response.isError()).isFalse(); + assertThat(response.content().get(0).type()).isEqualTo("text"); + assertThat(response.content()) + .first() + .isInstanceOfSatisfying(McpSchema.TextContent.class, textContent -> { + assertThat(textContent.type()).isEqualTo("text"); + assertThat(textContent.text()).contains("true"); + }); + + assertThat(build.isBuilding()).isFalse(); + } + } + jenkins.waitUntilNoActivityUpTo(MIN_1); + } + + private void enableSecurity(JenkinsRule jenkins) throws Exception { + JenkinsRule.DummySecurityRealm securityRealm = jenkins.createDummySecurityRealm(); + jenkins.jenkins.setSecurityRealm(securityRealm); + var authStrategy = new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER) + .everywhere() + .to("admin"); + authStrategy.grant(Jenkins.READ).everywhere().toEveryone(); + authStrategy.grant(Item.READ).everywhere().to("canceller", "reader"); + authStrategy.grant(Item.CANCEL).everywhere().to("canceller"); + jenkins.jenkins.setAuthorizationStrategy(authStrategy); + jenkins.jenkins.save(); + } +}