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 new file mode 100644 index 0000000..7d7932b --- /dev/null +++ b/src/main/java/io/jenkins/plugins/mcp/server/extensions/AgentExtension.java @@ -0,0 +1,65 @@ +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 or takes it back online") + public boolean agentStatus( + @ToolParam(description = "Agent name") String name, + @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 (status == AgentStatus.OFFLINE && !computer.hasPermission(Computer.DISCONNECT)) { + return false; + } + if (status == AgentStatus.ONLINE && !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/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..00ee979 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/mcp/server/extensions/AgentExtensionTest.java @@ -0,0 +1,224 @@ +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.HashMap; +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, "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, + String reason, + 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()) { + 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); + 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) { + if (reason != null) { + assertThat(node.toComputer().getOfflineCauseReason()).isEqualTo(reason); + } else { + assertThat(node.toComputer().getOfflineCause()).isNull(); + } + } + } + } + + 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( + "agentStatus", Map.of("name", node.getNodeName(), "status", "ONLINE"), 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(); + } +}