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();
+ }
+}