From b07437b6e08d95fabf5484ce35693077deb2dd58 Mon Sep 17 00:00:00 2001 From: Markus Winter Date: Mon, 3 Aug 2026 18:59:22 +0200 Subject: [PATCH 1/3] tools for agents Add 4 new tools to work with agents - listAgentNames: returns a list of all agents (doesn't include the built-in) - getAgent: Returns the Computer for the given agent (i.e. what also the api returns) - takeAgentOffline: take an agent offline with the given reason - takeAgentOnline: turn online --- .../mcp/server/extensions/AgentExtension.java | 72 ++++++ .../plugins/mcp/server/EndPointTest.java | 6 +- .../server/extensions/AgentExtensionTest.java | 205 ++++++++++++++++++ 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java create mode 100644 src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java new file mode 100644 index 0000000..37a27ae --- /dev/null +++ b/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java @@ -0,0 +1,72 @@ +package io.jenkins.plugins.mcp.server.extensions; + +import hudson.Extension; +import hudson.Util; +import hudson.model.Computer; +import hudson.model.Node; +import hudson.model.User; +import hudson.slaves.OfflineCause; +import io.jenkins.plugins.mcp.server.McpServerExtension; +import io.jenkins.plugins.mcp.server.annotation.Tool; +import io.jenkins.plugins.mcp.server.annotation.ToolParam; +import java.util.List; +import jenkins.model.Jenkins; +import lombok.extern.slf4j.Slf4j; + +@Extension +@Slf4j +public class AgentExtension implements McpServerExtension { + + @Tool( + description = "Get a list of all agent names, excluding the built-in node (master)", + annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false) + ) + public List listAgentNames() { + return Jenkins.get().getNodes().stream().map(Node::getNodeName).toList(); + } + + @Tool( + description = "Get a Jenkins agent by its name (the Computer object as the api does)", + annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false) + ) + public Computer getAgent(@ToolParam(description = "Agent name") String name) { + return Jenkins.get().getComputer(name); + } + + @Tool( + description = "Marks a Jenkins agent temporarily offline" + ) + public boolean takeAgentOffline( + @ToolParam(description = "Agent name") String name, + @ToolParam(description = "Offline reason") String reason + ) { + Computer computer = Jenkins.get().getComputer(name); + if (computer == null) { + return false; + } + if (!computer.hasPermission(Computer.DISCONNECT)) { + return false; + } + OfflineCause.UserCause cause = new OfflineCause.UserCause(User.current(), Util.fixEmptyAndTrim(reason)); + computer.setTemporaryOfflineCause(cause); + return true; + } + + @Tool( + description = "Take a Jenkins agent online", + annotations = @Tool.Annotations(destructiveHint = false) + ) + public boolean takeAgentOnline( + @ToolParam(description = "Agent name") String name + ) { + Computer computer = Jenkins.get().getComputer(name); + if (computer == null) { + return false; + } + if (!computer.hasPermission(Computer.CONNECT)) { + return false; + } + computer.setTemporaryOfflineCause(null); + return true; + } +} 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..7596021 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,11 @@ void testListTools(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClient "getStatus", "getTestResults", "getFlakyFailures", - "getQueueItem"); + "getQueueItem", + "getAgent", + "takeAgentOffline", + "takeAgentOnline", + "listAgentNames"); } } diff --git a/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java b/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java new file mode 100644 index 0000000..78deb0d --- /dev/null +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java @@ -0,0 +1,205 @@ +package io.jenkins.plugins.mcp.server.extensions; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import hudson.model.Computer; +import hudson.model.Node; +import hudson.model.User; +import hudson.slaves.OfflineCause; +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.List; +import java.util.Map; +import java.util.stream.Stream; +import jenkins.model.Jenkins; +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.junit.jupiter.WithJenkins; + +@WithJenkins +public class AgentExtensionTest { + + static Stream getAgentParameters() { + Stream baseArgs = Stream.of( + Arguments.of(false, ""), + Arguments.of(true, "Maintenance") + ); + return TestUtils.appendMcpClientArgs(baseArgs); + } + + @ParameterizedTest + @MethodSource("getAgentParameters") + void testGetAgent(boolean takeOffline, String offlineReason, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) throws Exception { + Node node = jenkins.createOnlineSlave(); + node.setLabelString("test linux"); + enableSecurity(jenkins); + if (takeOffline) { + User admin = User.getById("admin", true); + node.toComputer().setTemporaryOfflineCause(new OfflineCause.UserCause(admin, offlineReason)); + } + try (var client = jenkinsMcpClientBuilder + .jenkins(jenkins) + .requestCustomizer((builder, method, endpoint, body, context) -> { + String username = "admin"; + String password = "admin"; + String authString = username + ":" + password; + String encodedAuth = Base64.getEncoder().encodeToString(authString.getBytes()); + builder.setHeader("Authorization", "Basic " + encodedAuth); + }) + .build()) { + McpSchema.CallToolRequest request = + new McpSchema.CallToolRequest("getAgent", Map.of("name", node.getNodeName()), null); + var response = client.callTool(request); + assertThat(response.isError()).isFalse(); + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).type()).isEqualTo("text"); + assertThat(response.content()).first().isInstanceOfSatisfying(McpSchema.TextContent.class, textContent -> { + assertThat(textContent.type()).isEqualTo("text"); + DocumentContext documentContext = + JsonPath.using(Configuration.defaultConfiguration()).parse(textContent.text()); + var contentMap = documentContext.read("$.result", Map.class); + assertThat(contentMap).extractingByKey("displayName").isEqualTo(node.getDisplayName()); + assertThat(contentMap).extractingByKey("idle").isEqualTo(true); + assertThat(contentMap).extractingByKey("temporarilyOffline").isEqualTo(takeOffline); + assertThat(contentMap).extractingByKey("offline").isEqualTo(takeOffline); + assertThat(contentMap).extractingByKey("offlineCauseReason").isEqualTo(offlineReason); + }); + } + } + + + static Stream takeOfflineParameters() { + Stream baseArgs = Stream.of( + Arguments.of("admin", true), + Arguments.of("connecter", false), + Arguments.of("disconnecter", true) + ); + return TestUtils.appendMcpClientArgs(baseArgs); + } + + @ParameterizedTest + @MethodSource("takeOfflineParameters") + void testTakeAgentOffline(String user, boolean canTakeOffline, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) throws Exception { + Node node = jenkins.createOnlineSlave(); + node.setLabelString("test linux"); + enableSecurity(jenkins); + try (var client = jenkinsMcpClientBuilder + .jenkins(jenkins) + .requestCustomizer((builder, method, endpoint, body, context) -> { + String authString = user + ":" + user; + String encodedAuth = Base64.getEncoder().encodeToString(authString.getBytes()); + builder.setHeader("Authorization", "Basic " + encodedAuth); + }) + .build()) { + McpSchema.CallToolRequest request = + new McpSchema.CallToolRequest("takeAgentOffline", Map.of("name", node.getNodeName(), "reason", "Maintenance"), null); + var response = client.callTool(request); + assertThat(response.isError()).isFalse(); + assertThat(response.content()).hasSize(1); + 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(Boolean.toString(canTakeOffline)); + }); + // Verify that the node is now offline with the correct reason + assertThat(node.toComputer().isOffline()).isEqualTo(canTakeOffline); + if (canTakeOffline) { + assertThat(node.toComputer().getOfflineCauseReason()).isEqualTo("Maintenance"); + } + } + } + + static Stream takeOnlineParameters() { + Stream baseArgs = Stream.of( + Arguments.of("admin", true), + Arguments.of("connecter", true), + Arguments.of("disconnecter", true), + Arguments.of("reader", false) + ); + return TestUtils.appendMcpClientArgs(baseArgs); + } + + @ParameterizedTest + @MethodSource("takeOnlineParameters") + void testTakeAgentOnline(String user, boolean canTakeOnline, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) throws Exception { + Node node = jenkins.createOnlineSlave(); + node.setLabelString("test linux"); + node.toComputer().setTemporaryOfflineCause(new OfflineCause.UserCause(User.getById("admin", true), "Maintenance")); + enableSecurity(jenkins); + try (var client = jenkinsMcpClientBuilder + .jenkins(jenkins) + .requestCustomizer((builder, method, endpoint, body, context) -> { + String authString = user + ":" + user; + String encodedAuth = Base64.getEncoder().encodeToString(authString.getBytes()); + builder.setHeader("Authorization", "Basic " + encodedAuth); + }) + .build()) { + McpSchema.CallToolRequest request = + new McpSchema.CallToolRequest("takeAgentOnline", Map.of("name", node.getNodeName(), "reason", "Maintenance"), null); + var response = client.callTool(request); + assertThat(response.isError()).isFalse(); + assertThat(response.content()).hasSize(1); + 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(Boolean.toString(canTakeOnline)); + }); + // Verify that the node is now offline with the correct reason + assertThat(node.toComputer().isOnline()).isEqualTo(canTakeOnline); + if (!canTakeOnline) { + assertThat(node.toComputer().getOfflineCauseReason()).isEqualTo("Maintenance"); + } + } + } + + @McpClientTest + void testListAgentNames(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpClientBuilder) throws Exception { + jenkins.createOnlineSlave(); + jenkins.createOnlineSlave(); + jenkins.createOnlineSlave(); + enableSecurity(jenkins); + try (var client = jenkinsMcpClientBuilder + .jenkins(jenkins) + .requestCustomizer((builder, method, endpoint, body, context) -> { + String username = "admin"; + String password = "admin"; + String authString = username + ":" + password; + String encodedAuth = Base64.getEncoder().encodeToString(authString.getBytes()); + builder.setHeader("Authorization", "Basic " + encodedAuth); + }) + .build()) { + McpSchema.CallToolRequest request = + new McpSchema.CallToolRequest("listAgentNames", Map.of(), null); + var response = client.callTool(request); + assertThat(response.isError()).isFalse(); + assertThat(response.content()).hasSize(1); + assertThat(response.content().get(0).type()).isEqualTo("text"); + DocumentContext documentContext = JsonPath.using(Configuration.defaultConfiguration()) + .parse(((McpSchema.TextContent) response.content().get(0)).text()); + var contentList = documentContext.read("$.result", List.class); + assertThat(contentList).hasSize(3); + } + } + + + 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(Computer.CONNECT).everywhere().to("connecter"); + authStrategy.grant(Computer.DISCONNECT).everywhere().to("disconnecter"); + jenkins.jenkins.setAuthorizationStrategy(authStrategy); + jenkins.jenkins.save(); + } + +} From 9f384555e426302fd614ad39a813d394862bdfa9 Mon Sep 17 00:00:00 2001 From: Markus Winter Date: Mon, 3 Aug 2026 19:21:19 +0200 Subject: [PATCH 2/3] apply spotless --- .../mcp/server/extensions/AgentExtension.java | 22 +++----- .../server/extensions/AgentExtensionTest.java | 50 ++++++++++--------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java index 37a27ae..d379829 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java @@ -19,27 +19,22 @@ public class AgentExtension implements McpServerExtension { @Tool( description = "Get a list of all agent names, excluding the built-in node (master)", - annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false) - ) + annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false)) public List listAgentNames() { return Jenkins.get().getNodes().stream().map(Node::getNodeName).toList(); } @Tool( description = "Get a Jenkins agent by its name (the Computer object as the api does)", - annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false) - ) + annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false)) public Computer getAgent(@ToolParam(description = "Agent name") String name) { return Jenkins.get().getComputer(name); } - @Tool( - description = "Marks a Jenkins agent temporarily offline" - ) + @Tool(description = "Marks a Jenkins agent temporarily offline") public boolean takeAgentOffline( @ToolParam(description = "Agent name") String name, - @ToolParam(description = "Offline reason") String reason - ) { + @ToolParam(description = "Offline reason") String reason) { Computer computer = Jenkins.get().getComputer(name); if (computer == null) { return false; @@ -52,13 +47,8 @@ public boolean takeAgentOffline( return true; } - @Tool( - description = "Take a Jenkins agent online", - annotations = @Tool.Annotations(destructiveHint = false) - ) - public boolean takeAgentOnline( - @ToolParam(description = "Agent name") String name - ) { + @Tool(description = "Take a Jenkins agent online", annotations = @Tool.Annotations(destructiveHint = false)) + public boolean takeAgentOnline(@ToolParam(description = "Agent name") String name) { Computer computer = Jenkins.get().getComputer(name); if (computer == null) { return false; diff --git a/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java b/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java index 78deb0d..9a16664 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java @@ -29,16 +29,18 @@ public class AgentExtensionTest { static Stream getAgentParameters() { - Stream baseArgs = Stream.of( - Arguments.of(false, ""), - Arguments.of(true, "Maintenance") - ); + Stream baseArgs = Stream.of(Arguments.of(false, ""), Arguments.of(true, "Maintenance")); return TestUtils.appendMcpClientArgs(baseArgs); } @ParameterizedTest @MethodSource("getAgentParameters") - void testGetAgent(boolean takeOffline, String offlineReason, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) throws Exception { + void testGetAgent( + boolean takeOffline, + String offlineReason, + JenkinsMcpClientBuilder jenkinsMcpClientBuilder, + JenkinsRule jenkins) + throws Exception { Node node = jenkins.createOnlineSlave(); node.setLabelString("test linux"); enableSecurity(jenkins); @@ -76,19 +78,17 @@ void testGetAgent(boolean takeOffline, String offlineReason, JenkinsMcpClientBui } } - static Stream takeOfflineParameters() { Stream baseArgs = Stream.of( - Arguments.of("admin", true), - Arguments.of("connecter", false), - Arguments.of("disconnecter", true) - ); + Arguments.of("admin", true), Arguments.of("connecter", false), Arguments.of("disconnecter", true)); return TestUtils.appendMcpClientArgs(baseArgs); } @ParameterizedTest @MethodSource("takeOfflineParameters") - void testTakeAgentOffline(String user, boolean canTakeOffline, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) throws Exception { + void testTakeAgentOffline( + String user, boolean canTakeOffline, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) + throws Exception { Node node = jenkins.createOnlineSlave(); node.setLabelString("test linux"); enableSecurity(jenkins); @@ -100,8 +100,8 @@ void testTakeAgentOffline(String user, boolean canTakeOffline, JenkinsMcpClientB builder.setHeader("Authorization", "Basic " + encodedAuth); }) .build()) { - McpSchema.CallToolRequest request = - new McpSchema.CallToolRequest("takeAgentOffline", Map.of("name", node.getNodeName(), "reason", "Maintenance"), null); + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "takeAgentOffline", Map.of("name", node.getNodeName(), "reason", "Maintenance"), null); var response = client.callTool(request); assertThat(response.isError()).isFalse(); assertThat(response.content()).hasSize(1); @@ -123,17 +123,19 @@ static Stream takeOnlineParameters() { Arguments.of("admin", true), Arguments.of("connecter", true), Arguments.of("disconnecter", true), - Arguments.of("reader", false) - ); + Arguments.of("reader", false)); return TestUtils.appendMcpClientArgs(baseArgs); } @ParameterizedTest @MethodSource("takeOnlineParameters") - void testTakeAgentOnline(String user, boolean canTakeOnline, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) throws Exception { + void testTakeAgentOnline( + String user, boolean canTakeOnline, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) + throws Exception { Node node = jenkins.createOnlineSlave(); node.setLabelString("test linux"); - node.toComputer().setTemporaryOfflineCause(new OfflineCause.UserCause(User.getById("admin", true), "Maintenance")); + node.toComputer() + .setTemporaryOfflineCause(new OfflineCause.UserCause(User.getById("admin", true), "Maintenance")); enableSecurity(jenkins); try (var client = jenkinsMcpClientBuilder .jenkins(jenkins) @@ -143,8 +145,8 @@ void testTakeAgentOnline(String user, boolean canTakeOnline, JenkinsMcpClientBui builder.setHeader("Authorization", "Basic " + encodedAuth); }) .build()) { - McpSchema.CallToolRequest request = - new McpSchema.CallToolRequest("takeAgentOnline", Map.of("name", node.getNodeName(), "reason", "Maintenance"), null); + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "takeAgentOnline", Map.of("name", node.getNodeName(), "reason", "Maintenance"), null); var response = client.callTool(request); assertThat(response.isError()).isFalse(); assertThat(response.content()).hasSize(1); @@ -177,8 +179,7 @@ void testListAgentNames(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpC builder.setHeader("Authorization", "Basic " + encodedAuth); }) .build()) { - McpSchema.CallToolRequest request = - new McpSchema.CallToolRequest("listAgentNames", Map.of(), null); + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("listAgentNames", Map.of(), null); var response = client.callTool(request); assertThat(response.isError()).isFalse(); assertThat(response.content()).hasSize(1); @@ -190,16 +191,17 @@ void testListAgentNames(JenkinsRule jenkins, JenkinsMcpClientBuilder jenkinsMcpC } } - 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"); + var authStrategy = new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER) + .everywhere() + .to("admin"); authStrategy.grant(Jenkins.READ).everywhere().toEveryone(); authStrategy.grant(Computer.CONNECT).everywhere().to("connecter"); authStrategy.grant(Computer.DISCONNECT).everywhere().to("disconnecter"); jenkins.jenkins.setAuthorizationStrategy(authStrategy); jenkins.jenkins.save(); } - } From 08e7a96091386b6d9ba5248e08bd84c9cf4c3054 Mon Sep 17 00:00:00 2001 From: Markus Winter Date: Thu, 6 Aug 2026 20:47:14 +0200 Subject: [PATCH 3/3] use single tool to take agents offline or online --- README.md | 5 +++ .../mcp/server/extensions/AgentExtension.java | 33 ++++++++++--------- .../server/extensions/AgentExtensionTest.java | 29 ++++++++++++---- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0812466..840e982 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,11 @@ The plugin provides the following built-in tools for interacting with Jenkins: - `getBuildChangeSets`: Retrieve change log sets of a specific build. - `findJobsWithScmUrl`: Find jobs using a specific SCM (git) repository URL +#### Agent Management +- `getAgents`: Get a list of all Jenkins agents (nodes). +- `getAgent`: Get information about a specific Jenkins agent (node) by name. +- `agentStatus`: Take an agent online or offline. Provide the agent name and the desired status (online/offline). Optional `reason` parameter for offline reason. + #### Management Information - `whoAmI`: Get information about the current user. - `getStatus`: Checks the health and readiness status of a Jenkins instance. Use this tool to assess Jenkins instance health rather than simple up/down status. diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java index d379829..7d7932b 100644 --- a/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java +++ b/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java @@ -31,32 +31,35 @@ public Computer getAgent(@ToolParam(description = "Agent name") String name) { return Jenkins.get().getComputer(name); } - @Tool(description = "Marks a Jenkins agent temporarily offline") - public boolean takeAgentOffline( + @Tool(description = "Marks a Jenkins agent temporarily offline or takes it back online") + public boolean agentStatus( @ToolParam(description = "Agent name") String name, - @ToolParam(description = "Offline reason") String reason) { + @ToolParam( + description = + "Agent status, 'ONLINE' to take the agent online or 'OFFLINE' to take the agent offline") + AgentStatus status, + @ToolParam(description = "Offline reason when taking the agent offline", required = false) String reason) { Computer computer = Jenkins.get().getComputer(name); if (computer == null) { return false; } - if (!computer.hasPermission(Computer.DISCONNECT)) { + if (status == AgentStatus.OFFLINE && !computer.hasPermission(Computer.DISCONNECT)) { return false; } - OfflineCause.UserCause cause = new OfflineCause.UserCause(User.current(), Util.fixEmptyAndTrim(reason)); - computer.setTemporaryOfflineCause(cause); - return true; - } - - @Tool(description = "Take a Jenkins agent online", annotations = @Tool.Annotations(destructiveHint = false)) - public boolean takeAgentOnline(@ToolParam(description = "Agent name") String name) { - Computer computer = Jenkins.get().getComputer(name); - if (computer == null) { + if (status == AgentStatus.ONLINE && !computer.hasPermission(Computer.CONNECT)) { return false; } - if (!computer.hasPermission(Computer.CONNECT)) { - return false; + if (status == AgentStatus.OFFLINE) { + OfflineCause.UserCause cause = new OfflineCause.UserCause(User.current(), Util.fixEmptyAndTrim(reason)); + computer.setTemporaryOfflineCause(cause); + return true; } computer.setTemporaryOfflineCause(null); return true; } + + public enum AgentStatus { + ONLINE, + OFFLINE; + } } diff --git a/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java b/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java index 9a16664..00ee979 100644 --- a/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java @@ -14,6 +14,7 @@ import io.jenkins.plugins.mcp.server.junit.TestUtils; import io.modelcontextprotocol.spec.McpSchema; import java.util.Base64; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Stream; @@ -80,14 +81,21 @@ void testGetAgent( static Stream takeOfflineParameters() { Stream baseArgs = Stream.of( - Arguments.of("admin", true), Arguments.of("connecter", false), Arguments.of("disconnecter", true)); + Arguments.of("admin", true, "Maintenance"), + Arguments.of("connecter", false, "Maintenance"), + Arguments.of("connecter", false, null), + Arguments.of("disconnecter", true, "Maintenance")); return TestUtils.appendMcpClientArgs(baseArgs); } @ParameterizedTest @MethodSource("takeOfflineParameters") void testTakeAgentOffline( - String user, boolean canTakeOffline, JenkinsMcpClientBuilder jenkinsMcpClientBuilder, JenkinsRule jenkins) + String user, + boolean canTakeOffline, + String reason, + JenkinsMcpClientBuilder jenkinsMcpClientBuilder, + JenkinsRule jenkins) throws Exception { Node node = jenkins.createOnlineSlave(); node.setLabelString("test linux"); @@ -100,8 +108,13 @@ void testTakeAgentOffline( builder.setHeader("Authorization", "Basic " + encodedAuth); }) .build()) { - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( - "takeAgentOffline", Map.of("name", node.getNodeName(), "reason", "Maintenance"), null); + Map arguments = new HashMap<>(); + arguments.put("name", node.getNodeName()); + arguments.put("status", "OFFLINE"); + if (reason != null) { + arguments.put("reason", reason); + } + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("agentStatus", arguments, null); var response = client.callTool(request); assertThat(response.isError()).isFalse(); assertThat(response.content()).hasSize(1); @@ -113,7 +126,11 @@ void testTakeAgentOffline( // Verify that the node is now offline with the correct reason assertThat(node.toComputer().isOffline()).isEqualTo(canTakeOffline); if (canTakeOffline) { - assertThat(node.toComputer().getOfflineCauseReason()).isEqualTo("Maintenance"); + if (reason != null) { + assertThat(node.toComputer().getOfflineCauseReason()).isEqualTo(reason); + } else { + assertThat(node.toComputer().getOfflineCause()).isNull(); + } } } } @@ -146,7 +163,7 @@ void testTakeAgentOnline( }) .build()) { McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( - "takeAgentOnline", Map.of("name", node.getNodeName(), "reason", "Maintenance"), null); + "agentStatus", Map.of("name", node.getNodeName(), "status", "ONLINE"), null); var response = client.callTool(request); assertThat(response.isError()).isFalse(); assertThat(response.content()).hasSize(1);