From 9fde675a20b233cef0f4b26e917d24c3a8e9e4ee Mon Sep 17 00:00:00 2001 From: Markus Winter Date: Tue, 4 Aug 2026 22:57:56 +0200 Subject: [PATCH 1/4] Tool to cancel builds Add the tool `cancelBuild`. requires the parameters jobFullName and the build number. Performs relevant checks upfront. While the doStop methods perform permission checks themselves, they only return httpresponses which is a bit arkward to work with. So when we call `doStop` we can be sure we're allowed to cancel. fixes #189 --- pom.xml | 10 +- .../server/extensions/DefaultMcpServer.java | 33 ++++ .../server/extensions/CancelBuildTest.java | 170 ++++++++++++++++++ .../extensions/DefaultMcpServerTest.java | 88 +++++++++ 4 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 src/test/java/io/jenkins/plugins/mcp/server/extensions/CancelBuildTest.java 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/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(); + } +} diff --git a/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java b/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java index 39e01d0..fcd6bc7 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java @@ -37,6 +37,7 @@ import com.jayway.jsonpath.JsonPath; import hudson.model.BooleanParameterDefinition; import hudson.model.ChoiceParameterDefinition; +import hudson.model.FreeStyleProject; import hudson.model.ParametersDefinitionProperty; import hudson.model.Result; import hudson.model.StringParameterDefinition; @@ -56,6 +57,7 @@ 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.SleepBuilder; import org.jvnet.hudson.test.junit.jupiter.WithJenkins; @WithJenkins @@ -409,6 +411,92 @@ void testMcpToolCallGetStatusWithAuth(JenkinsRule jenkins, JenkinsMcpClientBuild } } + static Stream cancelBuildTestParameters() { + Stream baseArgs = Stream.of( + // security enable, no auth -> no, AccessDenied + Arguments.of(true, false, true, "AccessDenied", false), + // security enable, auth -> no, triggered + Arguments.of(true, true, false, "COMPLETED", true), + // security not enable, no auth -> run triggered yeah freedom! + Arguments.of(false, false, false, "COMPLETED", true), + // security not enable, auth -> run triggered root is the king + Arguments.of(false, true, false, "COMPLETED", true)); + return TestUtils.appendMcpClientArgs(baseArgs); + } + + @McpClientTest + void testMcpToolCallCancelBuildPipeline(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClientBuilder) + throws Exception { + enableSecurity(jenkins); + WorkflowJob project = jenkins.createProject(WorkflowJob.class, "demo-job"); + project.setDefinition(new CpsFlowDefinition("sleep 30", true)); + var build = project.scheduleBuild2(0).get(); + + 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", "demo-job", "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); + } + + @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).get(); + + 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); From 695f20f3995311e7ef62dfbe71b59da7d4362df5 Mon Sep 17 00:00:00 2001 From: Markus Winter Date: Wed, 5 Aug 2026 09:58:31 +0200 Subject: [PATCH 2/4] add cancelBuild to EndPointTest --- src/test/java/io/jenkins/plugins/mcp/server/EndPointTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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"); } } From 776413bbb325e1f119085faccd5446e1531e809b Mon Sep 17 00:00:00 2001 From: Markus Winter Date: Wed, 5 Aug 2026 10:19:34 +0200 Subject: [PATCH 3/4] remove duplicate test --- .../extensions/DefaultMcpServerTest.java | 88 ------------------- 1 file changed, 88 deletions(-) diff --git a/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java b/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java index fcd6bc7..39e01d0 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServerTest.java @@ -37,7 +37,6 @@ import com.jayway.jsonpath.JsonPath; import hudson.model.BooleanParameterDefinition; import hudson.model.ChoiceParameterDefinition; -import hudson.model.FreeStyleProject; import hudson.model.ParametersDefinitionProperty; import hudson.model.Result; import hudson.model.StringParameterDefinition; @@ -57,7 +56,6 @@ 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.SleepBuilder; import org.jvnet.hudson.test.junit.jupiter.WithJenkins; @WithJenkins @@ -411,92 +409,6 @@ void testMcpToolCallGetStatusWithAuth(JenkinsRule jenkins, JenkinsMcpClientBuild } } - static Stream cancelBuildTestParameters() { - Stream baseArgs = Stream.of( - // security enable, no auth -> no, AccessDenied - Arguments.of(true, false, true, "AccessDenied", false), - // security enable, auth -> no, triggered - Arguments.of(true, true, false, "COMPLETED", true), - // security not enable, no auth -> run triggered yeah freedom! - Arguments.of(false, false, false, "COMPLETED", true), - // security not enable, auth -> run triggered root is the king - Arguments.of(false, true, false, "COMPLETED", true)); - return TestUtils.appendMcpClientArgs(baseArgs); - } - - @McpClientTest - void testMcpToolCallCancelBuildPipeline(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClientBuilder) - throws Exception { - enableSecurity(jenkins); - WorkflowJob project = jenkins.createProject(WorkflowJob.class, "demo-job"); - project.setDefinition(new CpsFlowDefinition("sleep 30", true)); - var build = project.scheduleBuild2(0).get(); - - 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", "demo-job", "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); - } - - @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).get(); - - 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); From cc2f101de634f296345912b0d648e21bfba49393 Mon Sep 17 00:00:00 2001 From: Markus Winter Date: Thu, 6 Aug 2026 19:38:44 +0200 Subject: [PATCH 4/4] add cancelBuild to readme --- README.md | 1 + 1 file changed, 1 insertion(+) 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.