From 0e2ca1d051effd8de4f2561989ba0ba637a08e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sun, 17 May 2026 09:40:50 +0200 Subject: [PATCH 01/29] feat(mcp): add MCP server module and sample application Adds spring-boot-admin-server-mcp, an optional module that exposes registered instances and health status via the Model Context Protocol using Spring AI 2.0.0-M6 with stateless Streamable HTTP transport. Three MCP tools are provided: - list-applications: lists all registered Spring Boot applications - get-health: fetches live health details from /actuator/health - get-status: returns the cached status from the registry Also adds spring-boot-admin-sample-mcp demonstrating Web UI and MCP server running together on the same port. # Conflicts: # pom.xml --- pom.xml | 2 + spring-boot-admin-dependencies/pom.xml | 5 + spring-boot-admin-samples/pom.xml | 1 + .../spring-boot-admin-sample-mcp/pom.xml | 100 +++++++++++ .../mcp/SpringBootAdminMcpApplication.java | 75 ++++++++ .../src/main/resources/application.yml | 32 ++++ .../SpringBootAdminMcpApplicationTest.java | 32 ++++ spring-boot-admin-server-mcp/pom.xml | 88 ++++++++++ .../mcp/config/McpAutoConfiguration.java | 65 +++++++ .../server/mcp/config/McpProperties.java | 63 +++++++ .../server/mcp/tools/ApplicationTools.java | 83 +++++++++ .../admin/server/mcp/tools/HealthTools.java | 141 +++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../mcp/tools/ApplicationToolsTest.java | 89 ++++++++++ .../server/mcp/tools/HealthToolsTest.java | 160 ++++++++++++++++++ 15 files changed, 937 insertions(+) create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml create mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/test/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplicationTest.java create mode 100644 spring-boot-admin-server-mcp/pom.xml create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java diff --git a/pom.xml b/pom.xml index ae536cfc7c4..38e8cab01ca 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ 4.1.0 2025.1.2 + 2.0.0-M6 2.6.0 @@ -99,6 +100,7 @@ spring-boot-admin-docs spring-boot-admin-starter-server spring-boot-admin-starter-client + spring-boot-admin-server-mcp spring-boot-admin-samples diff --git a/spring-boot-admin-dependencies/pom.xml b/spring-boot-admin-dependencies/pom.xml index 1d6b5973343..2f3a49259ce 100644 --- a/spring-boot-admin-dependencies/pom.xml +++ b/spring-boot-admin-dependencies/pom.xml @@ -60,6 +60,11 @@ spring-boot-admin-starter-server ${revision} + + de.codecentric + spring-boot-admin-server-mcp + ${revision} + diff --git a/spring-boot-admin-samples/pom.xml b/spring-boot-admin-samples/pom.xml index 1860153702f..4f2b47fdd07 100644 --- a/spring-boot-admin-samples/pom.xml +++ b/spring-boot-admin-samples/pom.xml @@ -39,6 +39,7 @@ spring-boot-admin-sample-reactive spring-boot-admin-sample-war spring-boot-admin-sample-hazelcast + spring-boot-admin-sample-mcp diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml new file mode 100644 index 00000000000..866886d534d --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml @@ -0,0 +1,100 @@ + + + + + 4.0.0 + + spring-boot-admin-sample-mcp + + Spring Boot Admin Sample MCP + Spring Boot Admin sample demonstrating the MCP server alongside the Web UI + + + de.codecentric + spring-boot-admin-samples + ${revision} + ../pom.xml + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + de.codecentric + spring-boot-admin-starter-server + + + + de.codecentric + spring-boot-admin-server-mcp + + + + de.codecentric + spring-boot-admin-starter-client + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-devtools + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + build-info + + + + + de.codecentric.boot.admin.sample.mcp.SpringBootAdminMcpApplication + false + + + + + diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java new file mode 100644 index 00000000000..e84fc53f1de --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java @@ -0,0 +1,75 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.sample.mcp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; + +import de.codecentric.boot.admin.server.config.EnableAdminServer; + +/** + * Spring Boot Admin sample application combining the Web UI and MCP server. + * + *

+ * Start with the {@code insecure} profile for local development (default): + *

+ * + *
+ * ./mvnw spring-boot:run -pl spring-boot-admin-samples/spring-boot-admin-sample-mcp
+ * 
+ * + *

+ * The MCP server is available at {@code http://localhost:8080/mcp} and the Web UI at + * {@code http://localhost:8080}. + *

+ * + *

+ * To connect Claude Desktop, add the following to your + * {@code claude_desktop_config.json}: + *

+ * + *
+ * {
+ *   "mcpServers": {
+ *     "spring-boot-admin": {
+ *       "url": "http://localhost:8080/mcp"
+ *     }
+ *   }
+ * }
+ * 
+ */ +@SpringBootApplication +@EnableAdminServer +public class SpringBootAdminMcpApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringBootAdminMcpApplication.class, args); + } + + @Bean + @Profile("insecure") + public SecurityWebFilterChain securityWebFilterChainPermitAll(ServerHttpSecurity http) { + return http.authorizeExchange((authorizeExchange) -> authorizeExchange.anyExchange().permitAll()) + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .build(); + } + +} diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml new file mode 100644 index 00000000000..df39b920f37 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml @@ -0,0 +1,32 @@ +logging: + file: + name: "target/boot-admin-sample-mcp.log" + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: ALWAYS + +spring: + application: + name: spring-boot-admin-sample-mcp + boot: + admin: + client: + url: http://localhost:8080 + mcp: + enabled: true + ai: + mcp: + server: + type: ASYNC + protocol: STATELESS + name: "Spring Boot Admin" + version: "1.0.0" + profiles: + active: + - insecure diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/test/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplicationTest.java b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/test/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplicationTest.java new file mode 100644 index 00000000000..8a4c5026276 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/test/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplicationTest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.sample.mcp; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@SpringBootTest(classes = { SpringBootAdminMcpApplication.class }) +class SpringBootAdminMcpApplicationTest { + + @Test + void contextLoads() { + } + +} diff --git a/spring-boot-admin-server-mcp/pom.xml b/spring-boot-admin-server-mcp/pom.xml new file mode 100644 index 00000000000..bc1616e7aba --- /dev/null +++ b/spring-boot-admin-server-mcp/pom.xml @@ -0,0 +1,88 @@ + + + + + 4.0.0 + + spring-boot-admin-server-mcp + + Spring Boot Admin Server MCP + Spring Boot Admin MCP Server — exposes registered instances and health status via the Model Context Protocol + + + de.codecentric + spring-boot-admin-build + ${revision} + ../spring-boot-admin-build + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + de.codecentric + spring-boot-admin-server + + + org.springframework.ai + spring-ai-starter-mcp-server-webflux + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.wiremock + wiremock-standalone + test + + + diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java new file mode 100644 index 00000000000..523f8de2a14 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java @@ -0,0 +1,65 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.config; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; +import de.codecentric.boot.admin.server.mcp.tools.HealthTools; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * Auto-configuration for the Spring Boot Admin MCP server integration. + * + *

+ * Activates only when {@code spring.boot.admin.mcp.enabled=true}. When inactive, no MCP + * beans are created and the application context is unaffected. + *

+ */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "spring.boot.admin.mcp", name = "enabled", havingValue = "true") +@EnableConfigurationProperties(McpProperties.class) +public class McpAutoConfiguration { + + /** + * Creates the {@link ApplicationTools} bean for listing registered applications. + * @param instanceRepository the repository used to look up registered instances + * @return the configured {@link ApplicationTools} + */ + @Bean + public ApplicationTools applicationTools(InstanceRepository instanceRepository) { + return new ApplicationTools(instanceRepository); + } + + /** + * Creates the {@link HealthTools} bean for querying application health and status. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link HealthTools} + */ + @Bean + public HealthTools healthTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new HealthTools(instanceRepository, instanceWebClientBuilder.build()); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java new file mode 100644 index 00000000000..d4468bbda21 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java @@ -0,0 +1,63 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for the Spring Boot Admin MCP server integration. + * + *

+ * MCP support is disabled by default. To enable it, set + * {@code spring.boot.admin.mcp.enabled=true} in your application configuration. + *

+ * + *

+ * Example configuration: + *

+ * + *
+ * spring:
+ *   boot:
+ *     admin:
+ *       mcp:
+ *         enabled: true
+ *   ai:
+ *     mcp:
+ *       server:
+ *         type: ASYNC
+ *         protocol: STREAMABLE
+ * 
+ */ +@lombok.Data +@ConfigurationProperties(prefix = "spring.boot.admin.mcp") +public class McpProperties { + + /** + * Creates a new {@code McpProperties} instance with default values. + */ + public McpProperties() { + // NOOP + } + + /** + * Whether the MCP server integration is enabled. Defaults to {@code false} to ensure + * zero impact on existing Spring Boot Admin deployments. + */ + private boolean enabled = false; + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java new file mode 100644 index 00000000000..3fc4c0c38de --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java @@ -0,0 +1,83 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; + +/** + * MCP tools for querying registered Spring Boot applications. + * + *

+ * Exposes the {@code list-applications} tool which returns a plain-text summary of all + * instances currently registered with Spring Boot Admin. + *

+ */ +public class ApplicationTools { + + private static final Logger log = LoggerFactory.getLogger(ApplicationTools.class); + + private final InstanceRepository instanceRepository; + + /** + * Creates a new {@code ApplicationTools} instance. + * @param instanceRepository the repository used to look up registered instances + */ + public ApplicationTools(InstanceRepository instanceRepository) { + this.instanceRepository = instanceRepository; + } + + /** + * Lists all Spring Boot applications currently registered with Spring Boot Admin, + * including their name, status, and management URL. + * @return a plain-text list of registered applications, or a message indicating none + * are registered + */ + @McpTool(name = "list-applications", + description = "List all Spring Boot applications currently registered with Spring Boot Admin. " + + "Returns name, status (UP/DOWN/UNKNOWN) and management URL for each instance.") + public Mono listApplications() { + return this.instanceRepository.findAll().collectList().map((instances) -> { + if (instances.isEmpty()) { + return "No applications are currently registered."; + } + StringBuilder sb = new StringBuilder("Registered applications (").append(instances.size()).append("):\n"); + for (Instance instance : instances) { + String name = instance.getRegistration().getName(); + String status = instance.getStatusInfo().getStatus(); + String managementUrl = (instance.getRegistration().getManagementUrl() != null) + ? instance.getRegistration().getManagementUrl() : "N/A"; + sb.append("- ") + .append(name) + .append(" | status: ") + .append(status) + .append(" | management: ") + .append(managementUrl) + .append("\n"); + } + return sb.toString().trim(); + }) + .doOnError((ex) -> log.warn("Failed to list applications", ex)) + .onErrorReturn("Error retrieving registered applications."); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java new file mode 100644 index 00000000000..5a2a8fde5ed --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java @@ -0,0 +1,141 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.Endpoint; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for querying the health of registered Spring Boot applications. + * + *

+ * Exposes two tools: + *

    + *
  • {@code get-health} — fetches full health details from the application's + * {@code /actuator/health} endpoint
  • + *
  • {@code get-status} — returns the cached status (UP/DOWN/UNKNOWN) from the registry + * without an actuator call
  • + *
+ */ +public class HealthTools { + + private static final Logger log = LoggerFactory.getLogger(HealthTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final ParameterizedTypeReference> HEALTH_RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code HealthTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public HealthTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Fetches the full health details for the named application by calling its + * {@code /actuator/health} endpoint. Returns a plain-text summary including the + * overall status and individual health component statuses. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text health summary, or an error message if the app is not found or + * the actuator call fails + */ + @McpTool(name = "get-health", + description = "Fetch health details for a registered Spring Boot application by calling its " + + "/actuator/health endpoint. Returns overall status and per-component breakdown.") + public Mono getHealth(@McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.instanceRepository.findByName(applicationName) + .next() + .flatMap((instance) -> this.instanceWebClient.instance(instance) + .get() + .uri(Endpoint.HEALTH) + .retrieve() + .bodyToMono(HEALTH_RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatHealthResponse(applicationName, body)) + .doOnError((ex) -> log.warn("Failed to get health for {}", applicationName, ex)) + .onErrorResume( + (ex) -> Mono.just("Error retrieving health for " + applicationName + ": " + ex.getMessage()))) + .switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + /** + * Returns the cached status for the named application from the registry without + * making an actuator call. This is a fast, lightweight alternative to + * {@code get-health}. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text status line, or an error message if the app is not found + */ + @McpTool(name = "get-status", + description = "Return the cached health status (UP/DOWN/UNKNOWN/etc.) for a registered " + + "Spring Boot application from the registry — no actuator call is made. " + + "Use get-health for full component details.") + public Mono getStatus(@McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.instanceRepository.findByName(applicationName) + .next() + .map((instance) -> applicationName + " status: " + instance.getStatusInfo().getStatus()) + .switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")) + .doOnError((ex) -> log.warn("Failed to get status for {}", applicationName, ex)) + .onErrorReturn("Error retrieving status for " + applicationName + "."); + } + + @SuppressWarnings("unchecked") + private String formatHealthResponse(String applicationName, Map body) { + StringBuilder sb = new StringBuilder(); + Object status = body.get("status"); + sb.append(applicationName).append(" health: ").append((status != null) ? status : "UNKNOWN").append("\n"); + + Object components = body.get("components"); + if (components instanceof Map componentMap) { + for (Map.Entry entry : componentMap.entrySet()) { + String componentName = String.valueOf(entry.getKey()); + Object componentValue = entry.getValue(); + if (componentValue instanceof Map componentDetails) { + Object componentStatus = componentDetails.get("status"); + sb.append(" ") + .append(componentName) + .append(": ") + .append((componentStatus != null) ? componentStatus : "UNKNOWN") + .append("\n"); + } + } + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..f95cdc240f0 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +de.codecentric.boot.admin.server.mcp.config.McpAutoConfiguration diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java new file mode 100644 index 00000000000..f16b071a86c --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ApplicationToolsTest { + + private InstanceRepository instanceRepository; + + private ApplicationTools applicationTools; + + @BeforeEach + void setUp() { + this.instanceRepository = mock(InstanceRepository.class); + this.applicationTools = new ApplicationTools(this.instanceRepository); + } + + @Test + void listApplications_withRegisteredApps_returnsFormattedList() { + Instance instance1 = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", "http://payment/actuator/health") + .managementUrl("http://payment/actuator") + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + Instance instance2 = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("order-service", "http://order/actuator/health") + .managementUrl("http://order/actuator") + .build()) + .withStatusInfo(StatusInfo.ofDown()); + + when(this.instanceRepository.findAll()).thenReturn(Flux.just(instance1, instance2)); + + StepVerifier.create(this.applicationTools.listApplications()).assertNext((result) -> { + assertThat(result).contains("Registered applications (2)"); + assertThat(result).contains("payment-service"); + assertThat(result).contains("UP"); + assertThat(result).contains("order-service"); + assertThat(result).contains("DOWN"); + }).verifyComplete(); + } + + @Test + void listApplications_withNoApps_returnsNoAppsMessage() { + when(this.instanceRepository.findAll()).thenReturn(Flux.empty()); + + StepVerifier.create(this.applicationTools.listApplications()) + .assertNext((result) -> assertThat(result).isEqualTo("No applications are currently registered.")) + .verifyComplete(); + } + + @Test + void listApplications_onRepositoryError_returnsErrorMessage() { + when(this.instanceRepository.findAll()).thenReturn(Flux.error(new RuntimeException("connection failed"))); + + StepVerifier.create(this.applicationTools.listApplications()) + .assertNext((result) -> assertThat(result).isEqualTo("Error retrieving registered applications.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java new file mode 100644 index 00000000000..abfc0d40d72 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java @@ -0,0 +1,160 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.Endpoint; +import de.codecentric.boot.admin.server.domain.values.Endpoints; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class HealthToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private HealthTools healthTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.healthTools = new HealthTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + @Test + void getHealth_appFoundAndActuatorReachable_returnsHealthSummary() { + Instance instance = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")).build()) + .withEndpoints(Endpoints.single(Endpoint.HEALTH, this.wireMock.url("/actuator/health"))) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + String healthBody = "{\"status\":\"UP\",\"components\":{\"db\":{\"status\":\"UP\"},\"diskSpace\":{\"status\":\"UP\"}}}"; + this.wireMock.stubFor(get(urlEqualTo("/actuator/health")).willReturn(okJson(healthBody))); + + StepVerifier.create(this.healthTools.getHealth("payment-service")).assertNext((result) -> { + assertThat(result).contains("payment-service health: UP"); + assertThat(result).contains("db: UP"); + assertThat(result).contains("diskSpace: UP"); + }).verifyComplete(); + } + + @Test + void getHealth_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("unknown")).thenReturn(Flux.empty()); + + StepVerifier.create(this.healthTools.getHealth("unknown")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'unknown' not found in registry.")) + .verifyComplete(); + } + + @Test + void getHealth_actuatorTimeout_returnsErrorMessage() { + Instance instance = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("slow-service", this.wireMock.url("/actuator/health")).build()) + .withEndpoints(Endpoints.single(Endpoint.HEALTH, this.wireMock.url("/actuator/health"))) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("slow-service")).thenReturn(Flux.just(instance)); + + this.wireMock + .stubFor(get(urlEqualTo("/actuator/health")).willReturn(aResponse().withFixedDelay(600).withBody("{}"))); + + StepVerifier.create(this.healthTools.getHealth("slow-service")) + .assertNext((result) -> assertThat(result).contains("Error retrieving health for slow-service")) + .verifyComplete(); + } + + @Test + void getStatus_appFound_returnsStatusLine() { + Instance instance = Instance.create(InstanceId.of("id-3")) + .register(Registration.create("order-service", "http://localhost/health").build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + StepVerifier.create(this.healthTools.getStatus("order-service")) + .assertNext((result) -> assertThat(result).isEqualTo("order-service status: UP")) + .verifyComplete(); + } + + @Test + void getStatus_appDown_returnsDownStatus() { + Instance instance = Instance.create(InstanceId.of("id-4")) + .register(Registration.create("checkout-service", "http://localhost/health").build()) + .withStatusInfo(StatusInfo.ofDown()); + + when(this.instanceRepository.findByName("checkout-service")).thenReturn(Flux.just(instance)); + + StepVerifier.create(this.healthTools.getStatus("checkout-service")) + .assertNext((result) -> assertThat(result).isEqualTo("checkout-service status: DOWN")) + .verifyComplete(); + } + + @Test + void getStatus_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.healthTools.getStatus("ghost")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} From 63cb15c8bfeab40c8ed9dbdcb4228f518eea2ec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sun, 17 May 2026 10:10:34 +0200 Subject: [PATCH 02/29] feat(mcp): add metrics, logs and operations MCP tools Completes MVP tool coverage with five additional tools: - list-metrics: lists all available metric names from /actuator/metrics - get-metrics: fetches a specific metric value with unit - get-logs: returns last N lines from /actuator/logfile (default 50, max 500) - restart-application: POSTs to /actuator/restart - refresh-configuration: POSTs to /actuator/refresh, reports changed keys --- .../mcp/config/McpAutoConfiguration.java | 43 +++++ .../admin/server/mcp/tools/LogsTools.java | 108 ++++++++++++ .../admin/server/mcp/tools/MetricsTools.java | 155 ++++++++++++++++++ .../server/mcp/tools/OperationsTools.java | 133 +++++++++++++++ .../admin/server/mcp/tools/LogsToolsTest.java | 152 +++++++++++++++++ .../server/mcp/tools/MetricsToolsTest.java | 137 ++++++++++++++++ .../server/mcp/tools/OperationsToolsTest.java | 141 ++++++++++++++++ 7 files changed, 869 insertions(+) create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java index 523f8de2a14..31715948788 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java @@ -24,6 +24,9 @@ import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; import de.codecentric.boot.admin.server.mcp.tools.HealthTools; +import de.codecentric.boot.admin.server.mcp.tools.LogsTools; +import de.codecentric.boot.admin.server.mcp.tools.MetricsTools; +import de.codecentric.boot.admin.server.mcp.tools.OperationsTools; import de.codecentric.boot.admin.server.web.client.InstanceWebClient; /** @@ -62,4 +65,44 @@ public HealthTools healthTools(InstanceRepository instanceRepository, return new HealthTools(instanceRepository, instanceWebClientBuilder.build()); } + /** + * Creates the {@link MetricsTools} bean for querying application metrics. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link MetricsTools} + */ + @Bean + public MetricsTools metricsTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new MetricsTools(instanceRepository, instanceWebClientBuilder.build()); + } + + /** + * Creates the {@link LogsTools} bean for accessing application log output. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link LogsTools} + */ + @Bean + public LogsTools logsTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new LogsTools(instanceRepository, instanceWebClientBuilder.build()); + } + + /** + * Creates the {@link OperationsTools} bean for performing write operations on + * applications. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link OperationsTools} + */ + @Bean + public OperationsTools operationsTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new OperationsTools(instanceRepository, instanceWebClientBuilder.build()); + } + } diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java new file mode 100644 index 00000000000..be50a14fc25 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java @@ -0,0 +1,108 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.Arrays; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for accessing logs of registered Spring Boot applications. + * + *
    + *
  • {@code get-logs} — fetches the last N lines from the application's logfile
  • + *
+ */ +public class LogsTools { + + private static final Logger log = LoggerFactory.getLogger(LogsTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final int DEFAULT_LINES = 50; + + private static final int MAX_LINES = 500; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code LogsTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public LogsTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Fetches the last N lines from the logfile of the named application by calling its + * {@code /actuator/logfile} endpoint. + * @param applicationName the registered application name (case-insensitive) + * @param lines number of lines to return from the end of the log (default 50, max + * 500) + * @return plain-text log tail, or an error message + */ + @McpTool(name = "get-logs", + description = "Fetch the last N lines from the logfile of a registered Spring Boot application. " + + "Requires logging.file.name or logging.file.path to be configured in the application. " + + "Default is 50 lines, maximum is 500.") + public Mono getLogs( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "Number of lines to return from the end of the log (default 50, max 500)", + required = false) Integer lines) { + int lineCount = (lines != null) ? Math.min(Math.max(lines, 1), MAX_LINES) : DEFAULT_LINES; + + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/logfile"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(String.class) + .defaultIfEmpty("") + .timeout(TIMEOUT) + .map((body) -> formatLogTail(applicationName, body, lineCount)) + .doOnError((ex) -> log.warn("Failed to get logs for {}", applicationName, ex)) + .onErrorResume( + (ex) -> Mono.just("Error retrieving logs for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + private String formatLogTail(String applicationName, String body, int lineCount) { + if (body == null || body.isBlank()) { + return "No log content available for " + applicationName + "."; + } + String[] allLines = body.split("\n"); + int from = Math.max(0, allLines.length - lineCount); + String tail = Arrays.stream(allLines, from, allLines.length).collect(Collectors.joining("\n")); + return "Last " + Math.min(lineCount, allLines.length) + " lines of " + applicationName + " log:\n" + tail; + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java new file mode 100644 index 00000000000..bfa7e133b56 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java @@ -0,0 +1,155 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for querying metrics of registered Spring Boot applications. + * + *
    + *
  • {@code list-metrics} — lists all available metric names for an application
  • + *
  • {@code get-metrics} — fetches the value of a specific metric
  • + *
+ */ +public class MetricsTools { + + private static final Logger log = LoggerFactory.getLogger(MetricsTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code MetricsTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public MetricsTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Lists all available metric names for the named application by calling its + * {@code /actuator/metrics} endpoint. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text list of metric names, or an error message + */ + @McpTool(name = "list-metrics", + description = "List all available metric names for a registered Spring Boot application. " + + "Use the returned names with get-metrics to fetch specific values.") + public Mono listMetrics(@McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/metrics"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatMetricNames(applicationName, body)) + .doOnError((ex) -> log.warn("Failed to list metrics for {}", applicationName, ex)) + .onErrorResume( + (ex) -> Mono.just("Error listing metrics for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + /** + * Fetches the value of a specific metric for the named application by calling its + * {@code /actuator/metrics/{metricName}} endpoint. + * @param applicationName the registered application name (case-insensitive) + * @param metricName the metric name (e.g. {@code jvm.memory.used}) + * @return plain-text metric value with unit, or an error message + */ + @McpTool(name = "get-metrics", + description = "Fetch the current value of a specific metric for a registered Spring Boot application. " + + "Common metrics: jvm.memory.used, jvm.memory.max, system.cpu.usage, " + + "http.server.requests, jvm.threads.live. Use list-metrics to discover available names.") + public Mono getMetrics( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "The metric name (e.g. jvm.memory.used)", required = true) String metricName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/metrics/" + metricName; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatMetricValue(applicationName, metricName, body)) + .doOnError((ex) -> log.warn("Failed to get metric {} for {}", metricName, applicationName, ex)) + .onErrorResume((ex) -> Mono.just("Error retrieving metric '" + metricName + "' for " + applicationName + + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + @SuppressWarnings("unchecked") + private String formatMetricNames(String applicationName, Map body) { + Object names = body.get("names"); + if (!(names instanceof List nameList) || nameList.isEmpty()) { + return "No metrics available for " + applicationName + "."; + } + StringBuilder sb = new StringBuilder("Available metrics for ").append(applicationName) + .append(" (") + .append(nameList.size()) + .append("):\n"); + nameList.forEach((name) -> sb.append("- ").append(name).append("\n")); + return sb.toString().trim(); + } + + @SuppressWarnings("unchecked") + private String formatMetricValue(String applicationName, String metricName, Map body) { + StringBuilder sb = new StringBuilder(applicationName).append(" — ").append(metricName).append(":\n"); + Object measurements = body.get("measurements"); + if (measurements instanceof List list) { + for (Object item : list) { + if (item instanceof Map m) { + Object statistic = m.get("statistic"); + Object value = m.get("value"); + sb.append(" ").append(statistic).append(": ").append(value); + Object baseUnit = body.get("baseUnit"); + if (baseUnit != null) { + sb.append(" ").append(baseUnit); + } + sb.append("\n"); + } + } + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java new file mode 100644 index 00000000000..493260f433a --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java @@ -0,0 +1,133 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.http.MediaType; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for performing write operations on registered Spring Boot applications. + * + *
    + *
  • {@code restart-application} — restarts the application via + * {@code /actuator/restart} or {@code /actuator/shutdown}
  • + *
  • {@code refresh-configuration} — refreshes the application configuration via + * {@code /actuator/refresh}
  • + *
+ */ +public class OperationsTools { + + private static final Logger log = LoggerFactory.getLogger(OperationsTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code OperationsTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public OperationsTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Restarts the named application by calling its {@code /actuator/restart} endpoint. + * Falls back to {@code /actuator/shutdown} if restart is not available. Requires the + * actuator restart or shutdown endpoint to be enabled and exposed in the monitored + * application. + * @param applicationName the registered application name (case-insensitive) + * @return confirmation message or an error message + */ + @McpTool(name = "restart-application", + description = "Restart a registered Spring Boot application via its /actuator/restart endpoint. " + + "Requires management.endpoint.restart.enabled=true in the monitored application.") + public Mono restartApplication( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/restart"; + return this.instanceWebClient.instance(instance) + .post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .retrieve() + .toBodilessEntity() + .timeout(TIMEOUT) + .map((response) -> { + int status = response.getStatusCode().value(); + if ((status >= 200) && (status < 300)) { + return applicationName + " restart initiated successfully."; + } + return "Restart returned unexpected status " + status + " for " + applicationName + "."; + }) + .doOnError((ex) -> log.warn("Failed to restart {}", applicationName, ex)) + .onErrorResume((ex) -> Mono.just("Error restarting " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + /** + * Refreshes the configuration of the named application by calling its + * {@code /actuator/refresh} endpoint. Requires Spring Cloud Context on the monitored + * application's classpath. + * @param applicationName the registered application name (case-insensitive) + * @return confirmation message listing refreshed keys, or an error message + */ + @McpTool(name = "refresh-configuration", + description = "Refresh the configuration of a registered Spring Boot application via its " + + "/actuator/refresh endpoint. Requires Spring Cloud Context (spring-cloud-starter) " + + "on the monitored application's classpath.") + public Mono refreshConfiguration( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/refresh"; + return this.instanceWebClient.instance(instance) + .post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(String.class) + .timeout(TIMEOUT) + .map((body) -> formatRefreshResponse(applicationName, body)) + .doOnError((ex) -> log.warn("Failed to refresh configuration for {}", applicationName, ex)) + .onErrorResume((ex) -> Mono + .just("Error refreshing configuration for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + private String formatRefreshResponse(String applicationName, String body) { + if ((body == null) || body.isBlank() || "[]".equals(body.trim())) { + return "Configuration refreshed for " + applicationName + ". No properties changed."; + } + return "Configuration refreshed for " + applicationName + ". Changed keys: " + body; + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java new file mode 100644 index 00000000000..610009f2219 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java @@ -0,0 +1,152 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LogsToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private LogsTools logsTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.logsTools = new LogsTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + @Test + void getLogs_appFound_returnsLastNLines() { + Instance instance = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + String logContent = "line1\nline2\nline3\nline4\nline5"; + this.wireMock.stubFor(get(urlEqualTo("/actuator/logfile")) + .willReturn(aResponse().withStatus(200).withBody(logContent).withHeader("Content-Type", "text/plain"))); + + StepVerifier.create(this.logsTools.getLogs("payment-service", 3)).assertNext((result) -> { + assertThat(result).contains("payment-service"); + assertThat(result).contains("line3"); + assertThat(result).contains("line4"); + assertThat(result).contains("line5"); + assertThat(result).doesNotContain("line1"); + assertThat(result).doesNotContain("line2"); + }).verifyComplete(); + } + + @Test + void getLogs_emptyLogfile_returnsNoContentMessage() { + Instance instance = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("order-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/logfile")) + .willReturn(aResponse().withStatus(200).withBody("").withHeader("Content-Type", "text/plain"))); + + StepVerifier.create(this.logsTools.getLogs("order-service", null)) + .assertNext((result) -> assertThat(result).contains("No log content available")) + .verifyComplete(); + } + + @Test + void getLogs_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.logsTools.getLogs("ghost", null)) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + + @Test + void getLogs_defaultLines_returns50Lines() { + Instance instance = Instance.create(InstanceId.of("id-3")) + .register(Registration.create("checkout-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("checkout-service")).thenReturn(Flux.just(instance)); + + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= 100; i++) { + sb.append("line").append(i).append("\n"); + } + this.wireMock.stubFor(get(urlEqualTo("/actuator/logfile")) + .willReturn(aResponse().withStatus(200).withBody(sb.toString()).withHeader("Content-Type", "text/plain"))); + + StepVerifier.create(this.logsTools.getLogs("checkout-service", null)).assertNext((result) -> { + assertThat(result).contains("Last 50 lines"); + assertThat(result).contains("line100"); + assertThat(result).doesNotContain("line50\n").contains("line51"); + }).verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java new file mode 100644 index 00000000000..f05451f2743 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MetricsToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private MetricsTools metricsTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.metricsTools = new MetricsTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + @Test + void listMetrics_appFound_returnsMetricNames() { + Instance instance = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/metrics")) + .willReturn(okJson("{\"names\":[\"jvm.memory.used\",\"jvm.memory.max\",\"system.cpu.usage\"]}"))); + + StepVerifier.create(this.metricsTools.listMetrics("payment-service")).assertNext((result) -> { + assertThat(result).contains("Available metrics for payment-service"); + assertThat(result).contains("jvm.memory.used"); + assertThat(result).contains("system.cpu.usage"); + }).verifyComplete(); + } + + @Test + void listMetrics_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("unknown")).thenReturn(Flux.empty()); + + StepVerifier.create(this.metricsTools.listMetrics("unknown")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'unknown' not found in registry.")) + .verifyComplete(); + } + + @Test + void getMetrics_appFound_returnsMetricValue() { + Instance instance = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("order-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/metrics/jvm.memory.used")) + .willReturn(okJson("{\"name\":\"jvm.memory.used\",\"baseUnit\":\"bytes\"," + + "\"measurements\":[{\"statistic\":\"VALUE\",\"value\":1234567890}]}"))); + + StepVerifier.create(this.metricsTools.getMetrics("order-service", "jvm.memory.used")).assertNext((result) -> { + assertThat(result).contains("order-service"); + assertThat(result).contains("jvm.memory.used"); + assertThat(result).contains("VALUE"); + assertThat(result).contains("bytes"); + }).verifyComplete(); + } + + @Test + void getMetrics_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.metricsTools.getMetrics("ghost", "jvm.memory.used")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java new file mode 100644 index 00000000000..b1f01294a1d --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java @@ -0,0 +1,141 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.noContent; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OperationsToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private OperationsTools operationsTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.operationsTools = new OperationsTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + private Instance instance(String name) { + return Instance.create(InstanceId.of("id-" + name)) + .register(Registration.create(name, this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + } + + @Test + void restartApplication_success_returnsConfirmation() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(post(urlEqualTo("/actuator/restart")).willReturn(noContent())); + + StepVerifier.create(this.operationsTools.restartApplication("payment-service")) + .assertNext((result) -> assertThat(result).contains("payment-service restart initiated successfully")) + .verifyComplete(); + } + + @Test + void restartApplication_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.operationsTools.restartApplication("ghost")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + + @Test + void refreshConfiguration_withChanges_returnsChangedKeys() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(post(urlEqualTo("/actuator/refresh")) + .willReturn(okJson("[\"spring.datasource.url\",\"app.timeout\"]"))); + + StepVerifier.create(this.operationsTools.refreshConfiguration("order-service")).assertNext((result) -> { + assertThat(result).contains("Configuration refreshed for order-service"); + assertThat(result).contains("spring.datasource.url"); + }).verifyComplete(); + } + + @Test + void refreshConfiguration_noChanges_returnsNoChangesMessage() { + when(this.instanceRepository.findByName("checkout-service")) + .thenReturn(Flux.just(instance("checkout-service"))); + + this.wireMock.stubFor(post(urlEqualTo("/actuator/refresh")).willReturn(okJson("[]"))); + + StepVerifier.create(this.operationsTools.refreshConfiguration("checkout-service")).assertNext((result) -> { + assertThat(result).contains("Configuration refreshed for checkout-service"); + assertThat(result).contains("No properties changed"); + }).verifyComplete(); + } + + @Test + void refreshConfiguration_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.operationsTools.refreshConfiguration("ghost")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} From faf6108e5daee3a9fab4f8ffab15983c37fd2cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sun, 17 May 2026 10:20:53 +0200 Subject: [PATCH 03/29] docs(mcp): add MCP integration documentation page Covers setup, all 8 tools, configuration reference, security, and connection examples for OpenCode, Claude Desktop, Claude Code, and Cursor. --- .../src/site/docs/07-mcp/_category_.json | 4 + .../src/site/docs/07-mcp/index.md | 324 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json create mode 100644 spring-boot-admin-docs/src/site/docs/07-mcp/index.md diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json b/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json new file mode 100644 index 00000000000..14dd1c3b9df --- /dev/null +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json @@ -0,0 +1,4 @@ +{ + "position": 7, + "label": "MCP Integration" +} diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md new file mode 100644 index 00000000000..7317bd8ae5e --- /dev/null +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -0,0 +1,324 @@ +--- +sidebar_position: 7 +sidebar_custom_props: + icon: 'cpu' +--- + +# MCP Integration + +Spring Boot Admin can act as a **Model Context Protocol (MCP) server**, exposing your registered applications to AI +assistants. This lets you monitor and manage your Spring Boot applications conversationally — without leaving your chat +interface. + +``` +"What's the heap usage of payment-service?" +"Are there errors in the checkout-service logs?" +"Restart order-service" +"Refresh the configuration for inventory-service" +``` + +## How It Works + +```mermaid +flowchart LR + AI[AI Assistant\nOpenCode / Claude / ChatGPT] + SBA[Spring Boot Admin\nMCP Server] + Apps[Spring Boot\nApplications] + + AI -->|MCP tools| SBA + SBA -->|/actuator/*| Apps +``` + +Spring Boot Admin acts as a bridge: the AI assistant calls MCP tools, which Spring Boot Admin translates into actuator +calls against your registered applications. Responses are formatted as plain text for natural display in the chat. + +## Available Tools + +| Tool | Description | +|---|---| +| `list-applications` | Lists all registered applications with status and management URL | +| `get-status` | Returns the cached status (UP/DOWN/UNKNOWN) for a named application | +| `get-health` | Fetches full health details from `/actuator/health` | +| `list-metrics` | Lists all available metric names for a named application | +| `get-metrics` | Fetches the current value of a specific metric | +| `get-logs` | Returns the last N lines from `/actuator/logfile` | +| `restart-application` | Restarts an application via `/actuator/restart` | +| `refresh-configuration` | Refreshes configuration via `/actuator/refresh` | + +## Quick Start + +### 1. Add the MCP module + +Add `spring-boot-admin-server-mcp` alongside your existing Spring Boot Admin server dependency: + +```xml title="pom.xml" + + de.codecentric + spring-boot-admin-server-mcp + +``` + +:::tip +The MCP module is independent — add it alongside `spring-boot-admin-starter-server` to get both the Web UI and MCP +server on the same port. +::: + +### 2. Enable MCP in your configuration + +```yaml title="application.yml" +spring: + boot: + admin: + mcp: + enabled: true + ai: + mcp: + server: + type: ASYNC + protocol: STATELESS + name: "Spring Boot Admin" + version: "1.0.0" +``` + +:::note +`spring.boot.admin.mcp.enabled` defaults to `false`. Existing deployments are unaffected until you opt in. +::: + +### 3. Connect your AI assistant + +Once the server is running, point your AI tool at the MCP endpoint: + +``` +http://localhost:8080/mcp +``` + +See the [AI Tool Configuration](#ai-tool-configuration) section below for tool-specific instructions. + +## AI Tool Configuration + +### OpenCode + +Add to your `opencode.json` (global at `~/.config/opencode/opencode.json` or per-project): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "spring-boot-admin": { + "type": "remote", + "url": "http://localhost:8080/mcp", + "enabled": true + } + } +} +``` + +Verify the connection: + +```bash +opencode mcp list +``` + +You should see `spring-boot-admin connected`. Then ask naturally in any session: + +``` +list all registered spring boot applications +what is the health of payment-service? +get the last 100 log lines for order-service +restart checkout-service +``` + +### Claude Desktop + +Add to your Claude Desktop configuration file: + +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +```json title="claude_desktop_config.json" +{ + "mcpServers": { + "spring-boot-admin": { + "type": "streamableHttp", + "url": "http://localhost:8080/mcp" + } + } +} +``` + +Restart Claude Desktop after saving. The Spring Boot Admin tools will appear in the tool list. + +### Claude Code (claude.ai/code) + +Add to your project or global MCP configuration: + +```json title=".mcp.json" +{ + "mcpServers": { + "spring-boot-admin": { + "type": "streamableHttp", + "url": "http://localhost:8080/mcp" + } + } +} +``` + +### Cursor + +Add to your Cursor MCP settings (`~/.cursor/mcp.json`): + +```json title="~/.cursor/mcp.json" +{ + "mcpServers": { + "spring-boot-admin": { + "url": "http://localhost:8080/mcp", + "type": "streamableHttp" + } + } +} +``` + +### Generic MCP Client + +Any MCP client that supports **Stateless Streamable HTTP** transport can connect: + +- **Endpoint**: `POST http://localhost:8080/mcp` +- **Transport**: Stateless Streamable HTTP (MCP spec 2025-03-26) +- **Auth**: none by default (see [Security](#security)) + +## Example Conversations + +### Fleet status check + +``` +You: list all registered applications +Assistant: Registered applications (4): +- payment-service | status: UP | management: http://payment:8080/actuator +- order-service | status: UP | management: http://order:8080/actuator +- checkout-service | status: DOWN | management: http://checkout:8080/actuator +- inventory-service | status: UP | management: http://inventory:8080/actuator +``` + +### Debugging a failing service + +``` +You: what is the health of checkout-service? +Assistant: checkout-service health: DOWN + db: DOWN + diskSpace: UP + +You: get the last 50 log lines for checkout-service +Assistant: Last 50 lines of checkout-service log: + ... + ERROR 2026-05-17 09:12:43 - Connection refused: jdbc:postgresql://db:5432/checkout + +You: restart checkout-service +Assistant: checkout-service restart initiated successfully. +``` + +### Metrics investigation + +``` +You: list metrics for payment-service +Assistant: Available metrics for payment-service (42): +- jvm.memory.used +- jvm.memory.max +- system.cpu.usage +- http.server.requests +... + +You: get the jvm.memory.used metric for payment-service +Assistant: payment-service — jvm.memory.used: + VALUE: 1258291200 bytes +``` + +### Configuration refresh + +``` +You: refresh the configuration for inventory-service +Assistant: Configuration refreshed for inventory-service. Changed keys: ["app.feature.newFlow","app.cache.ttl"] +``` + +## Requirements in Monitored Applications + +Certain tools require additional setup in the monitored applications: + +| Tool | Requirement | +|---|---| +| `get-logs` | `logging.file.name` or `logging.file.path` configured; `logfile` actuator endpoint exposed | +| `restart-application` | `management.endpoint.restart.enabled=true`; restart actuator endpoint exposed | +| `refresh-configuration` | Spring Cloud Context on classpath (`spring-cloud-starter`); `refresh` endpoint exposed | +| All read tools | Actuator endpoints exposed: `management.endpoints.web.exposure.include=*` | + +```yaml title="application.yml (monitored application)" +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: ALWAYS + restart: + enabled: true +logging: + file: + name: "logs/my-application.log" +``` + +## Security + +By default, MCP endpoints are open. For production deployments, restrict access via Spring Security. + +The simplest approach is to use the existing Spring Boot Admin security configuration and extend it to protect `/mcp`: + +```java title="SecurityConfiguration.java" +@Bean +@Profile("secure") +public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + return http + .authorizeExchange((auth) -> auth + .pathMatchers("/mcp").authenticated() + .anyExchange().authenticated()) + .httpBasic(Customizer.withDefaults()) + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .build(); +} +``` + +Then pass credentials in the MCP client configuration: + +```json title="opencode.json" +{ + "mcp": { + "spring-boot-admin": { + "type": "remote", + "url": "http://localhost:8080/mcp", + "headers": { + "Authorization": "Basic {env:SBA_BASIC_AUTH}" + } + } + } +} +``` + +## Configuration Reference + +| Property | Default | Description | +|---|---|---| +| `spring.boot.admin.mcp.enabled` | `false` | Enable the MCP server integration | +| `spring.ai.mcp.server.type` | `SYNC` | Server type — use `ASYNC` for reactive tool methods | +| `spring.ai.mcp.server.protocol` | `SSE` | Transport protocol — use `STATELESS` for HTTP clients | +| `spring.ai.mcp.server.name` | `mcp-server` | Server name reported to MCP clients | +| `spring.ai.mcp.server.version` | `1.0.0` | Server version reported to MCP clients | + +## Sample Application + +A runnable sample combining the Web UI and MCP server is available at +`spring-boot-admin-samples/spring-boot-admin-sample-mcp`. Start it with: + +```bash +./mvnw spring-boot:run -pl spring-boot-admin-samples/spring-boot-admin-sample-mcp -DexcludeSpringCloud +``` + +The Web UI is available at `http://localhost:8080` and the MCP endpoint at `http://localhost:8080/mcp`. From 374a7fed2c22e9ffe480c4c9d8a0b89963edeaff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 17:59:30 +0200 Subject: [PATCH 04/29] docs(mcp): mark MCP integration as experimental --- spring-boot-admin-docs/src/site/docs/07-mcp/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index 7317bd8ae5e..4204aa13d01 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -4,7 +4,7 @@ sidebar_custom_props: icon: 'cpu' --- -# MCP Integration +# MCP Integration (experimental) Spring Boot Admin can act as a **Model Context Protocol (MCP) server**, exposing your registered applications to AI assistants. This lets you monitor and manage your Spring Boot applications conversationally — without leaving your chat From c5fa059474611a873b79ac2b8d5f2f3bf41d96be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 18:35:09 +0200 Subject: [PATCH 05/29] feat(mcp): add env MCP tools for retrieving application properties Add get-env to resolve a single configuration property or environment variable and list-env to retrieve all environment properties grouped by property source, with an optional case-insensitive name filter. Document both tools and their actuator endpoint requirements. --- .../src/site/docs/07-mcp/index.md | 19 ++ .../boot/admin/server/mcp/tools/EnvTools.java | 237 ++++++++++++++++++ .../admin/server/mcp/tools/EnvToolsTest.java | 228 +++++++++++++++++ 3 files changed, 484 insertions(+) create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index 4204aa13d01..3618f698e40 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -41,6 +41,8 @@ calls against your registered applications. Responses are formatted as plain tex | `get-health` | Fetches full health details from `/actuator/health` | | `list-metrics` | Lists all available metric names for a named application | | `get-metrics` | Fetches the current value of a specific metric | +| `get-env` | Resolves a single configuration property or environment variable via `/actuator/env/{name}` | +| `list-env` | Lists environment properties grouped by property source via `/actuator/env`, with an optional name filter | | `get-logs` | Returns the last N lines from `/actuator/logfile` | | `restart-application` | Restarts an application via `/actuator/restart` | | `refresh-configuration` | Refreshes configuration via `/actuator/refresh` | @@ -232,6 +234,22 @@ Assistant: payment-service — jvm.memory.used: VALUE: 1258291200 bytes ``` +### Inspecting configuration + +``` +You: list the env properties for payment-service filtered by datasource +Assistant: Environment for payment-service (filtered by "datasource"): + +[application.yml] (2): + spring.datasource.url = jdbc:postgresql://db:5432/payment + spring.datasource.username = payment + +You: what is the HELLO env variable for payment-service? +Assistant: payment-service — HELLO: + value: world + source: systemEnvironment +``` + ### Configuration refresh ``` @@ -246,6 +264,7 @@ Certain tools require additional setup in the monitored applications: | Tool | Requirement | |---|---| | `get-logs` | `logging.file.name` or `logging.file.path` configured; `logfile` actuator endpoint exposed | +| `get-env` / `list-env` | `env` actuator endpoint exposed. Values are masked (`******`) unless `management.endpoint.env.show-values` is set to `ALWAYS` or `WHEN_AUTHORIZED` | | `restart-application` | `management.endpoint.restart.enabled=true`; restart actuator endpoint exposed | | `refresh-configuration` | Spring Cloud Context on classpath (`spring-cloud-starter`); `refresh` endpoint exposed | | All read tools | Actuator endpoints exposed: `management.endpoints.web.exposure.include=*` | diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java new file mode 100644 index 00000000000..8f3ddb1d4e1 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java @@ -0,0 +1,237 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for querying the environment of registered Spring Boot applications. + * + *
    + *
  • {@code get-env} — resolves a single configuration property (including environment + * variables) across all property sources
  • + *
  • {@code list-env} — retrieves all environment properties grouped by property + * source
  • + *
+ */ +public class EnvTools { + + private static final Logger log = LoggerFactory.getLogger(EnvTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code EnvTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public EnvTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Resolves a single configuration property for the named application by calling its + * {@code /actuator/env/{propertyName}} endpoint. This covers environment variables + * (e.g. {@code HELLO}), system properties and any other property source. + * @param applicationName the registered application name (case-insensitive) + * @param propertyName the property or environment variable name (e.g. {@code HELLO}) + * @return plain-text resolved value with its originating property sources, or an + * error message + */ + @McpTool(name = "get-env", + description = "Resolve a single configuration property or environment variable for a registered " + + "Spring Boot application by calling its /actuator/env/{name} endpoint. Works for " + + "environment variables (e.g. HELLO), system properties and application properties. " + + "Requires the env actuator endpoint to be exposed on the monitored application.") + public Mono getEnv( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "The property or environment variable name (e.g. HELLO)", + required = true) String propertyName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/env/" + propertyName; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatProperty(applicationName, propertyName, body)) + .doOnError((ex) -> log.warn("Failed to get env property {} for {}", propertyName, applicationName, ex)) + .onErrorResume((ex) -> Mono.just("Error retrieving env property '" + propertyName + "' for " + + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + /** + * Retrieves all environment properties for the named application by calling its + * {@code /actuator/env} endpoint. Properties are grouped by their originating + * property source (e.g. {@code systemEnvironment}, {@code systemProperties}, + * application config). An optional case-insensitive filter restricts the result to + * property names containing the given text. + * @param applicationName the registered application name (case-insensitive) + * @param filter optional case-insensitive substring; only property names containing + * it are returned. When {@code null} or blank, all properties are returned. + * @return plain-text listing of every (matching) property grouped by property source, + * or an error message + */ + @McpTool(name = "list-env", + description = "Retrieve environment properties for a registered Spring Boot application by calling " + + "its /actuator/env endpoint. Returns properties grouped by their property source " + + "(e.g. systemEnvironment, systemProperties, application config). Provide an optional " + + "'filter' to return only property names containing that text (case-insensitive); omit " + + "it to return ALL properties. Values may be masked (******) if the monitored application " + + "does not expose them. Requires the env actuator endpoint to be exposed on the monitored " + + "application.") + public Mono listEnv( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "Optional case-insensitive substring; only property names containing it are " + + "returned. Omit to return all properties.", required = false) String filter) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/env"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatEnv(applicationName, filter, body)) + .doOnError((ex) -> log.warn("Failed to list env for {}", applicationName, ex)) + .onErrorResume( + (ex) -> Mono.just("Error retrieving env for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + @SuppressWarnings("unchecked") + private String formatEnv(String applicationName, String filter, Map body) { + Object propertySources = body.get("propertySources"); + if (!(propertySources instanceof List sources) || sources.isEmpty()) { + return "No environment properties available for " + applicationName + "."; + } + + boolean filtered = filter != null && !filter.isBlank(); + String needle = filtered ? filter.toLowerCase(Locale.ROOT) : null; + + StringBuilder body_sb = new StringBuilder(); + int matches = 0; + + for (Object source : sources) { + if (!(source instanceof Map sourceEntry)) { + continue; + } + Object name = sourceEntry.get("name"); + Object properties = sourceEntry.get("properties"); + if (!(properties instanceof Map props)) { + continue; + } + StringBuilder sourceSb = new StringBuilder(); + int sourceMatches = 0; + for (Map.Entry entry : props.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (filtered && !key.toLowerCase(Locale.ROOT).contains(needle)) { + continue; + } + sourceMatches++; + sourceSb.append(" ").append(key); + if (entry.getValue() instanceof Map propValue) { + sourceSb.append(" = ").append(propValue.get("value")); + Object origin = propValue.get("origin"); + if (origin != null) { + sourceSb.append(" (").append(origin).append(")"); + } + } + sourceSb.append("\n"); + } + if (sourceMatches > 0) { + body_sb.append("\n[").append(name).append("] (").append(sourceMatches).append("):\n").append(sourceSb); + matches += sourceMatches; + } + } + + if (filtered && matches == 0) { + return "No environment properties matching '" + filter + "' for " + applicationName + "."; + } + + StringBuilder sb = new StringBuilder("Environment for ").append(applicationName); + if (filtered) { + sb.append(" (filtered by \"").append(filter).append("\")"); + } + sb.append(":\n"); + + Object activeProfiles = body.get("activeProfiles"); + if (activeProfiles instanceof List profiles && !profiles.isEmpty()) { + sb.append(" active profiles: ").append(profiles).append("\n"); + } + + sb.append(body_sb); + return sb.toString().trim(); + } + + @SuppressWarnings("unchecked") + private String formatProperty(String applicationName, String propertyName, Map body) { + Object property = body.get("property"); + if (!(property instanceof Map resolved) || resolved.get("value") == null) { + return "Property '" + propertyName + "' is not set for " + applicationName + "."; + } + + StringBuilder sb = new StringBuilder(applicationName).append(" — ").append(propertyName).append(":\n"); + sb.append(" value: ").append(resolved.get("value")).append("\n"); + Object source = resolved.get("source"); + if (source != null) { + sb.append(" source: ").append(source).append("\n"); + } + + Object propertySources = body.get("propertySources"); + if (propertySources instanceof List sources && !sources.isEmpty()) { + sb.append(" property sources:\n"); + for (Object item : sources) { + if (item instanceof Map sourceEntry && sourceEntry.get("property") instanceof Map prop) { + sb.append(" - ").append(sourceEntry.get("name")).append(": ").append(prop.get("value")); + Object origin = prop.get("origin"); + if (origin != null) { + sb.append(" (").append(origin).append(")"); + } + sb.append("\n"); + } + } + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java new file mode 100644 index 00000000000..d18fcd2163f --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java @@ -0,0 +1,228 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class EnvToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private EnvTools envTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.envTools = new EnvTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + @Test + void getEnv_propertySet_returnsValueAndSources() { + Instance instance = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env/HELLO")) + .willReturn(okJson("{\"property\":{\"source\":\"systemEnvironment\",\"value\":\"world\"}," + + "\"propertySources\":[{\"name\":\"systemEnvironment\"," + + "\"property\":{\"value\":\"world\",\"origin\":\"System Environment Property \\\"HELLO\\\"\"}}]}"))); + + StepVerifier.create(this.envTools.getEnv("payment-service", "HELLO")).assertNext((result) -> { + assertThat(result).contains("payment-service"); + assertThat(result).contains("HELLO"); + assertThat(result).contains("value: world"); + assertThat(result).contains("systemEnvironment"); + }).verifyComplete(); + } + + @Test + void getEnv_propertyNotSet_returnsNotSetMessage() { + Instance instance = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("order-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env/MISSING")) + .willReturn(okJson("{\"property\":null,\"propertySources\":[{\"name\":\"systemEnvironment\"}]}"))); + + StepVerifier.create(this.envTools.getEnv("order-service", "MISSING")) + .assertNext((result) -> assertThat(result).isEqualTo("Property 'MISSING' is not set for order-service.")) + .verifyComplete(); + } + + @Test + void getEnv_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.envTools.getEnv("ghost", "HELLO")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + + @Test + void listEnv_returnsAllPropertiesGroupedBySource() { + Instance instance = Instance.create(InstanceId.of("id-3")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env")) + .willReturn(okJson("{\"activeProfiles\":[\"prod\"],\"propertySources\":[" + + "{\"name\":\"systemEnvironment\",\"properties\":{" + + "\"HELLO\":{\"value\":\"world\",\"origin\":\"System Environment Property \\\"HELLO\\\"\"}}}," + + "{\"name\":\"application.yml\",\"properties\":{" + + "\"spring.application.name\":{\"value\":\"payment-service\"}}}]}"))); + + StepVerifier.create(this.envTools.listEnv("payment-service", null)).assertNext((result) -> { + assertThat(result).contains("Environment for payment-service"); + assertThat(result).contains("active profiles: [prod]"); + assertThat(result).contains("[systemEnvironment]"); + assertThat(result).contains("HELLO = world"); + assertThat(result).contains("[application.yml]"); + assertThat(result).contains("spring.application.name = payment-service"); + }).verifyComplete(); + } + + @Test + void listEnv_withFilter_returnsOnlyMatchingProperties() { + Instance instance = Instance.create(InstanceId.of("id-5")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env")) + .willReturn(okJson("{\"activeProfiles\":[\"prod\"],\"propertySources\":[" + + "{\"name\":\"systemEnvironment\",\"properties\":{" + + "\"HELLO\":{\"value\":\"world\"},\"DB_URL\":{\"value\":\"jdbc:h2:mem\"}}}," + + "{\"name\":\"application.yml\",\"properties\":{" + + "\"spring.datasource.url\":{\"value\":\"jdbc:h2\"}," + + "\"spring.application.name\":{\"value\":\"payment-service\"}}}]}"))); + + StepVerifier.create(this.envTools.listEnv("payment-service", "url")).assertNext((result) -> { + assertThat(result).contains("filtered by \"url\""); + assertThat(result).contains("DB_URL = jdbc:h2:mem"); + assertThat(result).contains("spring.datasource.url = jdbc:h2"); + assertThat(result).doesNotContain("HELLO"); + assertThat(result).doesNotContain("spring.application.name"); + }).verifyComplete(); + } + + @Test + void listEnv_withFilter_noMatches_returnsMessage() { + Instance instance = Instance.create(InstanceId.of("id-6")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env")) + .willReturn(okJson("{\"propertySources\":[{\"name\":\"systemEnvironment\",\"properties\":{" + + "\"HELLO\":{\"value\":\"world\"}}}]}"))); + + StepVerifier.create(this.envTools.listEnv("payment-service", "nomatch")) + .assertNext((result) -> assertThat(result) + .isEqualTo("No environment properties matching 'nomatch' for payment-service.")) + .verifyComplete(); + } + + @Test + void listEnv_noPropertySources_returnsNoPropertiesMessage() { + Instance instance = Instance.create(InstanceId.of("id-4")) + .register(Registration.create("order-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env")).willReturn(okJson("{\"propertySources\":[]}"))); + + StepVerifier.create(this.envTools.listEnv("order-service", null)) + .assertNext( + (result) -> assertThat(result).isEqualTo("No environment properties available for order-service.")) + .verifyComplete(); + } + + @Test + void listEnv_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.envTools.listEnv("ghost", null)) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} From 886191cca9faba3c0c43d6d5a28313eb3b9b3a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 18:36:14 +0200 Subject: [PATCH 06/29] feat(mcp): register EnvTools bean in auto-configuration --- .../server/mcp/config/McpAutoConfiguration.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java index 31715948788..179f3289b2f 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java @@ -23,6 +23,7 @@ import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; +import de.codecentric.boot.admin.server.mcp.tools.EnvTools; import de.codecentric.boot.admin.server.mcp.tools.HealthTools; import de.codecentric.boot.admin.server.mcp.tools.LogsTools; import de.codecentric.boot.admin.server.mcp.tools.MetricsTools; @@ -78,6 +79,19 @@ public MetricsTools metricsTools(InstanceRepository instanceRepository, return new MetricsTools(instanceRepository, instanceWebClientBuilder.build()); } + /** + * Creates the {@link EnvTools} bean for resolving application environment properties. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link EnvTools} + */ + @Bean + public EnvTools envTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new EnvTools(instanceRepository, instanceWebClientBuilder.build()); + } + /** * Creates the {@link LogsTools} bean for accessing application log output. * @param instanceRepository the repository used to look up registered instances From 842ece4f1f4086268d4d352bf50d95017ee49c45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 18:36:18 +0200 Subject: [PATCH 07/29] build(deps): bump spring-ai to 2.0.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 38e8cab01ca..fd5668b599d 100644 --- a/pom.xml +++ b/pom.xml @@ -49,7 +49,7 @@ 4.1.0 2025.1.2 - 2.0.0-M6 + 2.0.0 2.6.0 From 70e86b71c4b6c90e44788c36c84e09c7f4dca741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 18:36:27 +0200 Subject: [PATCH 08/29] refactor(sample): simplify MCP sample and enable operation endpoints Drop the Spring Security and devtools dependencies and the permit-all security bean in favour of spring-cloud-starter, and enable the restart and refresh actuator endpoints so the operations MCP tools work against the sample out of the box. Remove the obsolete application context test. --- .../spring-boot-admin-sample-mcp/pom.xml | 16 ++----- .../mcp/SpringBootAdminMcpApplication.java | 43 ------------------- .../src/main/resources/application.yml | 30 +++++++------ .../SpringBootAdminMcpApplicationTest.java | 32 -------------- 4 files changed, 20 insertions(+), 101 deletions(-) delete mode 100644 spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/test/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplicationTest.java diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml index 866886d534d..ecbd987267b 100644 --- a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml @@ -59,20 +59,10 @@ de.codecentric spring-boot-admin-starter-client + - org.springframework.boot - spring-boot-starter-security - - - org.springframework.boot - spring-boot-devtools - true - - - - org.springframework.boot - spring-boot-starter-test - test + org.springframework.cloud + spring-cloud-starter diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java index e84fc53f1de..76bf1735251 100644 --- a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java @@ -18,44 +18,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Profile; -import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.web.server.SecurityWebFilterChain; import de.codecentric.boot.admin.server.config.EnableAdminServer; -/** - * Spring Boot Admin sample application combining the Web UI and MCP server. - * - *

- * Start with the {@code insecure} profile for local development (default): - *

- * - *
- * ./mvnw spring-boot:run -pl spring-boot-admin-samples/spring-boot-admin-sample-mcp
- * 
- * - *

- * The MCP server is available at {@code http://localhost:8080/mcp} and the Web UI at - * {@code http://localhost:8080}. - *

- * - *

- * To connect Claude Desktop, add the following to your - * {@code claude_desktop_config.json}: - *

- * - *
- * {
- *   "mcpServers": {
- *     "spring-boot-admin": {
- *       "url": "http://localhost:8080/mcp"
- *     }
- *   }
- * }
- * 
- */ @SpringBootApplication @EnableAdminServer public class SpringBootAdminMcpApplication { @@ -64,12 +29,4 @@ public static void main(String[] args) { SpringApplication.run(SpringBootAdminMcpApplication.class, args); } - @Bean - @Profile("insecure") - public SecurityWebFilterChain securityWebFilterChainPermitAll(ServerHttpSecurity http) { - return http.authorizeExchange((authorizeExchange) -> authorizeExchange.anyExchange().permitAll()) - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .build(); - } - } diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml index df39b920f37..c099a7405cb 100644 --- a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml @@ -1,16 +1,3 @@ -logging: - file: - name: "target/boot-admin-sample-mcp.log" - -management: - endpoints: - web: - exposure: - include: "*" - endpoint: - health: - show-details: ALWAYS - spring: application: name: spring-boot-admin-sample-mcp @@ -30,3 +17,20 @@ spring: profiles: active: - insecure + +logging: + file: + name: "target/boot-admin-sample-mcp.log" + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: ALWAYS + restart: + enabled: true + refresh: + enabled: true diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/test/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplicationTest.java b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/test/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplicationTest.java deleted file mode 100644 index 8a4c5026276..00000000000 --- a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/test/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplicationTest.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2014-2026 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package de.codecentric.boot.admin.sample.mcp; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -@ExtendWith(SpringExtension.class) -@SpringBootTest(classes = { SpringBootAdminMcpApplication.class }) -class SpringBootAdminMcpApplicationTest { - - @Test - void contextLoads() { - } - -} From 66adac776dbd9c5f9c6bb3fbb7ab523ea3162771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 19:05:25 +0200 Subject: [PATCH 09/29] feat(mcp): add per-category tool enablement flags --- .../mcp/config/McpAutoConfiguration.java | 12 ++ .../server/mcp/config/McpProperties.java | 50 +++++++++ .../mcp/config/McpAutoConfigurationTest.java | 104 ++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java index 179f3289b2f..d08e00f3ffa 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java @@ -49,6 +49,8 @@ public class McpAutoConfiguration { * @return the configured {@link ApplicationTools} */ @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "applications", havingValue = "true", + matchIfMissing = true) public ApplicationTools applicationTools(InstanceRepository instanceRepository) { return new ApplicationTools(instanceRepository); } @@ -61,6 +63,8 @@ public ApplicationTools applicationTools(InstanceRepository instanceRepository) * @return the configured {@link HealthTools} */ @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "health", havingValue = "true", + matchIfMissing = true) public HealthTools healthTools(InstanceRepository instanceRepository, InstanceWebClient.Builder instanceWebClientBuilder) { return new HealthTools(instanceRepository, instanceWebClientBuilder.build()); @@ -74,6 +78,8 @@ public HealthTools healthTools(InstanceRepository instanceRepository, * @return the configured {@link MetricsTools} */ @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "metrics", havingValue = "true", + matchIfMissing = true) public MetricsTools metricsTools(InstanceRepository instanceRepository, InstanceWebClient.Builder instanceWebClientBuilder) { return new MetricsTools(instanceRepository, instanceWebClientBuilder.build()); @@ -87,6 +93,8 @@ public MetricsTools metricsTools(InstanceRepository instanceRepository, * @return the configured {@link EnvTools} */ @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "env", havingValue = "true", + matchIfMissing = true) public EnvTools envTools(InstanceRepository instanceRepository, InstanceWebClient.Builder instanceWebClientBuilder) { return new EnvTools(instanceRepository, instanceWebClientBuilder.build()); @@ -100,6 +108,8 @@ public EnvTools envTools(InstanceRepository instanceRepository, * @return the configured {@link LogsTools} */ @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "logs", havingValue = "true", + matchIfMissing = true) public LogsTools logsTools(InstanceRepository instanceRepository, InstanceWebClient.Builder instanceWebClientBuilder) { return new LogsTools(instanceRepository, instanceWebClientBuilder.build()); @@ -114,6 +124,8 @@ public LogsTools logsTools(InstanceRepository instanceRepository, * @return the configured {@link OperationsTools} */ @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "operations", havingValue = "true", + matchIfMissing = true) public OperationsTools operationsTools(InstanceRepository instanceRepository, InstanceWebClient.Builder instanceWebClientBuilder) { return new OperationsTools(instanceRepository, instanceWebClientBuilder.build()); diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java index d4468bbda21..6a74cb66d02 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java @@ -60,4 +60,54 @@ public McpProperties() { */ private boolean enabled = false; + /** + * Server-side toggles controlling which MCP tool categories are registered and + * advertised to clients. These operate on the Spring Boot Admin server itself and are + * independent of the monitored applications' {@code management.endpoint.*} settings + * (which are enforced at runtime by the target application). All categories default + * to {@code true}. + */ + private Tools tools = new Tools(); + + /** + * Category-level enablement flags for the exposed MCP tools. Disabling a category + * prevents the corresponding tool bean from being created, so its tools are never + * advertised. Changes take effect on server restart. + */ + @lombok.Data + public static class Tools { + + /** + * Whether the {@code list-applications} tool is available. + */ + private boolean applications = true; + + /** + * Whether the {@code get-health} and {@code get-status} tools are available. + */ + private boolean health = true; + + /** + * Whether the {@code list-metrics} and {@code get-metrics} tools are available. + */ + private boolean metrics = true; + + /** + * Whether the {@code get-env} and {@code list-env} tools are available. + */ + private boolean env = true; + + /** + * Whether the {@code get-logs} tool is available. + */ + private boolean logs = true; + + /** + * Whether the write operation tools ({@code restart-application} and + * {@code refresh-configuration}) are available. + */ + private boolean operations = true; + + } + } diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java new file mode 100644 index 00000000000..92c7efe9a17 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; +import de.codecentric.boot.admin.server.mcp.tools.EnvTools; +import de.codecentric.boot.admin.server.mcp.tools.HealthTools; +import de.codecentric.boot.admin.server.mcp.tools.LogsTools; +import de.codecentric.boot.admin.server.mcp.tools.MetricsTools; +import de.codecentric.boot.admin.server.mcp.tools.OperationsTools; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class McpAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpAutoConfiguration.class)) + .withUserConfiguration(RequiredBeansConfiguration.class); + + @Test + void mcpDisabled_noToolBeansCreated() { + this.contextRunner.run((context) -> { + assertThat(context).doesNotHaveBean(ApplicationTools.class); + assertThat(context).doesNotHaveBean(HealthTools.class); + assertThat(context).doesNotHaveBean(MetricsTools.class); + assertThat(context).doesNotHaveBean(EnvTools.class); + assertThat(context).doesNotHaveBean(LogsTools.class); + assertThat(context).doesNotHaveBean(OperationsTools.class); + }); + } + + @Test + void mcpEnabled_allToolBeansCreatedByDefault() { + this.contextRunner.withPropertyValues("spring.boot.admin.mcp.enabled=true").run((context) -> { + assertThat(context).hasSingleBean(ApplicationTools.class); + assertThat(context).hasSingleBean(HealthTools.class); + assertThat(context).hasSingleBean(MetricsTools.class); + assertThat(context).hasSingleBean(EnvTools.class); + assertThat(context).hasSingleBean(LogsTools.class); + assertThat(context).hasSingleBean(OperationsTools.class); + }); + } + + @Test + void operationsDisabled_operationsToolBeanAbsentOthersPresent() { + this.contextRunner + .withPropertyValues("spring.boot.admin.mcp.enabled=true", "spring.boot.admin.mcp.tools.operations=false") + .run((context) -> { + assertThat(context).doesNotHaveBean(OperationsTools.class); + assertThat(context).hasSingleBean(ApplicationTools.class); + assertThat(context).hasSingleBean(EnvTools.class); + }); + } + + @Test + void envDisabled_envToolBeanAbsentOthersPresent() { + this.contextRunner + .withPropertyValues("spring.boot.admin.mcp.enabled=true", "spring.boot.admin.mcp.tools.env=false") + .run((context) -> { + assertThat(context).doesNotHaveBean(EnvTools.class); + assertThat(context).hasSingleBean(OperationsTools.class); + assertThat(context).hasSingleBean(HealthTools.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class RequiredBeansConfiguration { + + @Bean + InstanceRepository instanceRepository() { + return mock(InstanceRepository.class); + } + + @Bean + InstanceWebClient.Builder instanceWebClientBuilder() { + return InstanceWebClient.builder(); + } + + } + +} From ac3cfc719cdc718d08bf781223ed3368a9ed9978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 19:05:32 +0200 Subject: [PATCH 10/29] feat(mcp): default server name and version from Spring Boot Admin Contribute spring.ai.mcp.server.name and spring.ai.mcp.server.version as low-precedence defaults via an EnvironmentPostProcessor. The name defaults to "Spring Boot Admin MCP Server" and the version is read from the JAR manifest. User configuration still overrides both. Remove the now-redundant hardcoded values from the sample application. --- .../src/main/resources/application.yml | 2 - ...erverDefaultsEnvironmentPostProcessor.java | 107 ++++++++++++++++++ .../main/resources/META-INF/spring.factories | 2 + ...rDefaultsEnvironmentPostProcessorTest.java | 79 +++++++++++++ 4 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java create mode 100644 spring-boot-admin-server-mcp/src/main/resources/META-INF/spring.factories create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml index c099a7405cb..d3356df5a5d 100644 --- a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml @@ -12,8 +12,6 @@ spring: server: type: ASYNC protocol: STATELESS - name: "Spring Boot Admin" - version: "1.0.0" profiles: active: - insecure diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java new file mode 100644 index 00000000000..77dea862455 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java @@ -0,0 +1,107 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.config; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.util.StringUtils; + +/** + * Contributes sensible defaults for the Spring AI MCP server metadata reported to + * clients, namely {@code spring.ai.mcp.server.name} and + * {@code spring.ai.mcp.server.version}. + * + *

+ * The defaults are added as a low-precedence {@link MapPropertySource} placed at the end + * of the property source list. As a result, any explicit configuration in a user's + * {@code application.yml}, {@code application.properties}, environment variables, or + * command-line arguments takes precedence and overrides these defaults. + *

+ * + *

+ * The server name defaults to {@value #DEFAULT_SERVER_NAME}. The version is read from the + * JAR manifest via {@link Package#getImplementationVersion()}, so it tracks the running + * Spring Boot Admin version automatically. When the classes are not packaged in a JAR + * (for example during tests or when running from an IDE) the manifest attribute is + * absent; in that case no version default is contributed and Spring AI's own fallback + * applies. + *

+ */ +public class McpServerDefaultsEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { + + /** + * The property that reports the MCP server name to clients. + */ + static final String NAME_PROPERTY = "spring.ai.mcp.server.name"; + + /** + * The property that reports the MCP server version to clients. + */ + static final String VERSION_PROPERTY = "spring.ai.mcp.server.version"; + + /** + * Default server name advertised to MCP clients. + */ + static final String DEFAULT_SERVER_NAME = "Spring Boot Admin MCP Server"; + + /** + * Name of the property source contributing the defaults. + */ + static final String PROPERTY_SOURCE_NAME = "springBootAdminMcpServerDefaults"; + + /** + * Creates a new {@code McpServerDefaultsEnvironmentPostProcessor}. + */ + public McpServerDefaultsEnvironmentPostProcessor() { + // NOOP + } + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + Map defaults = new HashMap<>(); + defaults.put(NAME_PROPERTY, DEFAULT_SERVER_NAME); + String version = resolveVersion(); + if (StringUtils.hasText(version)) { + defaults.put(VERSION_PROPERTY, version); + } + // addLast -> lowest precedence, so user configuration always wins. + environment.getPropertySources().addLast(new MapPropertySource(PROPERTY_SOURCE_NAME, defaults)); + } + + /** + * Resolves the Spring Boot Admin implementation version from the JAR manifest. + * @return the implementation version, or {@code null} when unavailable (e.g. not + * running from a packaged JAR) + */ + private String resolveVersion() { + Package pkg = McpServerDefaultsEnvironmentPostProcessor.class.getPackage(); + return (pkg != null) ? pkg.getImplementationVersion() : null; + } + + @Override + public int getOrder() { + // Run late so the defaults are only ever contributed as a fallback. + return Ordered.LOWEST_PRECEDENCE; + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring.factories b/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..c1e9ad395cb --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.EnvironmentPostProcessor=\ +de.codecentric.boot.admin.server.mcp.config.McpServerDefaultsEnvironmentPostProcessor diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java new file mode 100644 index 00000000000..1b1ef7702c8 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.config; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.springframework.core.env.MapPropertySource; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpServerDefaultsEnvironmentPostProcessorTest { + + private final McpServerDefaultsEnvironmentPostProcessor postProcessor = new McpServerDefaultsEnvironmentPostProcessor(); + + @Test + void shouldContributeDefaultServerName() { + MockEnvironment environment = new MockEnvironment(); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.NAME_PROPERTY)) + .isEqualTo("Spring Boot Admin MCP Server"); + } + + @Test + void shouldNotOverrideUserProvidedName() { + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources() + .addFirst(new MapPropertySource("userConfig", Collections + .singletonMap(McpServerDefaultsEnvironmentPostProcessor.NAME_PROPERTY, "My Custom Server"))); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.NAME_PROPERTY)) + .isEqualTo("My Custom Server"); + } + + @Test + void shouldNotOverrideUserProvidedVersion() { + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources() + .addFirst(new MapPropertySource("userConfig", + Collections.singletonMap(McpServerDefaultsEnvironmentPostProcessor.VERSION_PROPERTY, "9.9.9"))); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.VERSION_PROPERTY)) + .isEqualTo("9.9.9"); + } + + @Test + void defaultsPropertySourceShouldHaveLowestPrecedence() { + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources().addLast(new MapPropertySource("dummy", Collections.emptyMap())); + + this.postProcessor.postProcessEnvironment(environment, null); + + // The contributed source must always be added last (fallback). + assertThat(environment.getPropertySources().stream().reduce((first, second) -> second).orElseThrow().getName()) + .isEqualTo(McpServerDefaultsEnvironmentPostProcessor.PROPERTY_SOURCE_NAME); + } + +} From 0d27216d38093c70c01cfa2bc6a9119468d174ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 19:05:36 +0200 Subject: [PATCH 11/29] docs(mcp): document tool toggles and server metadata defaults --- .../src/site/docs/07-mcp/_category_.json | 2 +- .../src/site/docs/07-mcp/index.md | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json b/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json index 14dd1c3b9df..9409e900c19 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json @@ -1,4 +1,4 @@ { "position": 7, - "label": "MCP Integration" + "label": "MCP Integration (experimental)" } diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index 3618f698e40..edb3e5fe109 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -326,10 +326,24 @@ Then pass credentials in the MCP client configuration: | Property | Default | Description | |---|---|---| | `spring.boot.admin.mcp.enabled` | `false` | Enable the MCP server integration | +| `spring.boot.admin.mcp.tools.applications` | `true` | Register the `list-applications` tool | +| `spring.boot.admin.mcp.tools.health` | `true` | Register the `get-health` and `get-status` tools | +| `spring.boot.admin.mcp.tools.metrics` | `true` | Register the `list-metrics` and `get-metrics` tools | +| `spring.boot.admin.mcp.tools.env` | `true` | Register the `get-env` and `list-env` tools | +| `spring.boot.admin.mcp.tools.logs` | `true` | Register the `get-logs` tool | +| `spring.boot.admin.mcp.tools.operations` | `true` | Register the write tools `restart-application` and `refresh-configuration` | | `spring.ai.mcp.server.type` | `SYNC` | Server type — use `ASYNC` for reactive tool methods | | `spring.ai.mcp.server.protocol` | `SSE` | Transport protocol — use `STATELESS` for HTTP clients | -| `spring.ai.mcp.server.name` | `mcp-server` | Server name reported to MCP clients | -| `spring.ai.mcp.server.version` | `1.0.0` | Server version reported to MCP clients | +| `spring.ai.mcp.server.name` | `Spring Boot Admin MCP Server` | Server name reported to MCP clients. Override to report a custom value. | +| `spring.ai.mcp.server.version` | current Spring Boot Admin version | Server version reported to MCP clients. Defaults to the running Spring Boot Admin version; override to report a custom value. | + +:::note +The `spring.boot.admin.mcp.tools.*` flags toggle tool availability **on the Spring Boot Admin server** — a disabled +category is never advertised to MCP clients. They are independent of the monitored applications' +`management.endpoint.*.enabled` settings, which are enforced at runtime by each target application (a call to a disabled +endpoint simply returns an error). For example, to run a read-only monitoring deployment, set +`spring.boot.admin.mcp.tools.operations=false`. Changes take effect on server restart. +::: ## Sample Application From 37765e07730b9d90b506f8f45ae0255e51b3405d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 19:23:23 +0200 Subject: [PATCH 12/29] build(mcp): add spring-boot-admin-starter-server-mcp starter module Introduces a dedicated MCP server starter that bundles the Spring Boot Admin server with the MCP server module (without the Web UI). Register it in the root reactor and BOM, and switch the MCP sample to use the new starter. --- pom.xml | 3 +- spring-boot-admin-dependencies/pom.xml | 7 ++- .../spring-boot-admin-sample-mcp/pom.xml | 8 ++-- spring-boot-admin-starter-server-mcp/pom.xml | 44 +++++++++++++++++++ 4 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 spring-boot-admin-starter-server-mcp/pom.xml diff --git a/pom.xml b/pom.xml index fd5668b599d..65849bf1310 100644 --- a/pom.xml +++ b/pom.xml @@ -95,12 +95,13 @@ spring-boot-admin-dependencies spring-boot-admin-build spring-boot-admin-server + spring-boot-admin-server-mcp spring-boot-admin-server-ui spring-boot-admin-client spring-boot-admin-docs spring-boot-admin-starter-server + spring-boot-admin-starter-server-mcp spring-boot-admin-starter-client - spring-boot-admin-server-mcp spring-boot-admin-samples diff --git a/spring-boot-admin-dependencies/pom.xml b/spring-boot-admin-dependencies/pom.xml index 2f3a49259ce..8e3176b8f5b 100644 --- a/spring-boot-admin-dependencies/pom.xml +++ b/spring-boot-admin-dependencies/pom.xml @@ -40,6 +40,11 @@ spring-boot-admin-server ${revision} + + de.codecentric + spring-boot-admin-server-mcp + ${revision} + de.codecentric spring-boot-admin-server-ui @@ -62,7 +67,7 @@ de.codecentric - spring-boot-admin-server-mcp + spring-boot-admin-starter-server-mcp ${revision} diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml index ecbd987267b..73712045d93 100644 --- a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml @@ -44,15 +44,15 @@
- + de.codecentric - spring-boot-admin-starter-server + spring-boot-admin-starter-server-mcp - + de.codecentric - spring-boot-admin-server-mcp + spring-boot-admin-starter-server diff --git a/spring-boot-admin-starter-server-mcp/pom.xml b/spring-boot-admin-starter-server-mcp/pom.xml new file mode 100644 index 00000000000..9ebadf64ff7 --- /dev/null +++ b/spring-boot-admin-starter-server-mcp/pom.xml @@ -0,0 +1,44 @@ + + + + + 4.0.0 + + spring-boot-admin-starter-server-mcp + + Spring Boot Admin MCP Server Starter + Spring Boot Admin MCP Server Starter + + + de.codecentric + spring-boot-admin-build + ${revision} + ../spring-boot-admin-build + + + + + de.codecentric + spring-boot-admin-server + + + de.codecentric + spring-boot-admin-server-mcp + + + From e6f9edb9772c91026362adb5afc4d172f645a3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Fri, 17 Jul 2026 19:23:28 +0200 Subject: [PATCH 13/29] docs(mcp): document MCP starter and optional server metadata Recommend the spring-boot-admin-starter-server-mcp starter and clarify it runs standalone without the Web UI. Note that server name and version have sensible defaults and need not be set explicitly. --- .../src/site/docs/07-mcp/index.md | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index edb3e5fe109..3e257f106e5 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -51,18 +51,32 @@ calls against your registered applications. Responses are formatted as plain tex ### 1. Add the MCP module -Add `spring-boot-admin-server-mcp` alongside your existing Spring Boot Admin server dependency: +The `spring-boot-admin-starter-server-mcp` starter brings in the Spring Boot Admin server together with the MCP +server. It works as a fully functional Spring Boot Admin server on its own — you get the registry and all MCP tools, +but **without the Web UI**: ```xml title="pom.xml" de.codecentric - spring-boot-admin-server-mcp + spring-boot-admin-starter-server-mcp ``` :::tip -The MCP module is independent — add it alongside `spring-boot-admin-starter-server` to get both the Web UI and MCP -server on the same port. +Add `spring-boot-admin-starter-server` alongside the MCP starter if you also want the Web UI. Both run on the same +port, giving you the UI and the MCP server side by side. +::: + +:::note +If you already depend on `spring-boot-admin-starter-server` and only want to add MCP capabilities, you can instead add +the standalone `spring-boot-admin-server-mcp` module: + +```xml title="pom.xml" + + de.codecentric + spring-boot-admin-server-mcp + +``` ::: ### 2. Enable MCP in your configuration @@ -78,14 +92,26 @@ spring: server: type: ASYNC protocol: STATELESS - name: "Spring Boot Admin" - version: "1.0.0" ``` :::note `spring.boot.admin.mcp.enabled` defaults to `false`. Existing deployments are unaffected until you opt in. ::: +:::note +You don't need to set `spring.ai.mcp.server.name` or `spring.ai.mcp.server.version` — Spring Boot Admin provides +sensible defaults for both. You can still override them explicitly if you want a custom name or version: + +```yaml title="application.yml" +spring: + ai: + mcp: + server: + name: "My Spring Boot Admin" + version: "1.0.0" +``` +::: + ### 3. Connect your AI assistant Once the server is running, point your AI tool at the MCP endpoint: From e1bd10bb241e577b86da405fdabb5bd80ce1cdbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 17:13:56 +0200 Subject: [PATCH 14/29] feat(mcp): add loggers, thread-dump, http-exchanges, scheduled-tasks, caches, and beans tools New tool classes (one per actuator, following existing pattern): - LoggersTools: list-loggers, get-logger, set-logger-level - ThreadDumpTools: get-thread-dump - HttpExchangesTools: get-http-exchanges (limit param) - ScheduledTasksTools: get-scheduled-tasks - CachesTools: list-caches - BeansTools: list-beans (filter param) Each tool class is independently togglable via spring.boot.admin.mcp.tools.=false. McpProperties and McpAutoConfiguration extended accordingly. docs/07-mcp/index.md updated: Available Tools, Requirements, and Configuration Reference tables. --- .../src/site/docs/07-mcp/index.md | 130 +++++---- .../mcp/config/McpAutoConfiguration.java | 97 +++++++ .../server/mcp/config/McpProperties.java | 31 +++ .../admin/server/mcp/tools/BeansTools.java | 177 +++++++++++++ .../admin/server/mcp/tools/CachesTools.java | 131 +++++++++ .../server/mcp/tools/HttpExchangesTools.java | 159 +++++++++++ .../admin/server/mcp/tools/LoggersTools.java | 248 ++++++++++++++++++ .../server/mcp/tools/ScheduledTasksTools.java | 172 ++++++++++++ .../server/mcp/tools/ThreadDumpTools.java | 162 ++++++++++++ .../mcp/config/McpAutoConfigurationTest.java | 18 ++ .../server/mcp/tools/BeansToolsTest.java | 155 +++++++++++ .../server/mcp/tools/CachesToolsTest.java | 123 +++++++++ .../mcp/tools/HttpExchangesToolsTest.java | 142 ++++++++++ .../server/mcp/tools/LoggersToolsTest.java | 203 ++++++++++++++ .../mcp/tools/ScheduledTasksToolsTest.java | 126 +++++++++ .../server/mcp/tools/ThreadDumpToolsTest.java | 126 +++++++++ 16 files changed, 2145 insertions(+), 55 deletions(-) create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java create mode 100644 spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index 3e257f106e5..de009af0665 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -24,7 +24,6 @@ flowchart LR AI[AI Assistant\nOpenCode / Claude / ChatGPT] SBA[Spring Boot Admin\nMCP Server] Apps[Spring Boot\nApplications] - AI -->|MCP tools| SBA SBA -->|/actuator/*| Apps ``` @@ -34,49 +33,58 @@ calls against your registered applications. Responses are formatted as plain tex ## Available Tools -| Tool | Description | -|---|---| -| `list-applications` | Lists all registered applications with status and management URL | -| `get-status` | Returns the cached status (UP/DOWN/UNKNOWN) for a named application | -| `get-health` | Fetches full health details from `/actuator/health` | -| `list-metrics` | Lists all available metric names for a named application | -| `get-metrics` | Fetches the current value of a specific metric | -| `get-env` | Resolves a single configuration property or environment variable via `/actuator/env/{name}` | -| `list-env` | Lists environment properties grouped by property source via `/actuator/env`, with an optional name filter | -| `get-logs` | Returns the last N lines from `/actuator/logfile` | -| `restart-application` | Restarts an application via `/actuator/restart` | -| `refresh-configuration` | Refreshes configuration via `/actuator/refresh` | +| Tool | Description | +|-------------------------|---------------------------------------------------------------------------------------------------------------------------| +| `list-applications` | Lists all registered applications with status and management URL | +| `get-status` | Returns the cached status (UP/DOWN/UNKNOWN) for a named application | +| `get-health` | Fetches full health details from `/actuator/health` | +| `list-metrics` | Lists all available metric names for a named application | +| `get-metrics` | Fetches the current value of a specific metric | +| `get-env` | Resolves a single configuration property or environment variable via `/actuator/env/{name}` | +| `list-env` | Lists environment properties grouped by property source via `/actuator/env`, with an optional name filter | +| `get-logs` | Returns the last N lines from `/actuator/logfile` | +| `restart-application` | Restarts an application via `/actuator/restart` | +| `refresh-configuration` | Refreshes configuration via `/actuator/refresh` | +| `list-loggers` | Lists all loggers and their effective log levels via `/actuator/loggers`, with an optional name filter | +| `get-logger` | Returns the configured and effective log level for a single logger | +| `set-logger-level` | Changes a logger's level at runtime via `POST /actuator/loggers/{name}`; resets to inherited when level is `null` | +| `get-thread-dump` | Captures a thread dump via `/actuator/threaddump`; useful for diagnosing deadlocks and hung threads | +| `get-http-exchanges` | Returns recent HTTP exchanges (method, URI, status, duration) via `/actuator/httpexchanges` | +| `get-scheduled-tasks` | Lists all `@Scheduled` tasks with their cron expressions or interval settings via `/actuator/scheduledtasks` | +| `list-caches` | Lists all caches grouped by `CacheManager` via `/actuator/caches` | +| `list-beans` | Lists Spring application context beans with their type and scope via `/actuator/beans`, with an optional name/type filter | ## Quick Start ### 1. Add the MCP module -The `spring-boot-admin-starter-server-mcp` starter brings in the Spring Boot Admin server together with the MCP -server. It works as a fully functional Spring Boot Admin server on its own — you get the registry and all MCP tools, -but **without the Web UI**: +The `spring-boot-admin-starter-server-mcp` starter brings in the Spring Boot Admin server together with the MCP server. +It works as a fully functional Spring Boot Admin server on its own — you get the registry and all MCP tools, but +**without the Web UI**: ```xml title="pom.xml" + de.codecentric spring-boot-admin-starter-server-mcp ``` -:::tip -Add `spring-boot-admin-starter-server` alongside the MCP starter if you also want the Web UI. Both run on the same -port, giving you the UI and the MCP server side by side. +:::tip Add `spring-boot-admin-starter-server` alongside the MCP starter if you also want the Web UI. Both run on the +same port, giving you the UI and the MCP server side by side. ::: -:::note -If you already depend on `spring-boot-admin-starter-server` and only want to add MCP capabilities, you can instead add -the standalone `spring-boot-admin-server-mcp` module: +:::note If you already depend on `spring-boot-admin-starter-server` and only want to add MCP capabilities, you can +instead add the standalone `spring-boot-admin-server-mcp` module: ```xml title="pom.xml" + de.codecentric spring-boot-admin-server-mcp ``` + ::: ### 2. Enable MCP in your configuration @@ -98,8 +106,7 @@ spring: `spring.boot.admin.mcp.enabled` defaults to `false`. Existing deployments are unaffected until you opt in. ::: -:::note -You don't need to set `spring.ai.mcp.server.name` or `spring.ai.mcp.server.version` — Spring Boot Admin provides +:::note You don't need to set `spring.ai.mcp.server.name` or `spring.ai.mcp.server.version` — Spring Boot Admin provides sensible defaults for both. You can still override them explicitly if you want a custom name or version: ```yaml title="application.yml" @@ -110,6 +117,7 @@ spring: name: "My Spring Boot Admin" version: "1.0.0" ``` + ::: ### 3. Connect your AI assistant @@ -287,13 +295,19 @@ Assistant: Configuration refreshed for inventory-service. Changed keys: ["app.fe Certain tools require additional setup in the monitored applications: -| Tool | Requirement | -|---|---| -| `get-logs` | `logging.file.name` or `logging.file.path` configured; `logfile` actuator endpoint exposed | -| `get-env` / `list-env` | `env` actuator endpoint exposed. Values are masked (`******`) unless `management.endpoint.env.show-values` is set to `ALWAYS` or `WHEN_AUTHORIZED` | -| `restart-application` | `management.endpoint.restart.enabled=true`; restart actuator endpoint exposed | -| `refresh-configuration` | Spring Cloud Context on classpath (`spring-cloud-starter`); `refresh` endpoint exposed | -| All read tools | Actuator endpoints exposed: `management.endpoints.web.exposure.include=*` | +| Tool | Requirement | +|----------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `get-logs` | `logging.file.name` or `logging.file.path` configured; `logfile` actuator endpoint exposed | +| `get-env` / `list-env` | `env` actuator endpoint exposed. Values are masked (`******`) unless `management.endpoint.env.show-values` is set to `ALWAYS` or `WHEN_AUTHORIZED` | +| `restart-application` | `management.endpoint.restart.enabled=true`; restart actuator endpoint exposed | +| `refresh-configuration` | Spring Cloud Context on classpath (`spring-cloud-starter`); `refresh` endpoint exposed | +| `list-loggers` / `get-logger` / `set-logger-level` | `loggers` actuator endpoint exposed | +| `get-thread-dump` | `threaddump` actuator endpoint exposed | +| `get-http-exchanges` | `management.httpexchanges.recording.enabled=true`; `httpexchanges` actuator endpoint exposed (Spring Boot 3.x) | +| `get-scheduled-tasks` | `scheduledtasks` actuator endpoint exposed | +| `list-caches` | `caches` actuator endpoint exposed; application must use Spring's cache abstraction | +| `list-beans` | `beans` actuator endpoint exposed | +| All read tools | Actuator endpoints exposed: `management.endpoints.web.exposure.include=*` | ```yaml title="application.yml (monitored application)" management: @@ -318,16 +332,17 @@ By default, MCP endpoints are open. For production deployments, restrict access The simplest approach is to use the existing Spring Boot Admin security configuration and extend it to protect `/mcp`: ```java title="SecurityConfiguration.java" + @Bean @Profile("secure") public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { - return http - .authorizeExchange((auth) -> auth - .pathMatchers("/mcp").authenticated() - .anyExchange().authenticated()) - .httpBasic(Customizer.withDefaults()) - .csrf(ServerHttpSecurity.CsrfSpec::disable) - .build(); + return http + .authorizeExchange((auth) -> auth + .pathMatchers("/mcp").authenticated() + .anyExchange().authenticated()) + .httpBasic(Customizer.withDefaults()) + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .build(); } ``` @@ -349,23 +364,28 @@ Then pass credentials in the MCP client configuration: ## Configuration Reference -| Property | Default | Description | -|---|---|---| -| `spring.boot.admin.mcp.enabled` | `false` | Enable the MCP server integration | -| `spring.boot.admin.mcp.tools.applications` | `true` | Register the `list-applications` tool | -| `spring.boot.admin.mcp.tools.health` | `true` | Register the `get-health` and `get-status` tools | -| `spring.boot.admin.mcp.tools.metrics` | `true` | Register the `list-metrics` and `get-metrics` tools | -| `spring.boot.admin.mcp.tools.env` | `true` | Register the `get-env` and `list-env` tools | -| `spring.boot.admin.mcp.tools.logs` | `true` | Register the `get-logs` tool | -| `spring.boot.admin.mcp.tools.operations` | `true` | Register the write tools `restart-application` and `refresh-configuration` | -| `spring.ai.mcp.server.type` | `SYNC` | Server type — use `ASYNC` for reactive tool methods | -| `spring.ai.mcp.server.protocol` | `SSE` | Transport protocol — use `STATELESS` for HTTP clients | -| `spring.ai.mcp.server.name` | `Spring Boot Admin MCP Server` | Server name reported to MCP clients. Override to report a custom value. | -| `spring.ai.mcp.server.version` | current Spring Boot Admin version | Server version reported to MCP clients. Defaults to the running Spring Boot Admin version; override to report a custom value. | - -:::note -The `spring.boot.admin.mcp.tools.*` flags toggle tool availability **on the Spring Boot Admin server** — a disabled -category is never advertised to MCP clients. They are independent of the monitored applications' +| Property | Default | Description | +|-----------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------| +| `spring.boot.admin.mcp.enabled` | `false` | Enable the MCP server integration | +| `spring.boot.admin.mcp.tools.applications` | `true` | Register the `list-applications` tool | +| `spring.boot.admin.mcp.tools.health` | `true` | Register the `get-health` and `get-status` tools | +| `spring.boot.admin.mcp.tools.metrics` | `true` | Register the `list-metrics` and `get-metrics` tools | +| `spring.boot.admin.mcp.tools.env` | `true` | Register the `get-env` and `list-env` tools | +| `spring.boot.admin.mcp.tools.logs` | `true` | Register the `get-logs` tool | +| `spring.boot.admin.mcp.tools.operations` | `true` | Register the write tools `restart-application` and `refresh-configuration` | +| `spring.boot.admin.mcp.tools.loggers` | `true` | Register the `list-loggers`, `get-logger`, and `set-logger-level` tools | +| `spring.boot.admin.mcp.tools.thread-dump` | `true` | Register the `get-thread-dump` tool | +| `spring.boot.admin.mcp.tools.http-exchanges` | `true` | Register the `get-http-exchanges` tool | +| `spring.boot.admin.mcp.tools.scheduled-tasks` | `true` | Register the `get-scheduled-tasks` tool | +| `spring.boot.admin.mcp.tools.caches` | `true` | Register the `list-caches` tool | +| `spring.boot.admin.mcp.tools.beans` | `true` | Register the `list-beans` tool | +| `spring.ai.mcp.server.type` | `SYNC` | Server type — use `ASYNC` for reactive tool methods | +| `spring.ai.mcp.server.protocol` | `SSE` | Transport protocol — use `STATELESS` for HTTP clients | +| `spring.ai.mcp.server.name` | `Spring Boot Admin MCP Server` | Server name reported to MCP clients. Override to report a custom value. | +| `spring.ai.mcp.server.version` | current Spring Boot Admin version | Server version reported to MCP clients. Defaults to the running Spring Boot Admin version; override to report a custom value. | + +:::note The `spring.boot.admin.mcp.tools.*` flags toggle tool availability **on the Spring Boot Admin server** — a +disabled category is never advertised to MCP clients. They are independent of the monitored applications' `management.endpoint.*.enabled` settings, which are enforced at runtime by each target application (a call to a disabled endpoint simply returns an error). For example, to run a read-only monitoring deployment, set `spring.boot.admin.mcp.tools.operations=false`. Changes take effect on server restart. diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java index d08e00f3ffa..a2d34ee2f59 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java @@ -23,11 +23,17 @@ import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; +import de.codecentric.boot.admin.server.mcp.tools.BeansTools; +import de.codecentric.boot.admin.server.mcp.tools.CachesTools; import de.codecentric.boot.admin.server.mcp.tools.EnvTools; import de.codecentric.boot.admin.server.mcp.tools.HealthTools; +import de.codecentric.boot.admin.server.mcp.tools.HttpExchangesTools; +import de.codecentric.boot.admin.server.mcp.tools.LoggersTools; import de.codecentric.boot.admin.server.mcp.tools.LogsTools; import de.codecentric.boot.admin.server.mcp.tools.MetricsTools; import de.codecentric.boot.admin.server.mcp.tools.OperationsTools; +import de.codecentric.boot.admin.server.mcp.tools.ScheduledTasksTools; +import de.codecentric.boot.admin.server.mcp.tools.ThreadDumpTools; import de.codecentric.boot.admin.server.web.client.InstanceWebClient; /** @@ -131,4 +137,95 @@ public OperationsTools operationsTools(InstanceRepository instanceRepository, return new OperationsTools(instanceRepository, instanceWebClientBuilder.build()); } + /** + * Creates the {@link LoggersTools} bean for querying and configuring log levels. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link LoggersTools} + */ + @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "loggers", havingValue = "true", + matchIfMissing = true) + public LoggersTools loggersTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new LoggersTools(instanceRepository, instanceWebClientBuilder.build()); + } + + /** + * Creates the {@link ThreadDumpTools} bean for capturing thread dumps. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link ThreadDumpTools} + */ + @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "thread-dump", havingValue = "true", + matchIfMissing = true) + public ThreadDumpTools threadDumpTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new ThreadDumpTools(instanceRepository, instanceWebClientBuilder.build()); + } + + /** + * Creates the {@link HttpExchangesTools} bean for inspecting recent HTTP exchanges. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link HttpExchangesTools} + */ + @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "http-exchanges", havingValue = "true", + matchIfMissing = true) + public HttpExchangesTools httpExchangesTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new HttpExchangesTools(instanceRepository, instanceWebClientBuilder.build()); + } + + /** + * Creates the {@link ScheduledTasksTools} bean for inspecting scheduled tasks. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link ScheduledTasksTools} + */ + @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "scheduled-tasks", havingValue = "true", + matchIfMissing = true) + public ScheduledTasksTools scheduledTasksTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new ScheduledTasksTools(instanceRepository, instanceWebClientBuilder.build()); + } + + /** + * Creates the {@link CachesTools} bean for inspecting application caches. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link CachesTools} + */ + @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "caches", havingValue = "true", + matchIfMissing = true) + public CachesTools cachesTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new CachesTools(instanceRepository, instanceWebClientBuilder.build()); + } + + /** + * Creates the {@link BeansTools} bean for inspecting Spring application context + * beans. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @return the configured {@link BeansTools} + */ + @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "beans", havingValue = "true", + matchIfMissing = true) + public BeansTools beansTools(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder) { + return new BeansTools(instanceRepository, instanceWebClientBuilder.build()); + } + } diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java index 6a74cb66d02..bdb6421bba3 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java @@ -108,6 +108,37 @@ public static class Tools { */ private boolean operations = true; + /** + * Whether the {@code list-loggers}, {@code get-logger}, and + * {@code set-logger-level} tools are available. + */ + private boolean loggers = true; + + /** + * Whether the {@code get-thread-dump} tool is available. + */ + private boolean threadDump = true; + + /** + * Whether the {@code get-http-exchanges} tool is available. + */ + private boolean httpExchanges = true; + + /** + * Whether the {@code get-scheduled-tasks} tool is available. + */ + private boolean scheduledTasks = true; + + /** + * Whether the {@code list-caches} tool is available. + */ + private boolean caches = true; + + /** + * Whether the {@code list-beans} tool is available. + */ + private boolean beans = true; + } } diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java new file mode 100644 index 00000000000..0c48f46d710 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java @@ -0,0 +1,177 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for inspecting the Spring application context beans of registered Spring Boot + * applications. + * + *
    + *
  • {@code list-beans} — lists all beans in the application context, with optional + * filtering by name or type
  • + *
+ */ +public class BeansTools { + + private static final Logger log = LoggerFactory.getLogger(BeansTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code BeansTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public BeansTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Lists all beans in the Spring application context of the named application by + * calling its {@code /actuator/beans} endpoint. An optional filter restricts the + * result to bean names or types containing the given text. + * @param applicationName the registered application name (case-insensitive) + * @param filter optional case-insensitive substring; only beans whose name or type + * contains it are returned. When {@code null} or blank, all beans are returned. + * @return plain-text listing of beans with their types, scopes, and dependencies, or + * an error message + */ + @McpTool(name = "list-beans", + description = "List all beans in the Spring application context of a registered Spring Boot application " + + "via its /actuator/beans endpoint. Provide an optional 'filter' to return only beans whose " + + "name or type contains that text (case-insensitive). Returns bean name, type, scope, and " + + "resource. Useful for inspecting which beans are active in production. " + + "Requires the beans actuator endpoint to be exposed.") + public Mono listBeans( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "Optional case-insensitive substring; only beans whose name or type contain " + + "it are returned. Omit to return all beans.", required = false) String filter) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/beans"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatBeans(applicationName, filter, body)) + .doOnError((ex) -> log.warn("Failed to list beans for {}", applicationName, ex)) + .onErrorResume( + (ex) -> Mono.just("Error listing beans for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + @SuppressWarnings("unchecked") + private String formatBeans(String applicationName, String filter, Map body) { + Object contextsObj = body.get("contexts"); + if (!(contextsObj instanceof Map contexts) || contexts.isEmpty()) { + return "No beans available for " + applicationName + "."; + } + + boolean filtered = filter != null && !filter.isBlank(); + String needle = filtered ? filter.toLowerCase(Locale.ROOT) : null; + + StringBuilder sb = new StringBuilder("Beans for ").append(applicationName); + if (filtered) { + sb.append(" (filtered by \"").append(filter).append("\")"); + } + sb.append(":\n"); + + int totalMatches = 0; + + for (Map.Entry contextEntry : contexts.entrySet()) { + String contextId = String.valueOf(contextEntry.getKey()); + if (!(contextEntry.getValue() instanceof Map contextDetails)) { + continue; + } + Object beansObj = contextDetails.get("beans"); + if (!(beansObj instanceof Map beans)) { + continue; + } + + StringBuilder contextSb = new StringBuilder(); + int contextMatches = 0; + + for (Map.Entry beanEntry : beans.entrySet()) { + String beanName = String.valueOf(beanEntry.getKey()); + if (!(beanEntry.getValue() instanceof Map beanDetails)) { + continue; + } + Object typeObj = beanDetails.get("type"); + String type = (typeObj != null) ? String.valueOf(typeObj) : ""; + + if (filtered && !beanName.toLowerCase(Locale.ROOT).contains(needle) + && !type.toLowerCase(Locale.ROOT).contains(needle)) { + continue; + } + contextMatches++; + + contextSb.append(" ").append(beanName).append("\n"); + if (!type.isEmpty()) { + contextSb.append(" type: ").append(type).append("\n"); + } + Object scope = beanDetails.get("scope"); + if (scope != null && !"singleton".equals(scope)) { + contextSb.append(" scope: ").append(scope).append("\n"); + } + Object dependencies = beanDetails.get("dependencies"); + if (dependencies instanceof List deps && !deps.isEmpty()) { + contextSb.append(" dependencies: ").append(deps).append("\n"); + } + } + + if (contextMatches > 0) { + sb.append("\n[context: ").append(contextId).append("] (").append(contextMatches).append(" beans):\n"); + sb.append(contextSb); + totalMatches += contextMatches; + } + } + + if (filtered && totalMatches == 0) { + return "No beans matching '" + filter + "' for " + applicationName + "."; + } + if (totalMatches == 0) { + return "No beans available for " + applicationName + "."; + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java new file mode 100644 index 00000000000..449671a8ed1 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java @@ -0,0 +1,131 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for inspecting caches of registered Spring Boot applications. + * + *
    + *
  • {@code list-caches} — lists all caches registered in the application's + * {@code CacheManager} via the {@code /actuator/caches} endpoint
  • + *
+ */ +public class CachesTools { + + private static final Logger log = LoggerFactory.getLogger(CachesTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code CachesTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public CachesTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Lists all caches for the named application by calling its {@code /actuator/caches} + * endpoint. Returns cache names grouped by their {@code CacheManager}, which is + * useful for identifying stale or oversized caches. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text listing of caches grouped by cache manager, or an error message + */ + @McpTool(name = "list-caches", + description = "List all caches registered in the CacheManager(s) of a registered Spring Boot application " + + "via its /actuator/caches endpoint. Returns cache names grouped by their CacheManager. " + + "Useful for identifying stale or oversized caches. " + + "Requires the caches actuator endpoint to be exposed.") + public Mono listCaches(@McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/caches"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatCaches(applicationName, body)) + .doOnError((ex) -> log.warn("Failed to list caches for {}", applicationName, ex)) + .onErrorResume( + (ex) -> Mono.just("Error listing caches for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + @SuppressWarnings("unchecked") + private String formatCaches(String applicationName, Map body) { + Object cacheManagersObj = body.get("cacheManagers"); + if (!(cacheManagersObj instanceof Map cacheManagers) || cacheManagers.isEmpty()) { + return "No caches available for " + applicationName + "."; + } + + int totalCaches = 0; + StringBuilder sb = new StringBuilder("Caches for ").append(applicationName).append(":\n"); + + for (Map.Entry managerEntry : cacheManagers.entrySet()) { + String managerName = String.valueOf(managerEntry.getKey()); + sb.append("\n[").append(managerName).append("]:\n"); + + if (managerEntry.getValue() instanceof Map managerDetails) { + Object cachesObj = managerDetails.get("caches"); + if (cachesObj instanceof Map caches) { + for (Map.Entry cacheEntry : caches.entrySet()) { + totalCaches++; + String cacheName = String.valueOf(cacheEntry.getKey()); + sb.append(" - ").append(cacheName); + if (cacheEntry.getValue() instanceof Map cacheDetails) { + Object target = cacheDetails.get("target"); + if (target != null) { + sb.append(" (").append(target).append(")"); + } + } + sb.append("\n"); + } + } + } + } + + if (totalCaches == 0) { + return "No caches available for " + applicationName + "."; + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java new file mode 100644 index 00000000000..2d495b7d8d5 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java @@ -0,0 +1,159 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for inspecting recent HTTP exchanges of registered Spring Boot applications. + * + *
    + *
  • {@code get-http-exchanges} — retrieves recent HTTP request/response records from + * the application's {@code /actuator/httpexchanges} endpoint
  • + *
+ */ +public class HttpExchangesTools { + + private static final Logger log = LoggerFactory.getLogger(HttpExchangesTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final int DEFAULT_LIMIT = 20; + + private static final int MAX_LIMIT = 100; + + private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code HttpExchangesTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public HttpExchangesTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Retrieves recent HTTP exchanges for the named application by calling its + * {@code /actuator/httpexchanges} endpoint. Returns a summary of recent HTTP requests + * and their responses including method, URI, status code, and duration. + * @param applicationName the registered application name (case-insensitive) + * @param limit maximum number of exchanges to return (default 20, max 100) + * @return plain-text listing of recent HTTP exchanges, or an error message + */ + @McpTool(name = "get-http-exchanges", + description = "Retrieve recent HTTP exchanges (requests and responses) for a registered Spring Boot " + + "application via its /actuator/httpexchanges endpoint. Returns method, URI, status code, " + + "and duration for each exchange. Useful for tracing requests and debugging client errors. " + + "Requires management.httpexchanges.recording.enabled=true and the httpexchanges actuator " + + "endpoint to be exposed (Spring Boot 3.x).") + public Mono getHttpExchanges( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "Maximum number of exchanges to return (default 20, max 100)", + required = false) Integer limit) { + int effectiveLimit = (limit != null) ? Math.min(Math.max(limit, 1), MAX_LIMIT) : DEFAULT_LIMIT; + + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/httpexchanges"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatExchanges(applicationName, body, effectiveLimit)) + .doOnError((ex) -> log.warn("Failed to get HTTP exchanges for {}", applicationName, ex)) + .onErrorResume((ex) -> Mono + .just("Error retrieving HTTP exchanges for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + @SuppressWarnings("unchecked") + private String formatExchanges(String applicationName, Map body, int limit) { + Object exchangesObj = body.get("exchanges"); + if (!(exchangesObj instanceof List exchanges) || exchanges.isEmpty()) { + return "No HTTP exchanges recorded for " + applicationName + "."; + } + + int total = exchanges.size(); + int from = Math.max(0, total - limit); + List recent = exchanges.subList(from, total); + + StringBuilder sb = new StringBuilder("Recent HTTP exchanges for ").append(applicationName) + .append(" (showing ") + .append(recent.size()) + .append(" of ") + .append(total) + .append("):\n"); + + for (Object exchangeObj : recent) { + if (!(exchangeObj instanceof Map exchange)) { + continue; + } + Object tsObj = exchange.get("timestamp"); + String timestamp = (tsObj != null) ? String.valueOf(tsObj) : ""; + Object requestObj = exchange.get("request"); + Object responseObj = exchange.get("response"); + Object timeTaken = exchange.get("timeTaken"); + + String method = ""; + String uri = ""; + if (requestObj instanceof Map request) { + Object methodObj = request.get("method"); + Object uriObj = request.get("uri"); + method = (methodObj != null) ? String.valueOf(methodObj) : ""; + uri = (uriObj != null) ? String.valueOf(uriObj) : ""; + } + + String status = ""; + if (responseObj instanceof Map response) { + Object statusObj = response.get("status"); + status = (statusObj != null) ? String.valueOf(statusObj) : ""; + } + + sb.append(" ").append(method).append(" ").append(uri).append(" -> ").append(status); + if (timeTaken != null) { + sb.append(" (").append(timeTaken).append(")"); + } + if (!timestamp.isEmpty()) { + sb.append(" [").append(timestamp).append("]"); + } + sb.append("\n"); + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java new file mode 100644 index 00000000000..b49acfa6842 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java @@ -0,0 +1,248 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for querying and configuring log levels of registered Spring Boot + * applications. + * + *
    + *
  • {@code list-loggers} — lists all configured loggers and their effective log + * levels
  • + *
  • {@code get-logger} — retrieves the configured and effective log level for a single + * logger
  • + *
  • {@code set-logger-level} — changes the log level of a logger at runtime
  • + *
+ */ +public class LoggersTools { + + private static final Logger log = LoggerFactory.getLogger(LoggersTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code LoggersTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public LoggersTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Lists all loggers and their effective log levels for the named application by + * calling its {@code /actuator/loggers} endpoint. An optional filter restricts the + * result to logger names containing the given text. + * @param applicationName the registered application name (case-insensitive) + * @param filter optional case-insensitive substring; only logger names containing it + * are returned. When {@code null} or blank, all loggers are returned. + * @return plain-text listing of loggers with their levels, or an error message + */ + @McpTool(name = "list-loggers", + description = "List all loggers and their effective log levels for a registered Spring Boot application " + + "by calling its /actuator/loggers endpoint. Provide an optional 'filter' to return only " + + "logger names containing that text (case-insensitive). Use set-logger-level to change a " + + "level at runtime. Requires the loggers actuator endpoint to be exposed.") + public Mono listLoggers( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "Optional case-insensitive substring; only logger names containing it are " + + "returned. Omit to return all loggers.", required = false) String filter) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/loggers"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatLoggers(applicationName, filter, body)) + .doOnError((ex) -> log.warn("Failed to list loggers for {}", applicationName, ex)) + .onErrorResume( + (ex) -> Mono.just("Error listing loggers for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + /** + * Retrieves the configured and effective log level for a single logger by calling the + * {@code /actuator/loggers/{loggerName}} endpoint. + * @param applicationName the registered application name (case-insensitive) + * @param loggerName the fully-qualified logger name (e.g. + * {@code com.example.MyService}) + * @return plain-text level information, or an error message + */ + @McpTool(name = "get-logger", + description = "Retrieve the configured and effective log level for a single logger in a registered " + + "Spring Boot application. Use list-loggers to discover logger names. " + + "Requires the loggers actuator endpoint to be exposed.") + public Mono getLogger( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "The fully-qualified logger name (e.g. com.example.MyService)", + required = true) String loggerName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/loggers/" + loggerName; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatLogger(applicationName, loggerName, body)) + .doOnError((ex) -> log.warn("Failed to get logger {} for {}", loggerName, applicationName, ex)) + .onErrorResume((ex) -> Mono.just("Error retrieving logger '" + loggerName + "' for " + applicationName + + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + /** + * Changes the log level of a logger at runtime for the named application by calling + * {@code POST /actuator/loggers/{loggerName}}. Pass {@code null} or {@code "null"} as + * the level to reset to the inherited level. + * @param applicationName the registered application name (case-insensitive) + * @param loggerName the fully-qualified logger name (e.g. + * {@code com.example.MyService}) + * @param level the log level to set (TRACE, DEBUG, INFO, WARN, ERROR, OFF) or null to + * reset + * @return confirmation message, or an error message + */ + @McpTool(name = "set-logger-level", + description = "Change the log level of a logger at runtime for a registered Spring Boot application " + + "via POST /actuator/loggers/{loggerName}. Valid levels: TRACE, DEBUG, INFO, WARN, ERROR, OFF. " + + "Pass null to reset to the inherited level. Changes are lost on restart. " + + "Requires the loggers actuator endpoint to be exposed.") + public Mono setLoggerLevel( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "The fully-qualified logger name (e.g. com.example.MyService)", + required = true) String loggerName, + @McpToolParam(description = "The log level to set: TRACE, DEBUG, INFO, WARN, ERROR, OFF, or null to reset", + required = true) String level) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/loggers/" + loggerName; + String body = (level == null || "null".equalsIgnoreCase(level)) ? "{}" + : "{\"configuredLevel\":\"" + level.toUpperCase(Locale.ROOT) + "\"}"; + return this.instanceWebClient.instance(instance) + .post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(body) + .retrieve() + .toBodilessEntity() + .timeout(TIMEOUT) + .map((response) -> { + int status = response.getStatusCode().value(); + if ((status >= 200) && (status < 300)) { + String effectiveLevel = (level == null || "null".equalsIgnoreCase(level)) ? "inherited" + : level.toUpperCase(Locale.ROOT); + return "Log level for '" + loggerName + "' in " + applicationName + " set to " + effectiveLevel + + "."; + } + return "Setting log level returned unexpected status " + status + " for " + applicationName + "."; + }) + .doOnError((ex) -> log.warn("Failed to set log level for {} in {}", loggerName, applicationName, ex)) + .onErrorResume((ex) -> Mono.just("Error setting log level for '" + loggerName + "' in " + + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + @SuppressWarnings("unchecked") + private String formatLoggers(String applicationName, String filter, Map body) { + Object loggersObj = body.get("loggers"); + if (!(loggersObj instanceof Map loggersMap) || loggersMap.isEmpty()) { + return "No loggers available for " + applicationName + "."; + } + + boolean filtered = filter != null && !filter.isBlank(); + String needle = filtered ? filter.toLowerCase(Locale.ROOT) : null; + + List levels = null; + Object levelsObj = body.get("levels"); + if (levelsObj instanceof List l) { + levels = (List) l; + } + + StringBuilder sb = new StringBuilder("Loggers for ").append(applicationName); + if (filtered) { + sb.append(" (filtered by \"").append(filter).append("\")"); + } + if (levels != null) { + sb.append(" — available levels: ").append(levels); + } + sb.append(":\n"); + + int count = 0; + for (Map.Entry entry : loggersMap.entrySet()) { + String name = String.valueOf(entry.getKey()); + if (filtered && !name.toLowerCase(Locale.ROOT).contains(needle)) { + continue; + } + count++; + sb.append(" ").append(name); + if (entry.getValue() instanceof Map loggerInfo) { + Object effective = loggerInfo.get("effectiveLevel"); + Object configured = loggerInfo.get("configuredLevel"); + sb.append(": effective=").append((effective != null) ? effective : "inherited"); + if (configured != null) { + sb.append(", configured=").append(configured); + } + } + sb.append("\n"); + } + + if (filtered && count == 0) { + return "No loggers matching '" + filter + "' for " + applicationName + "."; + } + return sb.toString().trim(); + } + + private String formatLogger(String applicationName, String loggerName, Map body) { + StringBuilder sb = new StringBuilder(applicationName).append(" — logger '").append(loggerName).append("':\n"); + Object effective = body.get("effectiveLevel"); + Object configured = body.get("configuredLevel"); + sb.append(" effectiveLevel: ").append((effective != null) ? effective : "inherited").append("\n"); + if (configured != null) { + sb.append(" configuredLevel: ").append(configured).append("\n"); + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java new file mode 100644 index 00000000000..0f8a0a2373f --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java @@ -0,0 +1,172 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for inspecting scheduled tasks of registered Spring Boot applications. + * + *
    + *
  • {@code get-scheduled-tasks} — lists all {@code @Scheduled} methods and their + * configuration via the application's {@code /actuator/scheduledtasks} endpoint
  • + *
+ */ +public class ScheduledTasksTools { + + private static final Logger log = LoggerFactory.getLogger(ScheduledTasksTools.class); + + private static final Duration TIMEOUT = Duration.ofMillis(450); + + private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code ScheduledTasksTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public ScheduledTasksTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Lists all scheduled tasks for the named application by calling its + * {@code /actuator/scheduledtasks} endpoint. Returns a summary of all + * {@code @Scheduled} methods, including their cron expressions, fixed-rate, and + * fixed-delay configurations. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text listing of scheduled tasks, or an error message + */ + @McpTool(name = "get-scheduled-tasks", + description = "List all scheduled tasks (@Scheduled methods) and their configuration for a registered " + + "Spring Boot application via its /actuator/scheduledtasks endpoint. Returns cron expressions, " + + "fixed-rate, and fixed-delay settings. Useful for verifying that batch jobs and cron tasks " + + "are configured as expected. Requires the scheduledtasks actuator endpoint to be exposed.") + public Mono getScheduledTasks( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/scheduledtasks"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatScheduledTasks(applicationName, body)) + .doOnError((ex) -> log.warn("Failed to get scheduled tasks for {}", applicationName, ex)) + .onErrorResume((ex) -> Mono + .just("Error retrieving scheduled tasks for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + @SuppressWarnings("unchecked") + private String formatScheduledTasks(String applicationName, Map body) { + StringBuilder sb = new StringBuilder("Scheduled tasks for ").append(applicationName).append(":\n"); + int total = 0; + + // cron tasks + Object cronObj = body.get("cron"); + if (cronObj instanceof List cronTasks && !cronTasks.isEmpty()) { + sb.append("\nCron tasks (").append(cronTasks.size()).append("):\n"); + for (Object taskObj : cronTasks) { + if (!(taskObj instanceof Map task)) { + continue; + } + total++; + appendTaskRunnable(sb, task); + Object expression = task.get("expression"); + if (expression != null) { + sb.append(" expression: ").append(expression).append("\n"); + } + } + } + + // fixed-rate tasks + Object fixedRateObj = body.get("fixedRate"); + if (fixedRateObj instanceof List fixedRateTasks && !fixedRateTasks.isEmpty()) { + sb.append("\nFixed-rate tasks (").append(fixedRateTasks.size()).append("):\n"); + for (Object taskObj : fixedRateTasks) { + if (!(taskObj instanceof Map task)) { + continue; + } + total++; + appendTaskRunnable(sb, task); + appendIntervalDetails(sb, task); + } + } + + // fixed-delay tasks + Object fixedDelayObj = body.get("fixedDelay"); + if (fixedDelayObj instanceof List fixedDelayTasks && !fixedDelayTasks.isEmpty()) { + sb.append("\nFixed-delay tasks (").append(fixedDelayTasks.size()).append("):\n"); + for (Object taskObj : fixedDelayTasks) { + if (!(taskObj instanceof Map task)) { + continue; + } + total++; + appendTaskRunnable(sb, task); + appendIntervalDetails(sb, task); + } + } + + if (total == 0) { + return "No scheduled tasks found for " + applicationName + "."; + } + return sb.toString().trim(); + } + + private void appendTaskRunnable(StringBuilder sb, Map task) { + Object runnableObj = task.get("runnable"); + if (runnableObj instanceof Map runnable) { + sb.append(" ").append(runnable.get("target")).append("\n"); + } + } + + private void appendIntervalDetails(StringBuilder sb, Map task) { + Object interval = task.get("interval"); + Object initialDelay = task.get("initialDelay"); + if (interval != null) { + sb.append(" interval: ").append(interval).append("ms"); + } + if (initialDelay != null) { + sb.append(", initialDelay: ").append(initialDelay).append("ms"); + } + if (interval != null || initialDelay != null) { + sb.append("\n"); + } + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java new file mode 100644 index 00000000000..a93f4e8e883 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java @@ -0,0 +1,162 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.core.ParameterizedTypeReference; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * MCP tools for capturing thread dumps from registered Spring Boot applications. + * + *
    + *
  • {@code get-thread-dump} — retrieves a thread dump from the application's + * {@code /actuator/threaddump} endpoint
  • + *
+ */ +public class ThreadDumpTools { + + private static final Logger log = LoggerFactory.getLogger(ThreadDumpTools.class); + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + /** + * Creates a new {@code ThreadDumpTools} instance. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + */ + public ThreadDumpTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + } + + /** + * Retrieves a thread dump for the named application by calling its + * {@code /actuator/threaddump} endpoint. Returns a human-readable summary of all + * threads including their state and stack traces. Useful for diagnosing deadlocks, + * hung threads, and thread pool saturation. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text thread dump summary, or an error message + */ + @McpTool(name = "get-thread-dump", + description = "Retrieve a thread dump from a registered Spring Boot application via its " + + "/actuator/threaddump endpoint. Returns all threads with their state and stack traces. " + + "Useful for diagnosing deadlocks, hung threads, and thread pool saturation. " + + "Requires the threaddump actuator endpoint to be exposed.") + public Mono getThreadDump(@McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/threaddump"; + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(RESPONSE_TYPE) + .timeout(TIMEOUT) + .map((body) -> formatThreadDump(applicationName, body)) + .doOnError((ex) -> log.warn("Failed to get thread dump for {}", applicationName, ex)) + .onErrorResume((ex) -> Mono + .just("Error retrieving thread dump for " + applicationName + ": " + ex.getMessage())); + }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + @SuppressWarnings("unchecked") + private String formatThreadDump(String applicationName, Map body) { + Object threadsObj = body.get("threads"); + if (!(threadsObj instanceof List threads) || threads.isEmpty()) { + return "No thread dump available for " + applicationName + "."; + } + + // Aggregate thread counts by state + java.util.Map stateCounts = new java.util.LinkedHashMap<>(); + StringBuilder detail = new StringBuilder(); + + for (Object threadObj : threads) { + if (!(threadObj instanceof Map thread)) { + continue; + } + Object nameObj = thread.get("threadName"); + Object stateObj = thread.get("threadState"); + String threadName = (nameObj != null) ? String.valueOf(nameObj) : "unknown"; + String threadState = (stateObj != null) ? String.valueOf(stateObj) : "UNKNOWN"; + boolean blocked = Boolean.TRUE.equals(thread.get("blocked")); + boolean suspended = Boolean.TRUE.equals(thread.get("suspended")); + + stateCounts.merge(threadState, 1, Integer::sum); + + detail.append("\n[").append(threadState); + if (blocked) { + detail.append(", BLOCKED"); + } + if (suspended) { + detail.append(", SUSPENDED"); + } + detail.append("] ").append(threadName).append("\n"); + + Object stackTrace = thread.get("stackTrace"); + if (stackTrace instanceof List frames && !frames.isEmpty()) { + int limit = Math.min(frames.size(), 8); + for (int i = 0; i < limit; i++) { + Object frame = frames.get(i); + if (frame instanceof Map f) { + Object classNameObj = f.get("className"); + Object methodNameObj = f.get("methodName"); + String className = (classNameObj != null) ? String.valueOf(classNameObj) : ""; + String methodName = (methodNameObj != null) ? String.valueOf(methodNameObj) : ""; + Object lineNumber = f.get("lineNumber"); + detail.append(" at ").append(className).append(".").append(methodName); + if (lineNumber != null) { + detail.append("(line:").append(lineNumber).append(")"); + } + detail.append("\n"); + } + } + if (frames.size() > limit) { + detail.append(" ... ").append(frames.size() - limit).append(" more\n"); + } + } + } + + StringBuilder sb = new StringBuilder("Thread dump for ").append(applicationName) + .append(" (") + .append(threads.size()) + .append(" threads):\n"); + sb.append("Thread states: "); + stateCounts.forEach((state, count) -> sb.append(state).append("=").append(count).append(" ")); + sb.append("\n"); + sb.append(detail); + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java index 92c7efe9a17..e324ff9eea0 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java @@ -24,11 +24,17 @@ import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; +import de.codecentric.boot.admin.server.mcp.tools.BeansTools; +import de.codecentric.boot.admin.server.mcp.tools.CachesTools; import de.codecentric.boot.admin.server.mcp.tools.EnvTools; import de.codecentric.boot.admin.server.mcp.tools.HealthTools; +import de.codecentric.boot.admin.server.mcp.tools.HttpExchangesTools; +import de.codecentric.boot.admin.server.mcp.tools.LoggersTools; import de.codecentric.boot.admin.server.mcp.tools.LogsTools; import de.codecentric.boot.admin.server.mcp.tools.MetricsTools; import de.codecentric.boot.admin.server.mcp.tools.OperationsTools; +import de.codecentric.boot.admin.server.mcp.tools.ScheduledTasksTools; +import de.codecentric.boot.admin.server.mcp.tools.ThreadDumpTools; import de.codecentric.boot.admin.server.web.client.InstanceWebClient; import static org.assertj.core.api.Assertions.assertThat; @@ -49,6 +55,12 @@ void mcpDisabled_noToolBeansCreated() { assertThat(context).doesNotHaveBean(EnvTools.class); assertThat(context).doesNotHaveBean(LogsTools.class); assertThat(context).doesNotHaveBean(OperationsTools.class); + assertThat(context).doesNotHaveBean(LoggersTools.class); + assertThat(context).doesNotHaveBean(ThreadDumpTools.class); + assertThat(context).doesNotHaveBean(HttpExchangesTools.class); + assertThat(context).doesNotHaveBean(ScheduledTasksTools.class); + assertThat(context).doesNotHaveBean(CachesTools.class); + assertThat(context).doesNotHaveBean(BeansTools.class); }); } @@ -61,6 +73,12 @@ void mcpEnabled_allToolBeansCreatedByDefault() { assertThat(context).hasSingleBean(EnvTools.class); assertThat(context).hasSingleBean(LogsTools.class); assertThat(context).hasSingleBean(OperationsTools.class); + assertThat(context).hasSingleBean(LoggersTools.class); + assertThat(context).hasSingleBean(ThreadDumpTools.class); + assertThat(context).hasSingleBean(HttpExchangesTools.class); + assertThat(context).hasSingleBean(ScheduledTasksTools.class); + assertThat(context).hasSingleBean(CachesTools.class); + assertThat(context).hasSingleBean(BeansTools.class); }); } diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java new file mode 100644 index 00000000000..cb8b86e2108 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java @@ -0,0 +1,155 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class BeansToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private BeansTools beansTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.beansTools = new BeansTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + private Instance instance(String name) { + return Instance.create(InstanceId.of("id-" + name)) + .register(Registration.create(name, this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + } + + @Test + void listBeans_returnsBeansGroupedByContext() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/beans")).willReturn(okJson("{\"contexts\":{" + + "\"application\":{\"beans\":{" + + "\"paymentService\":{\"type\":\"com.example.PaymentService\",\"scope\":\"singleton\",\"dependencies\":[\"paymentRepository\"]}," + + "\"paymentRepository\":{\"type\":\"com.example.PaymentRepository\",\"scope\":\"singleton\",\"dependencies\":[]}" + + "}}}}"))); + + StepVerifier.create(this.beansTools.listBeans("payment-service", null)).assertNext((result) -> { + assertThat(result).contains("Beans for payment-service"); + assertThat(result).contains("[context: application]"); + assertThat(result).contains("paymentService"); + assertThat(result).contains("com.example.PaymentService"); + assertThat(result).contains("paymentRepository"); + }).verifyComplete(); + } + + @Test + void listBeans_withFilter_returnsOnlyMatchingBeans() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/beans")).willReturn(okJson("{\"contexts\":{" + + "\"application\":{\"beans\":{" + + "\"paymentService\":{\"type\":\"com.example.PaymentService\",\"scope\":\"singleton\",\"dependencies\":[]}," + + "\"dataSource\":{\"type\":\"javax.sql.DataSource\",\"scope\":\"singleton\",\"dependencies\":[]}" + + "}}}}"))); + + StepVerifier.create(this.beansTools.listBeans("payment-service", "payment")).assertNext((result) -> { + assertThat(result).contains("filtered by \"payment\""); + assertThat(result).contains("paymentService"); + assertThat(result).doesNotContain("dataSource"); + }).verifyComplete(); + } + + @Test + void listBeans_withFilter_noMatches_returnsMessage() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/beans")).willReturn(okJson("{\"contexts\":{" + + "\"application\":{\"beans\":{" + + "\"paymentService\":{\"type\":\"com.example.PaymentService\",\"scope\":\"singleton\",\"dependencies\":[]}" + + "}}}}"))); + + StepVerifier.create(this.beansTools.listBeans("payment-service", "nomatch")) + .assertNext((result) -> assertThat(result).isEqualTo("No beans matching 'nomatch' for payment-service.")) + .verifyComplete(); + } + + @Test + void listBeans_noContexts_returnsNoBeansMessage() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/beans")).willReturn(okJson("{\"contexts\":{}}"))); + + StepVerifier.create(this.beansTools.listBeans("order-service", null)) + .assertNext((result) -> assertThat(result).isEqualTo("No beans available for order-service.")) + .verifyComplete(); + } + + @Test + void listBeans_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.beansTools.listBeans("ghost", null)) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java new file mode 100644 index 00000000000..4bcc879f522 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CachesToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private CachesTools cachesTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.cachesTools = new CachesTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + private Instance instance(String name) { + return Instance.create(InstanceId.of("id-" + name)) + .register(Registration.create(name, this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + } + + @Test + void listCaches_returnsCachesGroupedByCacheManager() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/caches")) + .willReturn(okJson("{\"cacheManagers\":{" + "\"cacheManager\":{\"caches\":{" + + "\"payments\":{\"target\":\"java.util.concurrent.ConcurrentHashMap\"}," + + "\"rates\":{\"target\":\"java.util.concurrent.ConcurrentHashMap\"}" + "}}}}"))); + + StepVerifier.create(this.cachesTools.listCaches("payment-service")).assertNext((result) -> { + assertThat(result).contains("Caches for payment-service"); + assertThat(result).contains("[cacheManager]"); + assertThat(result).contains("payments"); + assertThat(result).contains("rates"); + assertThat(result).contains("ConcurrentHashMap"); + }).verifyComplete(); + } + + @Test + void listCaches_noCacheManagers_returnsNoCachesMessage() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/caches")).willReturn(okJson("{\"cacheManagers\":{}}"))); + + StepVerifier.create(this.cachesTools.listCaches("order-service")) + .assertNext((result) -> assertThat(result).isEqualTo("No caches available for order-service.")) + .verifyComplete(); + } + + @Test + void listCaches_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.cachesTools.listCaches("ghost")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java new file mode 100644 index 00000000000..84f672259fd --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class HttpExchangesToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private HttpExchangesTools httpExchangesTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.httpExchangesTools = new HttpExchangesTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + private Instance instance(String name) { + return Instance.create(InstanceId.of("id-" + name)) + .register(Registration.create(name, this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + } + + @Test + void getHttpExchanges_returnsRecentExchanges() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/httpexchanges")) + .willReturn(okJson("{\"exchanges\":[" + "{\"timestamp\":\"2026-01-01T10:00:00Z\"," + + "\"request\":{\"method\":\"GET\",\"uri\":\"http://localhost/api/payments\"}," + + "\"response\":{\"status\":200}," + "\"timeTaken\":\"PT0.042S\"}," + + "{\"timestamp\":\"2026-01-01T10:00:01Z\"," + + "\"request\":{\"method\":\"POST\",\"uri\":\"http://localhost/api/payments\"}," + + "\"response\":{\"status\":422}," + "\"timeTaken\":\"PT0.015S\"}" + "]}"))); + + StepVerifier.create(this.httpExchangesTools.getHttpExchanges("payment-service", null)).assertNext((result) -> { + assertThat(result).contains("Recent HTTP exchanges for payment-service"); + assertThat(result).contains("GET http://localhost/api/payments -> 200"); + assertThat(result).contains("POST http://localhost/api/payments -> 422"); + assertThat(result).contains("PT0.042S"); + }).verifyComplete(); + } + + @Test + void getHttpExchanges_withLimit_returnsLimitedExchanges() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/httpexchanges")).willReturn(okJson( + "{\"exchanges\":[" + "{\"request\":{\"method\":\"GET\",\"uri\":\"/a\"},\"response\":{\"status\":200}}," + + "{\"request\":{\"method\":\"GET\",\"uri\":\"/b\"},\"response\":{\"status\":200}}," + + "{\"request\":{\"method\":\"GET\",\"uri\":\"/c\"},\"response\":{\"status\":200}}" + "]}"))); + + StepVerifier.create(this.httpExchangesTools.getHttpExchanges("order-service", 2)).assertNext((result) -> { + assertThat(result).contains("showing 2 of 3"); + assertThat(result).contains("/b"); + assertThat(result).contains("/c"); + assertThat(result).doesNotContain("/a"); + }).verifyComplete(); + } + + @Test + void getHttpExchanges_noExchanges_returnsNoContentMessage() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/httpexchanges")).willReturn(okJson("{\"exchanges\":[]}"))); + + StepVerifier.create(this.httpExchangesTools.getHttpExchanges("order-service", null)) + .assertNext((result) -> assertThat(result).isEqualTo("No HTTP exchanges recorded for order-service.")) + .verifyComplete(); + } + + @Test + void getHttpExchanges_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.httpExchangesTools.getHttpExchanges("ghost", null)) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java new file mode 100644 index 00000000000..b5222cde175 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java @@ -0,0 +1,203 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.noContent; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoggersToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private LoggersTools loggersTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.loggersTools = new LoggersTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + private Instance instance(String name) { + return Instance.create(InstanceId.of("id-" + name)) + .register(Registration.create(name, this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + } + + @Test + void listLoggers_returnsLoggersGroupedByLevel() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/loggers")) + .willReturn(okJson("{\"levels\":[\"TRACE\",\"DEBUG\",\"INFO\",\"WARN\",\"ERROR\",\"OFF\"]," + + "\"loggers\":{" + "\"ROOT\":{\"configuredLevel\":\"INFO\",\"effectiveLevel\":\"INFO\"}," + + "\"com.example\":{\"configuredLevel\":null,\"effectiveLevel\":\"INFO\"}" + "}}"))); + + StepVerifier.create(this.loggersTools.listLoggers("payment-service", null)).assertNext((result) -> { + assertThat(result).contains("Loggers for payment-service"); + assertThat(result).contains("ROOT"); + assertThat(result).contains("effective=INFO"); + assertThat(result).contains("com.example"); + }).verifyComplete(); + } + + @Test + void listLoggers_withFilter_returnsOnlyMatchingLoggers() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/loggers")) + .willReturn(okJson("{\"loggers\":{" + "\"ROOT\":{\"effectiveLevel\":\"INFO\"}," + + "\"com.example.PaymentService\":{\"effectiveLevel\":\"DEBUG\"}," + + "\"org.springframework\":{\"effectiveLevel\":\"WARN\"}" + "}}"))); + + StepVerifier.create(this.loggersTools.listLoggers("payment-service", "example")).assertNext((result) -> { + assertThat(result).contains("filtered by \"example\""); + assertThat(result).contains("com.example.PaymentService"); + assertThat(result).doesNotContain("ROOT"); + assertThat(result).doesNotContain("org.springframework"); + }).verifyComplete(); + } + + @Test + void listLoggers_withFilter_noMatches_returnsMessage() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/loggers")) + .willReturn(okJson("{\"loggers\":{\"ROOT\":{\"effectiveLevel\":\"INFO\"}}}"))); + + StepVerifier.create(this.loggersTools.listLoggers("payment-service", "nomatch")) + .assertNext((result) -> assertThat(result).isEqualTo("No loggers matching 'nomatch' for payment-service.")) + .verifyComplete(); + } + + @Test + void listLoggers_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.loggersTools.listLoggers("ghost", null)) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + + @Test + void getLogger_returnsEffectiveAndConfiguredLevel() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/loggers/com.example.OrderService")) + .willReturn(okJson("{\"configuredLevel\":\"DEBUG\",\"effectiveLevel\":\"DEBUG\"}"))); + + StepVerifier.create(this.loggersTools.getLogger("order-service", "com.example.OrderService")) + .assertNext((result) -> { + assertThat(result).contains("order-service"); + assertThat(result).contains("com.example.OrderService"); + assertThat(result).contains("effectiveLevel: DEBUG"); + assertThat(result).contains("configuredLevel: DEBUG"); + }) + .verifyComplete(); + } + + @Test + void getLogger_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.loggersTools.getLogger("ghost", "com.example.Foo")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + + @Test + void setLoggerLevel_success_returnsConfirmation() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(post(urlEqualTo("/actuator/loggers/com.example.PaymentService")).willReturn(noContent())); + + StepVerifier.create(this.loggersTools.setLoggerLevel("payment-service", "com.example.PaymentService", "DEBUG")) + .assertNext((result) -> { + assertThat(result).contains("com.example.PaymentService"); + assertThat(result).contains("payment-service"); + assertThat(result).contains("DEBUG"); + }) + .verifyComplete(); + } + + @Test + void setLoggerLevel_nullLevel_resetsToInherited() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(post(urlEqualTo("/actuator/loggers/com.example.PaymentService")).willReturn(noContent())); + + StepVerifier.create(this.loggersTools.setLoggerLevel("payment-service", "com.example.PaymentService", null)) + .assertNext((result) -> assertThat(result).contains("inherited")) + .verifyComplete(); + } + + @Test + void setLoggerLevel_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.loggersTools.setLoggerLevel("ghost", "com.example.Foo", "DEBUG")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java new file mode 100644 index 00000000000..ce58a87d058 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java @@ -0,0 +1,126 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ScheduledTasksToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private ScheduledTasksTools scheduledTasksTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.scheduledTasksTools = new ScheduledTasksTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + private Instance instance(String name) { + return Instance.create(InstanceId.of("id-" + name)) + .register(Registration.create(name, this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + } + + @Test + void getScheduledTasks_returnsCronAndFixedRateTasks() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/scheduledtasks")).willReturn(okJson("{" + + "\"cron\":[{\"runnable\":{\"target\":\"com.example.BatchJob.runNightly\"},\"expression\":\"0 0 2 * * *\"}]," + + "\"fixedRate\":[{\"runnable\":{\"target\":\"com.example.HeartbeatTask.ping\"},\"interval\":30000,\"initialDelay\":0}]," + + "\"fixedDelay\":[]" + "}"))); + + StepVerifier.create(this.scheduledTasksTools.getScheduledTasks("payment-service")).assertNext((result) -> { + assertThat(result).contains("Scheduled tasks for payment-service"); + assertThat(result).contains("Cron tasks"); + assertThat(result).contains("com.example.BatchJob.runNightly"); + assertThat(result).contains("0 0 2 * * *"); + assertThat(result).contains("Fixed-rate tasks"); + assertThat(result).contains("com.example.HeartbeatTask.ping"); + assertThat(result).contains("30000"); + }).verifyComplete(); + } + + @Test + void getScheduledTasks_noTasks_returnsNoTasksMessage() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/scheduledtasks")) + .willReturn(okJson("{\"cron\":[],\"fixedRate\":[],\"fixedDelay\":[]}"))); + + StepVerifier.create(this.scheduledTasksTools.getScheduledTasks("order-service")) + .assertNext((result) -> assertThat(result).isEqualTo("No scheduled tasks found for order-service.")) + .verifyComplete(); + } + + @Test + void getScheduledTasks_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.scheduledTasksTools.getScheduledTasks("ghost")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java new file mode 100644 index 00000000000..43764701d2a --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java @@ -0,0 +1,126 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ThreadDumpToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private ThreadDumpTools threadDumpTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(15)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); + this.threadDumpTools = new ThreadDumpTools(this.instanceRepository, instanceWebClient); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + private Instance instance(String name) { + return Instance.create(InstanceId.of("id-" + name)) + .register(Registration.create(name, this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + } + + @Test + void getThreadDump_returnsThreadSummaryWithStates() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/threaddump")).willReturn(okJson("{\"threads\":[" + + "{\"threadName\":\"main\",\"threadState\":\"RUNNABLE\",\"blocked\":false,\"suspended\":false," + + "\"stackTrace\":[{\"className\":\"com.example.Main\",\"methodName\":\"run\",\"lineNumber\":42}]}," + + "{\"threadName\":\"worker-1\",\"threadState\":\"WAITING\",\"blocked\":false,\"suspended\":false," + + "\"stackTrace\":[]}" + "]}"))); + + StepVerifier.create(this.threadDumpTools.getThreadDump("payment-service")).assertNext((result) -> { + assertThat(result).contains("Thread dump for payment-service"); + assertThat(result).contains("2 threads"); + assertThat(result).contains("RUNNABLE=1"); + assertThat(result).contains("WAITING=1"); + assertThat(result).contains("main"); + assertThat(result).contains("worker-1"); + assertThat(result).contains("com.example.Main.run"); + }).verifyComplete(); + } + + @Test + void getThreadDump_emptyThreads_returnsNoContentMessage() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/threaddump")).willReturn(okJson("{\"threads\":[]}"))); + + StepVerifier.create(this.threadDumpTools.getThreadDump("order-service")) + .assertNext((result) -> assertThat(result).isEqualTo("No thread dump available for order-service.")) + .verifyComplete(); + } + + @Test + void getThreadDump_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.threadDumpTools.getThreadDump("ghost")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} From 25285e4174786f64023db99ee9af22fba4bcb828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 18:44:34 +0200 Subject: [PATCH 15/29] feat(mcp): update MCP label and enhance tool documentation structure --- .../src/site/docs/07-mcp/_category_.json | 2 +- .../src/site/docs/07-mcp/index.md | 110 ++++++++++++++---- 2 files changed, 88 insertions(+), 24 deletions(-) diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json b/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json index 9409e900c19..b80211434da 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json @@ -1,4 +1,4 @@ { "position": 7, - "label": "MCP Integration (experimental)" + "label": "MCP" } diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index de009af0665..5f44ed839ca 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -33,26 +33,90 @@ calls against your registered applications. Responses are formatted as plain tex ## Available Tools -| Tool | Description | -|-------------------------|---------------------------------------------------------------------------------------------------------------------------| -| `list-applications` | Lists all registered applications with status and management URL | -| `get-status` | Returns the cached status (UP/DOWN/UNKNOWN) for a named application | -| `get-health` | Fetches full health details from `/actuator/health` | -| `list-metrics` | Lists all available metric names for a named application | -| `get-metrics` | Fetches the current value of a specific metric | -| `get-env` | Resolves a single configuration property or environment variable via `/actuator/env/{name}` | -| `list-env` | Lists environment properties grouped by property source via `/actuator/env`, with an optional name filter | -| `get-logs` | Returns the last N lines from `/actuator/logfile` | -| `restart-application` | Restarts an application via `/actuator/restart` | -| `refresh-configuration` | Refreshes configuration via `/actuator/refresh` | -| `list-loggers` | Lists all loggers and their effective log levels via `/actuator/loggers`, with an optional name filter | -| `get-logger` | Returns the configured and effective log level for a single logger | -| `set-logger-level` | Changes a logger's level at runtime via `POST /actuator/loggers/{name}`; resets to inherited when level is `null` | -| `get-thread-dump` | Captures a thread dump via `/actuator/threaddump`; useful for diagnosing deadlocks and hung threads | -| `get-http-exchanges` | Returns recent HTTP exchanges (method, URI, status, duration) via `/actuator/httpexchanges` | -| `get-scheduled-tasks` | Lists all `@Scheduled` tasks with their cron expressions or interval settings via `/actuator/scheduledtasks` | -| `list-caches` | Lists all caches grouped by `CacheManager` via `/actuator/caches` | -| `list-beans` | Lists Spring application context beans with their type and scope via `/actuator/beans`, with an optional name/type filter | +:::info +Each tool group below corresponds to an actuator endpoint. For a tool to work, two conditions must be met: + +1. **The tool must be enabled on the MCP server** — all tool groups are enabled by default and can be toggled via `spring.boot.admin.mcp.tools.=false` (see [Configuration Reference](#configuration-reference)). +2. **The monitored application must expose the corresponding actuator endpoint** — see [Requirements in Monitored Applications](#requirements-in-monitored-applications) for the exact setup needed per tool. +::: + +### Registry (no actuator call) + +| Tool | Description | +|---|---| +| `list-applications` | Lists all registered applications with their status and management URL | +| `get-status` | Returns the cached status (UP/DOWN/UNKNOWN) for a named application without making an actuator call | + +### Beans + +| Tool | Description | +|---|---| +| `list-beans` | Lists Spring application context beans with their type and scope; supports an optional name/type filter | + +### Caches + +| Tool | Description | +|---|---| +| `list-caches` | Lists all caches grouped by `CacheManager` | + +### Env + +| Tool | Description | +|---|---| +| `get-env` | Resolves a single configuration property or environment variable | +| `list-env` | Lists all environment properties grouped by property source; supports an optional name filter | + +### Health + +| Tool | Description | +|---|---| +| `get-health` | Fetches full health details including per-component breakdown | + +### HTTP Exchanges + +| Tool | Description | +|---|---| +| `get-http-exchanges` | Returns recent HTTP exchanges (method, URI, status, duration); supports a limit parameter | + +### Logfile + +| Tool | Description | +|---|---| +| `get-logs` | Returns the last N lines from the application log file | + +### Loggers + +| Tool | Description | +|---|---| +| `list-loggers` | Lists all loggers and their effective log levels; supports an optional name filter | +| `get-logger` | Returns the configured and effective log level for a single logger | +| `set-logger-level` | Changes a logger's level at runtime; pass `null` to reset to the inherited level | + +### Metrics + +| Tool | Description | +|---|---| +| `list-metrics` | Lists all available metric names | +| `get-metrics` | Fetches the current value of a specific metric | + +### Restart · Refresh + +| Tool | Description | +|---|---| +| `restart-application` | Restarts an application via `/actuator/restart` | +| `refresh-configuration` | Triggers a Spring Cloud config refresh via `/actuator/refresh` | + +### Scheduled Tasks + +| Tool | Description | +|---|---| +| `get-scheduled-tasks` | Lists all `@Scheduled` tasks with their cron expressions or interval settings | + +### Thread Dump + +| Tool | Description | +|---|---| +| `get-thread-dump` | Captures a thread dump; useful for diagnosing deadlocks and hung threads | ## Quick Start @@ -384,8 +448,8 @@ Then pass credentials in the MCP client configuration: | `spring.ai.mcp.server.name` | `Spring Boot Admin MCP Server` | Server name reported to MCP clients. Override to report a custom value. | | `spring.ai.mcp.server.version` | current Spring Boot Admin version | Server version reported to MCP clients. Defaults to the running Spring Boot Admin version; override to report a custom value. | -:::note The `spring.boot.admin.mcp.tools.*` flags toggle tool availability **on the Spring Boot Admin server** — a -disabled category is never advertised to MCP clients. They are independent of the monitored applications' +:::note The `spring.boot.admin.mcp.tools.*` flags toggle tool availability **on the Spring Boot Admin server** +a disabled category is never advertised to MCP clients. They are independent of the monitored applications' `management.endpoint.*.enabled` settings, which are enforced at runtime by each target application (a call to a disabled endpoint simply returns an error). For example, to run a read-only monitoring deployment, set `spring.boot.admin.mcp.tools.operations=false`. Changes take effect on server restart. @@ -397,7 +461,7 @@ A runnable sample combining the Web UI and MCP server is available at `spring-boot-admin-samples/spring-boot-admin-sample-mcp`. Start it with: ```bash -./mvnw spring-boot:run -pl spring-boot-admin-samples/spring-boot-admin-sample-mcp -DexcludeSpringCloud +./mvnw spring-boot:run -pl spring-boot-admin-samples/spring-boot-admin-sample-mcp ``` The Web UI is available at `http://localhost:8080` and the MCP endpoint at `http://localhost:8080/mcp`. From 713b15b89440649e0c6259f902737ff57f3f0e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 19:11:18 +0200 Subject: [PATCH 16/29] refactor(mcp): introduce ActuatorClient and query() helper Extract shared WebClient call patterns into ActuatorClient (composition): - withInstance(): findByName / switchIfEmpty lookup with standard not-found message - fetch(): GET -> Map with configurable or default timeout - fetchText(): GET -> raw String (logfile) - post() / postBodiless(): POST with JSON body - query(appName, urlSuffix, formatter): collapses the withInstance -> fetch -> map -> onErrorResume block into one call for 10 read-only GET tool methods; label derived from urlSuffix Tool classes now hold a single ActuatorClient field. 6 tool classes (Caches, Beans, Env, Metrics, HttpExchanges, ScheduledTasks) drop their Logger field entirely; doOnError warnings route through ActuatorClient's own logger. McpAutoConfiguration wires one shared ActuatorClient bean into all tool beans. 65 tests remain green. --- .../mcp/config/McpAutoConfiguration.java | 116 ++++------ .../server/mcp/tools/ActuatorClient.java | 215 ++++++++++++++++++ .../admin/server/mcp/tools/BeansTools.java | 40 +--- .../admin/server/mcp/tools/CachesTools.java | 40 +--- .../boot/admin/server/mcp/tools/EnvTools.java | 55 +---- .../admin/server/mcp/tools/HealthTools.java | 47 +--- .../server/mcp/tools/HttpExchangesTools.java | 42 +--- .../admin/server/mcp/tools/LoggersTools.java | 70 +----- .../admin/server/mcp/tools/LogsTools.java | 31 +-- .../admin/server/mcp/tools/MetricsTools.java | 55 +---- .../server/mcp/tools/OperationsTools.java | 54 ++--- .../server/mcp/tools/ScheduledTasksTools.java | 43 +--- .../server/mcp/tools/ThreadDumpTools.java | 37 +-- .../mcp/config/McpAutoConfigurationTest.java | 3 + .../server/mcp/tools/BeansToolsTest.java | 3 +- .../server/mcp/tools/CachesToolsTest.java | 3 +- .../admin/server/mcp/tools/EnvToolsTest.java | 3 +- .../server/mcp/tools/HealthToolsTest.java | 3 +- .../mcp/tools/HttpExchangesToolsTest.java | 3 +- .../server/mcp/tools/LoggersToolsTest.java | 3 +- .../admin/server/mcp/tools/LogsToolsTest.java | 3 +- .../server/mcp/tools/MetricsToolsTest.java | 3 +- .../server/mcp/tools/OperationsToolsTest.java | 3 +- .../mcp/tools/ScheduledTasksToolsTest.java | 3 +- .../server/mcp/tools/ThreadDumpToolsTest.java | 4 +- 25 files changed, 380 insertions(+), 502 deletions(-) create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java index a2d34ee2f59..66cfa20a7d4 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java @@ -22,6 +22,7 @@ import org.springframework.context.annotation.Bean; import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.mcp.tools.ActuatorClient; import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; import de.codecentric.boot.admin.server.mcp.tools.BeansTools; import de.codecentric.boot.admin.server.mcp.tools.CachesTools; @@ -49,6 +50,21 @@ @EnableConfigurationProperties(McpProperties.class) public class McpAutoConfiguration { + /** + * Creates the shared {@link ActuatorClient} bean used by all tool classes to call + * actuator endpoints on registered applications. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClientBuilder builder for the web client used to call actuator + * endpoints + * @param mcpProperties the MCP configuration properties + * @return the configured {@link ActuatorClient} + */ + @Bean + public ActuatorClient actuatorClient(InstanceRepository instanceRepository, + InstanceWebClient.Builder instanceWebClientBuilder, McpProperties mcpProperties) { + return new ActuatorClient(instanceRepository, instanceWebClientBuilder.build(), mcpProperties.getTimeout()); + } + /** * Creates the {@link ApplicationTools} bean for listing registered applications. * @param instanceRepository the repository used to look up registered instances @@ -63,169 +79,137 @@ public ApplicationTools applicationTools(InstanceRepository instanceRepository) /** * Creates the {@link HealthTools} bean for querying application health and status. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link HealthTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "health", havingValue = "true", matchIfMissing = true) - public HealthTools healthTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new HealthTools(instanceRepository, instanceWebClientBuilder.build()); + public HealthTools healthTools(ActuatorClient actuatorClient) { + return new HealthTools(actuatorClient); } /** * Creates the {@link MetricsTools} bean for querying application metrics. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link MetricsTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "metrics", havingValue = "true", matchIfMissing = true) - public MetricsTools metricsTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new MetricsTools(instanceRepository, instanceWebClientBuilder.build()); + public MetricsTools metricsTools(ActuatorClient actuatorClient) { + return new MetricsTools(actuatorClient); } /** * Creates the {@link EnvTools} bean for resolving application environment properties. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link EnvTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "env", havingValue = "true", matchIfMissing = true) - public EnvTools envTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new EnvTools(instanceRepository, instanceWebClientBuilder.build()); + public EnvTools envTools(ActuatorClient actuatorClient) { + return new EnvTools(actuatorClient); } /** * Creates the {@link LogsTools} bean for accessing application log output. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link LogsTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "logs", havingValue = "true", matchIfMissing = true) - public LogsTools logsTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new LogsTools(instanceRepository, instanceWebClientBuilder.build()); + public LogsTools logsTools(ActuatorClient actuatorClient) { + return new LogsTools(actuatorClient); } /** * Creates the {@link OperationsTools} bean for performing write operations on * applications. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link OperationsTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "operations", havingValue = "true", matchIfMissing = true) - public OperationsTools operationsTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new OperationsTools(instanceRepository, instanceWebClientBuilder.build()); + public OperationsTools operationsTools(ActuatorClient actuatorClient) { + return new OperationsTools(actuatorClient); } /** * Creates the {@link LoggersTools} bean for querying and configuring log levels. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link LoggersTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "loggers", havingValue = "true", matchIfMissing = true) - public LoggersTools loggersTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new LoggersTools(instanceRepository, instanceWebClientBuilder.build()); + public LoggersTools loggersTools(ActuatorClient actuatorClient) { + return new LoggersTools(actuatorClient); } /** * Creates the {@link ThreadDumpTools} bean for capturing thread dumps. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper + * @param mcpProperties the MCP configuration properties * @return the configured {@link ThreadDumpTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "thread-dump", havingValue = "true", matchIfMissing = true) - public ThreadDumpTools threadDumpTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new ThreadDumpTools(instanceRepository, instanceWebClientBuilder.build()); + public ThreadDumpTools threadDumpTools(ActuatorClient actuatorClient, McpProperties mcpProperties) { + return new ThreadDumpTools(actuatorClient, mcpProperties.getThreadDumpTimeout()); } /** * Creates the {@link HttpExchangesTools} bean for inspecting recent HTTP exchanges. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link HttpExchangesTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "http-exchanges", havingValue = "true", matchIfMissing = true) - public HttpExchangesTools httpExchangesTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new HttpExchangesTools(instanceRepository, instanceWebClientBuilder.build()); + public HttpExchangesTools httpExchangesTools(ActuatorClient actuatorClient) { + return new HttpExchangesTools(actuatorClient); } /** * Creates the {@link ScheduledTasksTools} bean for inspecting scheduled tasks. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link ScheduledTasksTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "scheduled-tasks", havingValue = "true", matchIfMissing = true) - public ScheduledTasksTools scheduledTasksTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new ScheduledTasksTools(instanceRepository, instanceWebClientBuilder.build()); + public ScheduledTasksTools scheduledTasksTools(ActuatorClient actuatorClient) { + return new ScheduledTasksTools(actuatorClient); } /** * Creates the {@link CachesTools} bean for inspecting application caches. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link CachesTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "caches", havingValue = "true", matchIfMissing = true) - public CachesTools cachesTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new CachesTools(instanceRepository, instanceWebClientBuilder.build()); + public CachesTools cachesTools(ActuatorClient actuatorClient) { + return new CachesTools(actuatorClient); } /** * Creates the {@link BeansTools} bean for inspecting Spring application context * beans. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClientBuilder builder for the web client used to call actuator - * endpoints + * @param actuatorClient the shared actuator call helper * @return the configured {@link BeansTools} */ @Bean @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "beans", havingValue = "true", matchIfMissing = true) - public BeansTools beansTools(InstanceRepository instanceRepository, - InstanceWebClient.Builder instanceWebClientBuilder) { - return new BeansTools(instanceRepository, instanceWebClientBuilder.build()); + public BeansTools beansTools(ActuatorClient actuatorClient) { + return new BeansTools(actuatorClient); } } diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java new file mode 100644 index 00000000000..3a490c853c5 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java @@ -0,0 +1,215 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +/** + * Shared helper for MCP tool classes that call actuator endpoints on registered Spring + * Boot applications. + * + *

+ * Encapsulates the repeated WebClient call patterns and the + * {@code findByName / switchIfEmpty} lookup so that individual tool classes only contain + * their {@code @McpTool}-annotated methods and response formatters. + *

+ */ +public class ActuatorClient { + + private static final Logger log = LoggerFactory.getLogger(ActuatorClient.class); + + static final ParameterizedTypeReference> MAP_TYPE = new ParameterizedTypeReference<>() { + }; + + private final InstanceRepository instanceRepository; + + private final InstanceWebClient instanceWebClient; + + private final Duration timeout; + + /** + * Creates a new {@code ActuatorClient} with the given timeout. + * @param instanceRepository the repository used to look up registered instances + * @param instanceWebClient the client used to call actuator endpoints on instances + * @param timeout the default timeout applied to all actuator calls + */ + public ActuatorClient(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient, + Duration timeout) { + this.instanceRepository = instanceRepository; + this.instanceWebClient = instanceWebClient; + this.timeout = timeout; + } + + /** + * Resolves the first registered instance for the given application name, applies + * {@code action}, and falls back to a plain-text "not found" message when no instance + * is registered. + * @param applicationName the registered application name (case-insensitive) + * @param action function to execute against the resolved instance + * @return the action result, or a plain-text "not found" message + */ + public Mono withInstance(String applicationName, Function> action) { + return this.instanceRepository.findByName(applicationName) + .next() + .flatMap(action) + .switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + } + + /** + * Resolves the application, performs a GET against {@code managementUrl + urlSuffix}, + * applies {@code formatter} to the JSON body, and maps any error to a plain-text + * message. The endpoint label used in logs and error messages is derived from + * {@code urlSuffix} by stripping the leading slash. + * @param applicationName the registered application name (case-insensitive) + * @param urlSuffix the actuator endpoint path, including the leading slash (e.g. + * {@code "/caches"} or {@code "/metrics/jvm.memory.used"}) + * @param formatter function that converts the application name and parsed response + * body into a plain-text result string + * @return the formatted result, a plain-text error message, or a "not found" message + */ + public Mono query(String applicationName, String urlSuffix, + BiFunction, String> formatter) { + String label = urlSuffix.startsWith("/") ? urlSuffix.substring(1) : urlSuffix; + return withInstance(applicationName, (instance) -> { + String url = instance.getRegistration().getManagementUrl() + urlSuffix; + return fetch(instance, url, log, label + " for " + applicationName) + .map((body) -> formatter.apply(applicationName, body)) + .onErrorResume((ex) -> Mono + .just("Error retrieving " + label + " for " + applicationName + ": " + ex.getMessage())); + }); + } + + /** + * Performs a GET request against the given actuator URL and deserialises the JSON + * response body into a {@code Map} using the supplied timeout. + * @param instance the target instance + * @param url the full actuator endpoint URL + * @param timeout the request timeout + * @param log the caller's logger + * @param errorContext a short description used in the warning log (e.g. + * {@code "health for payment-service"}) + * @return a {@code Mono} of the parsed response body + */ + public Mono> fetch(Instance instance, String url, Duration timeout, Logger log, + String errorContext) { + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(MAP_TYPE) + .timeout(timeout) + .doOnError((ex) -> log.warn("Failed to get {}", errorContext, ex)) + .onErrorResume(Mono::error); + } + + /** + * Performs a GET request against the given actuator URL and deserialises the JSON + * response body into a {@code Map} using the standard timeout. + * @param instance the target instance + * @param url the full actuator endpoint URL + * @param log the caller's logger + * @param errorContext a short description used in the warning log + * @return a {@code Mono} of the parsed response body + */ + public Mono> fetch(Instance instance, String url, Logger log, String errorContext) { + return fetch(instance, url, this.timeout, log, errorContext); + } + + /** + * Performs a GET request against the given actuator URL and returns the raw response + * body as a {@code String}. + * @param instance the target instance + * @param url the full actuator endpoint URL + * @param log the caller's logger + * @param errorContext a short description used in the warning log + * @return a {@code Mono} of the raw response text; empty string when the body is + * absent + */ + public Mono fetchText(Instance instance, String url, Logger log, String errorContext) { + return this.instanceWebClient.instance(instance) + .get() + .uri(url) + .retrieve() + .bodyToMono(String.class) + .defaultIfEmpty("") + .timeout(this.timeout) + .doOnError((ex) -> log.warn("Failed to get {}", errorContext, ex)) + .onErrorResume(Mono::error); + } + + /** + * Performs a POST request with a JSON string body against the given actuator URL and + * returns the raw response body as a {@code String}. + * @param instance the target instance + * @param url the full actuator endpoint URL + * @param jsonBody the JSON string to send as the request body + * @param log the caller's logger + * @param errorContext a short description used in the warning log + * @return a {@code Mono} of the raw response text + */ + public Mono post(Instance instance, String url, String jsonBody, Logger log, String errorContext) { + return this.instanceWebClient.instance(instance) + .post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(jsonBody) + .retrieve() + .bodyToMono(String.class) + .timeout(this.timeout) + .doOnError((ex) -> log.warn("Failed to post {}", errorContext, ex)) + .onErrorResume(Mono::error); + } + + /** + * Performs a POST request with a JSON string body against the given actuator URL and + * returns the HTTP status code. + * @param instance the target instance + * @param url the full actuator endpoint URL + * @param jsonBody the JSON string to send as the request body; use {@code "{}"} for + * an empty body + * @param log the caller's logger + * @param errorContext a short description used in the warning log + * @return a {@code Mono} of the HTTP status code + */ + public Mono postBodiless(Instance instance, String url, String jsonBody, Logger log, String errorContext) { + return this.instanceWebClient.instance(instance) + .post() + .uri(url) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(jsonBody) + .retrieve() + .toBodilessEntity() + .timeout(this.timeout) + .map((response) -> response.getStatusCode().value()) + .doOnError((ex) -> log.warn("Failed to post {}", errorContext, ex)) + .onErrorResume(Mono::error); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java index 0c48f46d710..575524fa735 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java @@ -16,21 +16,14 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.List; import java.util.Locale; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for inspecting the Spring application context beans of registered Spring Boot * applications. @@ -42,25 +35,14 @@ */ public class BeansTools { - private static final Logger log = LoggerFactory.getLogger(BeansTools.class); - - private static final Duration TIMEOUT = Duration.ofMillis(450); - - private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code BeansTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public BeansTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public BeansTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -84,19 +66,7 @@ public Mono listBeans( required = true) String applicationName, @McpToolParam(description = "Optional case-insensitive substring; only beans whose name or type contain " + "it are returned. Omit to return all beans.", required = false) String filter) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/beans"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatBeans(applicationName, filter, body)) - .doOnError((ex) -> log.warn("Failed to list beans for {}", applicationName, ex)) - .onErrorResume( - (ex) -> Mono.just("Error listing beans for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/beans", (app, body) -> formatBeans(app, filter, body)); } @SuppressWarnings("unchecked") diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java index 449671a8ed1..52b53bfb541 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java @@ -16,19 +16,12 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for inspecting caches of registered Spring Boot applications. * @@ -39,25 +32,14 @@ */ public class CachesTools { - private static final Logger log = LoggerFactory.getLogger(CachesTools.class); - - private static final Duration TIMEOUT = Duration.ofMillis(450); - - private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code CachesTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public CachesTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public CachesTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -74,19 +56,7 @@ public CachesTools(InstanceRepository instanceRepository, InstanceWebClient inst + "Requires the caches actuator endpoint to be exposed.") public Mono listCaches(@McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/caches"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatCaches(applicationName, body)) - .doOnError((ex) -> log.warn("Failed to list caches for {}", applicationName, ex)) - .onErrorResume( - (ex) -> Mono.just("Error listing caches for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/caches", this::formatCaches); } @SuppressWarnings("unchecked") diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java index 8f3ddb1d4e1..0c641c2c19e 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java @@ -16,21 +16,14 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.List; import java.util.Locale; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for querying the environment of registered Spring Boot applications. * @@ -43,25 +36,14 @@ */ public class EnvTools { - private static final Logger log = LoggerFactory.getLogger(EnvTools.class); - - private static final Duration TIMEOUT = Duration.ofMillis(450); - - private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code EnvTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public EnvTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public EnvTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -83,19 +65,8 @@ public Mono getEnv( required = true) String applicationName, @McpToolParam(description = "The property or environment variable name (e.g. HELLO)", required = true) String propertyName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/env/" + propertyName; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatProperty(applicationName, propertyName, body)) - .doOnError((ex) -> log.warn("Failed to get env property {} for {}", propertyName, applicationName, ex)) - .onErrorResume((ex) -> Mono.just("Error retrieving env property '" + propertyName + "' for " - + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/env/" + propertyName, + (app, body) -> formatProperty(app, propertyName, body)); } /** @@ -123,19 +94,7 @@ public Mono listEnv( required = true) String applicationName, @McpToolParam(description = "Optional case-insensitive substring; only property names containing it are " + "returned. Omit to return all properties.", required = false) String filter) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/env"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatEnv(applicationName, filter, body)) - .doOnError((ex) -> log.warn("Failed to list env for {}", applicationName, ex)) - .onErrorResume( - (ex) -> Mono.just("Error retrieving env for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/env", (app, body) -> formatEnv(app, filter, body)); } @SuppressWarnings("unchecked") diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java index 5a2a8fde5ed..a7455cc4ff7 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java @@ -16,19 +16,15 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; import de.codecentric.boot.admin.server.domain.values.Endpoint; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; /** * MCP tools for querying the health of registered Spring Boot applications. @@ -46,23 +42,14 @@ public class HealthTools { private static final Logger log = LoggerFactory.getLogger(HealthTools.class); - private static final Duration TIMEOUT = Duration.ofMillis(450); - - private static final ParameterizedTypeReference> HEALTH_RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code HealthTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public HealthTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public HealthTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -78,19 +65,11 @@ public HealthTools(InstanceRepository instanceRepository, InstanceWebClient inst + "/actuator/health endpoint. Returns overall status and per-component breakdown.") public Mono getHealth(@McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName) { - return this.instanceRepository.findByName(applicationName) - .next() - .flatMap((instance) -> this.instanceWebClient.instance(instance) - .get() - .uri(Endpoint.HEALTH) - .retrieve() - .bodyToMono(HEALTH_RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatHealthResponse(applicationName, body)) - .doOnError((ex) -> log.warn("Failed to get health for {}", applicationName, ex)) - .onErrorResume( - (ex) -> Mono.just("Error retrieving health for " + applicationName + ": " + ex.getMessage()))) - .switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.withInstance(applicationName, (instance) -> this.actuatorClient + .fetch(instance, Endpoint.HEALTH, log, "health for " + applicationName) + .map((body) -> formatHealthResponse(applicationName, body)) + .onErrorResume( + (ex) -> Mono.just("Error retrieving health for " + applicationName + ": " + ex.getMessage()))); } /** @@ -106,12 +85,8 @@ public Mono getHealth(@McpToolParam(description = "The registered applic + "Use get-health for full component details.") public Mono getStatus(@McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName) { - return this.instanceRepository.findByName(applicationName) - .next() - .map((instance) -> applicationName + " status: " + instance.getStatusInfo().getStatus()) - .switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")) - .doOnError((ex) -> log.warn("Failed to get status for {}", applicationName, ex)) - .onErrorReturn("Error retrieving status for " + applicationName + "."); + return this.actuatorClient.withInstance(applicationName, + (instance) -> Mono.just(applicationName + " status: " + instance.getStatusInfo().getStatus())); } @SuppressWarnings("unchecked") diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java index 2d495b7d8d5..3c428c0ef6b 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java @@ -16,20 +16,13 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.List; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for inspecting recent HTTP exchanges of registered Spring Boot applications. * @@ -40,29 +33,18 @@ */ public class HttpExchangesTools { - private static final Logger log = LoggerFactory.getLogger(HttpExchangesTools.class); - - private static final Duration TIMEOUT = Duration.ofMillis(450); - private static final int DEFAULT_LIMIT = 20; private static final int MAX_LIMIT = 100; - private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code HttpExchangesTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public HttpExchangesTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public HttpExchangesTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -85,20 +67,8 @@ public Mono getHttpExchanges( @McpToolParam(description = "Maximum number of exchanges to return (default 20, max 100)", required = false) Integer limit) { int effectiveLimit = (limit != null) ? Math.min(Math.max(limit, 1), MAX_LIMIT) : DEFAULT_LIMIT; - - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/httpexchanges"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatExchanges(applicationName, body, effectiveLimit)) - .doOnError((ex) -> log.warn("Failed to get HTTP exchanges for {}", applicationName, ex)) - .onErrorResume((ex) -> Mono - .just("Error retrieving HTTP exchanges for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/httpexchanges", + (app, body) -> formatExchanges(app, body, effectiveLimit)); } @SuppressWarnings("unchecked") diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java index b49acfa6842..d3556435123 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java @@ -16,7 +16,6 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.List; import java.util.Locale; import java.util.Map; @@ -25,13 +24,8 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.MediaType; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for querying and configuring log levels of registered Spring Boot * applications. @@ -48,23 +42,14 @@ public class LoggersTools { private static final Logger log = LoggerFactory.getLogger(LoggersTools.class); - private static final Duration TIMEOUT = Duration.ofMillis(450); - - private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code LoggersTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public LoggersTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public LoggersTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -86,19 +71,7 @@ public Mono listLoggers( required = true) String applicationName, @McpToolParam(description = "Optional case-insensitive substring; only logger names containing it are " + "returned. Omit to return all loggers.", required = false) String filter) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/loggers"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatLoggers(applicationName, filter, body)) - .doOnError((ex) -> log.warn("Failed to list loggers for {}", applicationName, ex)) - .onErrorResume( - (ex) -> Mono.just("Error listing loggers for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/loggers", (app, body) -> formatLoggers(app, filter, body)); } /** @@ -118,19 +91,8 @@ public Mono getLogger( required = true) String applicationName, @McpToolParam(description = "The fully-qualified logger name (e.g. com.example.MyService)", required = true) String loggerName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/loggers/" + loggerName; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatLogger(applicationName, loggerName, body)) - .doOnError((ex) -> log.warn("Failed to get logger {} for {}", loggerName, applicationName, ex)) - .onErrorResume((ex) -> Mono.just("Error retrieving logger '" + loggerName + "' for " + applicationName - + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/loggers/" + loggerName, + (app, body) -> formatLogger(app, loggerName, body)); } /** @@ -156,20 +118,13 @@ public Mono setLoggerLevel( required = true) String loggerName, @McpToolParam(description = "The log level to set: TRACE, DEBUG, INFO, WARN, ERROR, OFF, or null to reset", required = true) String level) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + return this.actuatorClient.withInstance(applicationName, (instance) -> { String url = instance.getRegistration().getManagementUrl() + "/loggers/" + loggerName; String body = (level == null || "null".equalsIgnoreCase(level)) ? "{}" : "{\"configuredLevel\":\"" + level.toUpperCase(Locale.ROOT) + "\"}"; - return this.instanceWebClient.instance(instance) - .post() - .uri(url) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(body) - .retrieve() - .toBodilessEntity() - .timeout(TIMEOUT) - .map((response) -> { - int status = response.getStatusCode().value(); + return this.actuatorClient + .postBodiless(instance, url, body, log, "set log level for " + loggerName + " in " + applicationName) + .map((status) -> { if ((status >= 200) && (status < 300)) { String effectiveLevel = (level == null || "null".equalsIgnoreCase(level)) ? "inherited" : level.toUpperCase(Locale.ROOT); @@ -178,10 +133,9 @@ public Mono setLoggerLevel( } return "Setting log level returned unexpected status " + status + " for " + applicationName + "."; }) - .doOnError((ex) -> log.warn("Failed to set log level for {} in {}", loggerName, applicationName, ex)) .onErrorResume((ex) -> Mono.just("Error setting log level for '" + loggerName + "' in " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + }); } @SuppressWarnings("unchecked") diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java index be50a14fc25..271fe6375c9 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java @@ -16,7 +16,6 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.Arrays; import java.util.stream.Collectors; @@ -26,9 +25,6 @@ import org.springframework.ai.mcp.annotation.McpToolParam; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for accessing logs of registered Spring Boot applications. * @@ -40,24 +36,18 @@ public class LogsTools { private static final Logger log = LoggerFactory.getLogger(LogsTools.class); - private static final Duration TIMEOUT = Duration.ofMillis(450); - private static final int DEFAULT_LINES = 50; private static final int MAX_LINES = 500; - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code LogsTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public LogsTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public LogsTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -79,20 +69,13 @@ public Mono getLogs( required = false) Integer lines) { int lineCount = (lines != null) ? Math.min(Math.max(lines, 1), MAX_LINES) : DEFAULT_LINES; - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + return this.actuatorClient.withInstance(applicationName, (instance) -> { String url = instance.getRegistration().getManagementUrl() + "/logfile"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(String.class) - .defaultIfEmpty("") - .timeout(TIMEOUT) + return this.actuatorClient.fetchText(instance, url, log, "logs for " + applicationName) .map((body) -> formatLogTail(applicationName, body, lineCount)) - .doOnError((ex) -> log.warn("Failed to get logs for {}", applicationName, ex)) .onErrorResume( (ex) -> Mono.just("Error retrieving logs for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + }); } private String formatLogTail(String applicationName, String body, int lineCount) { diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java index bfa7e133b56..6951678965e 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java @@ -16,20 +16,13 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.List; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for querying metrics of registered Spring Boot applications. * @@ -40,25 +33,14 @@ */ public class MetricsTools { - private static final Logger log = LoggerFactory.getLogger(MetricsTools.class); - - private static final Duration TIMEOUT = Duration.ofMillis(450); - - private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code MetricsTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public MetricsTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public MetricsTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -72,19 +54,7 @@ public MetricsTools(InstanceRepository instanceRepository, InstanceWebClient ins + "Use the returned names with get-metrics to fetch specific values.") public Mono listMetrics(@McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/metrics"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatMetricNames(applicationName, body)) - .doOnError((ex) -> log.warn("Failed to list metrics for {}", applicationName, ex)) - .onErrorResume( - (ex) -> Mono.just("Error listing metrics for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/metrics", this::formatMetricNames); } /** @@ -102,19 +72,8 @@ public Mono getMetrics( @McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName, @McpToolParam(description = "The metric name (e.g. jvm.memory.used)", required = true) String metricName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/metrics/" + metricName; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatMetricValue(applicationName, metricName, body)) - .doOnError((ex) -> log.warn("Failed to get metric {} for {}", metricName, applicationName, ex)) - .onErrorResume((ex) -> Mono.just("Error retrieving metric '" + metricName + "' for " + applicationName - + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/metrics/" + metricName, + (app, body) -> formatMetricValue(app, metricName, body)); } @SuppressWarnings("unchecked") diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java index 493260f433a..f411b334aa9 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java @@ -16,24 +16,18 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.http.MediaType; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for performing write operations on registered Spring Boot applications. * *
    *
  • {@code restart-application} — restarts the application via - * {@code /actuator/restart} or {@code /actuator/shutdown}
  • + * {@code /actuator/restart} *
  • {@code refresh-configuration} — refreshes the application configuration via * {@code /actuator/refresh}
  • *
@@ -42,26 +36,19 @@ public class OperationsTools { private static final Logger log = LoggerFactory.getLogger(OperationsTools.class); - private static final Duration TIMEOUT = Duration.ofMillis(450); - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code OperationsTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public OperationsTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public OperationsTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** * Restarts the named application by calling its {@code /actuator/restart} endpoint. - * Falls back to {@code /actuator/shutdown} if restart is not available. Requires the - * actuator restart or shutdown endpoint to be enabled and exposed in the monitored + * Requires the actuator restart endpoint to be enabled and exposed in the monitored * application. * @param applicationName the registered application name (case-insensitive) * @return confirmation message or an error message @@ -72,25 +59,17 @@ public OperationsTools(InstanceRepository instanceRepository, InstanceWebClient public Mono restartApplication( @McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + return this.actuatorClient.withInstance(applicationName, (instance) -> { String url = instance.getRegistration().getManagementUrl() + "/restart"; - return this.instanceWebClient.instance(instance) - .post() - .uri(url) - .contentType(MediaType.APPLICATION_JSON) - .retrieve() - .toBodilessEntity() - .timeout(TIMEOUT) - .map((response) -> { - int status = response.getStatusCode().value(); + return this.actuatorClient.postBodiless(instance, url, "{}", log, "restart of " + applicationName) + .map((status) -> { if ((status >= 200) && (status < 300)) { return applicationName + " restart initiated successfully."; } return "Restart returned unexpected status " + status + " for " + applicationName + "."; }) - .doOnError((ex) -> log.warn("Failed to restart {}", applicationName, ex)) .onErrorResume((ex) -> Mono.just("Error restarting " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + }); } /** @@ -107,20 +86,13 @@ public Mono restartApplication( public Mono refreshConfiguration( @McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + return this.actuatorClient.withInstance(applicationName, (instance) -> { String url = instance.getRegistration().getManagementUrl() + "/refresh"; - return this.instanceWebClient.instance(instance) - .post() - .uri(url) - .contentType(MediaType.APPLICATION_JSON) - .retrieve() - .bodyToMono(String.class) - .timeout(TIMEOUT) + return this.actuatorClient.post(instance, url, "{}", log, "refresh of " + applicationName) .map((body) -> formatRefreshResponse(applicationName, body)) - .doOnError((ex) -> log.warn("Failed to refresh configuration for {}", applicationName, ex)) .onErrorResume((ex) -> Mono .just("Error refreshing configuration for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + }); } private String formatRefreshResponse(String applicationName, String body) { diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java index 0f8a0a2373f..ffb61dbd008 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java @@ -16,20 +16,13 @@ package de.codecentric.boot.admin.server.mcp.tools; -import java.time.Duration; import java.util.List; import java.util.Map; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for inspecting scheduled tasks of registered Spring Boot applications. * @@ -40,25 +33,14 @@ */ public class ScheduledTasksTools { - private static final Logger log = LoggerFactory.getLogger(ScheduledTasksTools.class); - - private static final Duration TIMEOUT = Duration.ofMillis(450); - - private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; - - private final InstanceWebClient instanceWebClient; + private final ActuatorClient actuatorClient; /** * Creates a new {@code ScheduledTasksTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper */ - public ScheduledTasksTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public ScheduledTasksTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; } /** @@ -77,19 +59,7 @@ public ScheduledTasksTools(InstanceRepository instanceRepository, InstanceWebCli public Mono getScheduledTasks( @McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { - String url = instance.getRegistration().getManagementUrl() + "/scheduledtasks"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) - .map((body) -> formatScheduledTasks(applicationName, body)) - .doOnError((ex) -> log.warn("Failed to get scheduled tasks for {}", applicationName, ex)) - .onErrorResume((ex) -> Mono - .just("Error retrieving scheduled tasks for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + return this.actuatorClient.query(applicationName, "/scheduledtasks", this::formatScheduledTasks); } @SuppressWarnings("unchecked") @@ -97,7 +67,6 @@ private String formatScheduledTasks(String applicationName, Map StringBuilder sb = new StringBuilder("Scheduled tasks for ").append(applicationName).append(":\n"); int total = 0; - // cron tasks Object cronObj = body.get("cron"); if (cronObj instanceof List cronTasks && !cronTasks.isEmpty()) { sb.append("\nCron tasks (").append(cronTasks.size()).append("):\n"); @@ -114,7 +83,6 @@ private String formatScheduledTasks(String applicationName, Map } } - // fixed-rate tasks Object fixedRateObj = body.get("fixedRate"); if (fixedRateObj instanceof List fixedRateTasks && !fixedRateTasks.isEmpty()) { sb.append("\nFixed-rate tasks (").append(fixedRateTasks.size()).append("):\n"); @@ -128,7 +96,6 @@ private String formatScheduledTasks(String applicationName, Map } } - // fixed-delay tasks Object fixedDelayObj = body.get("fixedDelay"); if (fixedDelayObj instanceof List fixedDelayTasks && !fixedDelayTasks.isEmpty()) { sb.append("\nFixed-delay tasks (").append(fixedDelayTasks.size()).append("):\n"); diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java index a93f4e8e883..44f97a58bd2 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java @@ -24,12 +24,8 @@ import org.slf4j.LoggerFactory; import org.springframework.ai.mcp.annotation.McpTool; import org.springframework.ai.mcp.annotation.McpToolParam; -import org.springframework.core.ParameterizedTypeReference; import reactor.core.publisher.Mono; -import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; - /** * MCP tools for capturing thread dumps from registered Spring Boot applications. * @@ -42,23 +38,19 @@ public class ThreadDumpTools { private static final Logger log = LoggerFactory.getLogger(ThreadDumpTools.class); - private static final Duration TIMEOUT = Duration.ofSeconds(10); - - private static final ParameterizedTypeReference> RESPONSE_TYPE = new ParameterizedTypeReference<>() { - }; - - private final InstanceRepository instanceRepository; + private final ActuatorClient actuatorClient; - private final InstanceWebClient instanceWebClient; + private final Duration timeout; /** * Creates a new {@code ThreadDumpTools} instance. - * @param instanceRepository the repository used to look up registered instances - * @param instanceWebClient the client used to call actuator endpoints on instances + * @param actuatorClient the shared actuator call helper + * @param timeout the request timeout for thread dump calls (can be slower than + * standard actuator endpoints) */ - public ThreadDumpTools(InstanceRepository instanceRepository, InstanceWebClient instanceWebClient) { - this.instanceRepository = instanceRepository; - this.instanceWebClient = instanceWebClient; + public ThreadDumpTools(ActuatorClient actuatorClient, Duration timeout) { + this.actuatorClient = actuatorClient; + this.timeout = timeout; } /** @@ -76,19 +68,13 @@ public ThreadDumpTools(InstanceRepository instanceRepository, InstanceWebClient + "Requires the threaddump actuator endpoint to be exposed.") public Mono getThreadDump(@McpToolParam(description = "The registered application name (case-insensitive)", required = true) String applicationName) { - return this.instanceRepository.findByName(applicationName).next().flatMap((instance) -> { + return this.actuatorClient.withInstance(applicationName, (instance) -> { String url = instance.getRegistration().getManagementUrl() + "/threaddump"; - return this.instanceWebClient.instance(instance) - .get() - .uri(url) - .retrieve() - .bodyToMono(RESPONSE_TYPE) - .timeout(TIMEOUT) + return this.actuatorClient.fetch(instance, url, this.timeout, log, "thread dump for " + applicationName) .map((body) -> formatThreadDump(applicationName, body)) - .doOnError((ex) -> log.warn("Failed to get thread dump for {}", applicationName, ex)) .onErrorResume((ex) -> Mono .just("Error retrieving thread dump for " + applicationName + ": " + ex.getMessage())); - }).switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); + }); } @SuppressWarnings("unchecked") @@ -98,7 +84,6 @@ private String formatThreadDump(String applicationName, Map body return "No thread dump available for " + applicationName + "."; } - // Aggregate thread counts by state java.util.Map stateCounts = new java.util.LinkedHashMap<>(); StringBuilder detail = new StringBuilder(); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java index e324ff9eea0..42bb596e5a7 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java @@ -23,6 +23,7 @@ import org.springframework.context.annotation.Configuration; import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.mcp.tools.ActuatorClient; import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; import de.codecentric.boot.admin.server.mcp.tools.BeansTools; import de.codecentric.boot.admin.server.mcp.tools.CachesTools; @@ -49,6 +50,7 @@ class McpAutoConfigurationTest { @Test void mcpDisabled_noToolBeansCreated() { this.contextRunner.run((context) -> { + assertThat(context).doesNotHaveBean(ActuatorClient.class); assertThat(context).doesNotHaveBean(ApplicationTools.class); assertThat(context).doesNotHaveBean(HealthTools.class); assertThat(context).doesNotHaveBean(MetricsTools.class); @@ -67,6 +69,7 @@ void mcpDisabled_noToolBeansCreated() { @Test void mcpEnabled_allToolBeansCreatedByDefault() { this.contextRunner.withPropertyValues("spring.boot.admin.mcp.enabled=true").run((context) -> { + assertThat(context).hasSingleBean(ActuatorClient.class); assertThat(context).hasSingleBean(ApplicationTools.class); assertThat(context).hasSingleBean(HealthTools.class); assertThat(context).hasSingleBean(MetricsTools.class); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java index cb8b86e2108..1debe2d91ef 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java @@ -66,7 +66,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.beansTools = new BeansTools(this.instanceRepository, instanceWebClient); + this.beansTools = new BeansTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java index 4bcc879f522..8a27c7f5132 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java @@ -66,7 +66,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.cachesTools = new CachesTools(this.instanceRepository, instanceWebClient); + this.cachesTools = new CachesTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java index d18fcd2163f..c8b7159bda8 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java @@ -66,7 +66,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.envTools = new EnvTools(this.instanceRepository, instanceWebClient); + this.envTools = new EnvTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java index abfc0d40d72..c0e818384cf 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java @@ -69,7 +69,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.healthTools = new HealthTools(this.instanceRepository, instanceWebClient); + this.healthTools = new HealthTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java index 84f672259fd..1c0d75e786c 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java @@ -66,7 +66,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.httpExchangesTools = new HttpExchangesTools(this.instanceRepository, instanceWebClient); + this.httpExchangesTools = new HttpExchangesTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java index b5222cde175..729f03e996c 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java @@ -68,7 +68,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.loggersTools = new LoggersTools(this.instanceRepository, instanceWebClient); + this.loggersTools = new LoggersTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java index 610009f2219..4b71ea9a762 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java @@ -66,7 +66,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.logsTools = new LogsTools(this.instanceRepository, instanceWebClient); + this.logsTools = new LogsTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java index f05451f2743..faa40874aaa 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java @@ -66,7 +66,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.metricsTools = new MetricsTools(this.instanceRepository, instanceWebClient); + this.metricsTools = new MetricsTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java index b1f01294a1d..e2f789b405f 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java @@ -67,7 +67,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.operationsTools = new OperationsTools(this.instanceRepository, instanceWebClient); + this.operationsTools = new OperationsTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java index ce58a87d058..e80471585d6 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java @@ -66,7 +66,8 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.scheduledTasksTools = new ScheduledTasksTools(this.instanceRepository, instanceWebClient); + this.scheduledTasksTools = new ScheduledTasksTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); } @AfterEach diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java index 43764701d2a..ca692788ce2 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java @@ -66,7 +66,9 @@ void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); - this.threadDumpTools = new ThreadDumpTools(this.instanceRepository, instanceWebClient); + this.threadDumpTools = new ThreadDumpTools( + new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450)), + Duration.ofSeconds(10)); } @AfterEach From c1a33a3fafcfeac0463b838df95ca5eb5722058d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 19:11:24 +0200 Subject: [PATCH 17/29] feat(mcp): make actuator call timeouts configurable Two new properties in spring.boot.admin.mcp: - timeout (default 450ms): applied to all standard actuator calls - thread-dump-timeout (default 10s): applied to /actuator/threaddump Docs: two new rows added to the Configuration Reference table. --- .../src/site/docs/07-mcp/index.md | 4 +++- .../admin/server/mcp/config/McpProperties.java | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index 5f44ed839ca..63677906919 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -430,7 +430,9 @@ Then pass credentials in the MCP client configuration: | Property | Default | Description | |-----------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------| -| `spring.boot.admin.mcp.enabled` | `false` | Enable the MCP server integration | +| `spring.boot.admin.mcp.enabled` | `false` | Enable the MCP server integration | +| `spring.boot.admin.mcp.timeout` | `450ms` | Timeout for actuator calls made by MCP tools | +| `spring.boot.admin.mcp.thread-dump-timeout` | `10s` | Timeout for `/actuator/threaddump` calls, which can be slower than standard endpoints | | `spring.boot.admin.mcp.tools.applications` | `true` | Register the `list-applications` tool | | `spring.boot.admin.mcp.tools.health` | `true` | Register the `get-health` and `get-status` tools | | `spring.boot.admin.mcp.tools.metrics` | `true` | Register the `list-metrics` and `get-metrics` tools | diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java index bdb6421bba3..2c733ef0117 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java @@ -16,6 +16,8 @@ package de.codecentric.boot.admin.server.mcp.config; +import java.time.Duration; + import org.springframework.boot.context.properties.ConfigurationProperties; /** @@ -60,6 +62,19 @@ public McpProperties() { */ private boolean enabled = false; + /** + * Timeout for actuator calls made by MCP tools. Applies to all standard actuator + * endpoints. Defaults to {@code 450ms}. The thread dump endpoint uses + * {@code threadDumpTimeout} instead. + */ + private Duration timeout = Duration.ofMillis(450); + + /** + * Timeout for {@code /actuator/threaddump} calls, which can be slower than standard + * actuator endpoints. Defaults to {@code 10s}. + */ + private Duration threadDumpTimeout = Duration.ofSeconds(10); + /** * Server-side toggles controlling which MCP tool categories are registered and * advertised to clients. These operate on the Spring Boot Admin server itself and are From 2eae73fbf56a29f39c14f24521b84f2ae704ec01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 22:20:49 +0200 Subject: [PATCH 18/29] feat(mcp): default type=async and protocol=streamable via EnvironmentPostProcessor Spring AI's sync default would block Netty's event loop; the async server matches the reactive WebFlux/Netty stack and tool methods that return Mono. Although McpServerProperties.protocol defaults to STREAMABLE in Java, transport selection uses @ConditionalOnProperty evaluated against the Environment before property binding. McpServerSseWebFluxAutoConfiguration carries matchIfMissing=true on its SSE condition, so when the property is absent the legacy SSE transport wins and /mcp returns 404. Both defaults are contributed at lowest precedence (addLast) so users can override them. Users now only need spring.boot.admin.mcp.enabled=true. --- ...erverDefaultsEnvironmentPostProcessor.java | 49 +++++++++++++++++++ ...rDefaultsEnvironmentPostProcessorTest.java | 44 +++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java index 77dea862455..2fb9e9c8c32 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java @@ -46,6 +46,28 @@ * absent; in that case no version default is contributed and Spring AI's own fallback * applies. *

+ * + *

+ * The MCP server API type defaults to {@value #DEFAULT_SERVER_TYPE}. Spring Boot Admin + * runs on a reactive WebFlux/Netty stack and its actuator calls are non-blocking, so the + * asynchronous MCP server is the natural fit. Spring AI's own default is {@code sync}, + * which would introduce blocking bridges on the reactive event loop. Contributing + * {@code async} here means users only need to enable the MCP server via + * {@code spring.boot.admin.mcp.enabled=true} without also setting any {@code spring.ai.*} + * properties. + *

+ * + *

+ * The transport protocol defaults to {@value #DEFAULT_SERVER_PROTOCOL}. Although Spring + * AI's {@code McpServerProperties} Java default is {@code STREAMABLE}, the transport + * autoconfiguration selects the active transport via {@code @ConditionalOnProperty} + * evaluated against the {@code Environment} — before the properties object is bound. + * {@code McpServerSseWebFluxAutoConfiguration} carries {@code matchIfMissing=true} on its + * SSE condition, so when {@code spring.ai.mcp.server.protocol} is absent from the + * environment the legacy SSE transport wins and {@code /mcp} returns 404. Contributing + * {@code streamable} here ensures the Streamable HTTP transport is active out of the box, + * exposing the single {@code /mcp} endpoint that modern MCP clients expect. + *

*/ public class McpServerDefaultsEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { @@ -59,11 +81,36 @@ public class McpServerDefaultsEnvironmentPostProcessor implements EnvironmentPos */ static final String VERSION_PROPERTY = "spring.ai.mcp.server.version"; + /** + * The property that selects the MCP server API type ({@code sync} or {@code async}). + */ + static final String TYPE_PROPERTY = "spring.ai.mcp.server.type"; + + /** + * The property that selects the MCP transport protocol ({@code streamable}, + * {@code stateless}, or the legacy {@code sse}). + */ + static final String PROTOCOL_PROPERTY = "spring.ai.mcp.server.protocol"; + /** * Default server name advertised to MCP clients. */ static final String DEFAULT_SERVER_NAME = "Spring Boot Admin MCP Server"; + /** + * Default MCP server API type. Spring Boot Admin runs on a reactive WebFlux/Netty + * stack, so the asynchronous server is the natural fit. + */ + static final String DEFAULT_SERVER_TYPE = "async"; + + /** + * Default MCP transport protocol. Must be set explicitly in the environment because + * Spring AI's SSE transport condition carries {@code matchIfMissing=true} and wins + * when the property is absent, even though {@code McpServerProperties} defaults to + * {@code STREAMABLE}. + */ + static final String DEFAULT_SERVER_PROTOCOL = "streamable"; + /** * Name of the property source contributing the defaults. */ @@ -80,6 +127,8 @@ public McpServerDefaultsEnvironmentPostProcessor() { public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { Map defaults = new HashMap<>(); defaults.put(NAME_PROPERTY, DEFAULT_SERVER_NAME); + defaults.put(TYPE_PROPERTY, DEFAULT_SERVER_TYPE); + defaults.put(PROTOCOL_PROPERTY, DEFAULT_SERVER_PROTOCOL); String version = resolveVersion(); if (StringUtils.hasText(version)) { defaults.put(VERSION_PROPERTY, version); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java index 1b1ef7702c8..c1c844b5e07 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java @@ -64,6 +64,50 @@ void shouldNotOverrideUserProvidedVersion() { .isEqualTo("9.9.9"); } + @Test + void shouldContributeDefaultServerProtocol() { + MockEnvironment environment = new MockEnvironment(); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.PROTOCOL_PROPERTY)) + .isEqualTo("streamable"); + } + + @Test + void shouldNotOverrideUserProvidedProtocol() { + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources() + .addFirst(new MapPropertySource("userConfig", Collections + .singletonMap(McpServerDefaultsEnvironmentPostProcessor.PROTOCOL_PROPERTY, "stateless"))); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.PROTOCOL_PROPERTY)) + .isEqualTo("stateless"); + } + + @Test + void shouldContributeDefaultServerType() { + MockEnvironment environment = new MockEnvironment(); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.TYPE_PROPERTY)).isEqualTo("async"); + } + + @Test + void shouldNotOverrideUserProvidedType() { + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources() + .addFirst(new MapPropertySource("userConfig", + Collections.singletonMap(McpServerDefaultsEnvironmentPostProcessor.TYPE_PROPERTY, "sync"))); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.TYPE_PROPERTY)).isEqualTo("sync"); + } + @Test void defaultsPropertySourceShouldHaveLowestPrecedence() { MockEnvironment environment = new MockEnvironment(); From eedb4611b9299a984346a64182ec5fd5d942a290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 22:20:53 +0200 Subject: [PATCH 19/29] fix(mcp-sample): remove redundant spring.ai config from application.yml type=async and protocol=streamable are now contributed automatically by McpServerDefaultsEnvironmentPostProcessor. Users only need spring.boot.admin.mcp.enabled=true. --- .../src/main/resources/application.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml index d3356df5a5d..230a4e40ba5 100644 --- a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml @@ -7,11 +7,6 @@ spring: url: http://localhost:8080 mcp: enabled: true - ai: - mcp: - server: - type: ASYNC - protocol: STATELESS profiles: active: - insecure From 0e57d49ec2a48e4571417db2e291d845ed5aead9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 22:23:00 +0200 Subject: [PATCH 20/29] docs(mcp): update Quick Start and config reference --- .../src/site/docs/07-mcp/index.md | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index 63677906919..2c9b2f187fa 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -159,19 +159,18 @@ spring: admin: mcp: enabled: true - ai: - mcp: - server: - type: ASYNC - protocol: STATELESS ``` :::note `spring.boot.admin.mcp.enabled` defaults to `false`. Existing deployments are unaffected until you opt in. ::: -:::note You don't need to set `spring.ai.mcp.server.name` or `spring.ai.mcp.server.version` — Spring Boot Admin provides -sensible defaults for both. You can still override them explicitly if you want a custom name or version: +:::note You don't need to set any `spring.ai.mcp.server.*` properties. Spring Boot Admin automatically contributes +`type=async` and `protocol=streamable` as low-precedence defaults. Both are necessary: Spring AI's `sync` default +would block the reactive event loop, and its SSE transport condition carries `matchIfMissing=true` — meaning when +`protocol` is absent from the environment the legacy SSE transport activates instead of the Streamable HTTP endpoint +at `/mcp`. All values can still be overridden explicitly if needed — for example to report a custom name or +version: ```yaml title="application.yml" spring: @@ -280,10 +279,10 @@ Add to your Cursor MCP settings (`~/.cursor/mcp.json`): ### Generic MCP Client -Any MCP client that supports **Stateless Streamable HTTP** transport can connect: +Any MCP client that supports **Streamable HTTP** transport can connect: - **Endpoint**: `POST http://localhost:8080/mcp` -- **Transport**: Stateless Streamable HTTP (MCP spec 2025-03-26) +- **Transport**: Streamable HTTP (MCP spec 2025-03-26) - **Auth**: none by default (see [Security](#security)) ## Example Conversations @@ -445,8 +444,8 @@ Then pass credentials in the MCP client configuration: | `spring.boot.admin.mcp.tools.scheduled-tasks` | `true` | Register the `get-scheduled-tasks` tool | | `spring.boot.admin.mcp.tools.caches` | `true` | Register the `list-caches` tool | | `spring.boot.admin.mcp.tools.beans` | `true` | Register the `list-beans` tool | -| `spring.ai.mcp.server.type` | `SYNC` | Server type — use `ASYNC` for reactive tool methods | -| `spring.ai.mcp.server.protocol` | `SSE` | Transport protocol — use `STATELESS` for HTTP clients | +| `spring.ai.mcp.server.type` | `async` | Server API type. Spring Boot Admin overrides Spring AI's `sync` default to `async` to match its reactive WebFlux/Netty stack. Override to `sync` only if you have a specific reason. | +| `spring.ai.mcp.server.protocol` | `streamable` | Transport protocol. Spring Boot Admin sets this to `streamable` so the `/mcp` endpoint is active. Without it, Spring AI's SSE transport condition (`matchIfMissing=true`) activates the legacy SSE transport instead. Set to `stateless` for stateless HTTP or `sse` for the legacy SSE transport. | | `spring.ai.mcp.server.name` | `Spring Boot Admin MCP Server` | Server name reported to MCP clients. Override to report a custom value. | | `spring.ai.mcp.server.version` | current Spring Boot Admin version | Server version reported to MCP clients. Defaults to the running Spring Boot Admin version; override to report a custom value. | From c0e8a2e7a50112b779ac9f19b5026980fde487c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 23:00:13 +0200 Subject: [PATCH 21/29] docs(mcp): add instance ID to list-applications output and samples index - Update list-applications tool description to mention instance ID - Update fleet status example conversation to show id field in output - Add MCP sample to 09-samples index (list, directory tree, feature table) --- .../src/site/docs/07-mcp/index.md | 10 ++++---- .../src/site/docs/09-samples/index.md | 23 +++++++++++-------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md index 2c9b2f187fa..382382cca38 100644 --- a/spring-boot-admin-docs/src/site/docs/07-mcp/index.md +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -44,7 +44,7 @@ Each tool group below corresponds to an actuator endpoint. For a tool to work, t | Tool | Description | |---|---| -| `list-applications` | Lists all registered applications with their status and management URL | +| `list-applications` | Lists all registered applications with their instance ID, status, and management URL | | `get-status` | Returns the cached status (UP/DOWN/UNKNOWN) for a named application without making an actuator call | ### Beans @@ -292,10 +292,10 @@ Any MCP client that supports **Streamable HTTP** transport can connect: ``` You: list all registered applications Assistant: Registered applications (4): -- payment-service | status: UP | management: http://payment:8080/actuator -- order-service | status: UP | management: http://order:8080/actuator -- checkout-service | status: DOWN | management: http://checkout:8080/actuator -- inventory-service | status: UP | management: http://inventory:8080/actuator +- payment-service | id: a1b2c3d4e5f6 | status: UP | management: http://payment:8080/actuator +- order-service | id: b2c3d4e5f6a1 | status: UP | management: http://order:8080/actuator +- checkout-service | id: c3d4e5f6a1b2 | status: DOWN | management: http://checkout:8080/actuator +- inventory-service | id: d4e5f6a1b2c3 | status: UP | management: http://inventory:8080/actuator ``` ### Debugging a failing service diff --git a/spring-boot-admin-docs/src/site/docs/09-samples/index.md b/spring-boot-admin-docs/src/site/docs/09-samples/index.md index d2469d07943..84e8178b595 100644 --- a/spring-boot-admin-docs/src/site/docs/09-samples/index.md +++ b/spring-boot-admin-docs/src/site/docs/09-samples/index.md @@ -27,6 +27,7 @@ patterns. These samples provide working examples you can use as starting points - **[Hazelcast Sample](./60-sample-hazelcast.md)** - Clustered deployment with Hazelcast - **[Custom UI Sample](./70-sample-custom-ui.md)** - Custom UI extensions and branding +- **[MCP Sample](../07-mcp/)** - AI assistant integration via Model Context Protocol ## Repository Location @@ -42,7 +43,8 @@ spring-boot-admin-samples/ ├── spring-boot-admin-sample-consul/ ├── spring-boot-admin-sample-zookeeper/ ├── spring-boot-admin-sample-hazelcast/ -└── spring-boot-admin-sample-custom-ui/ +├── spring-boot-admin-sample-custom-ui/ +└── spring-boot-admin-sample-mcp/ ``` ## Running the Samples @@ -79,15 +81,16 @@ Most secured samples use: ## Sample Features Comparison -| Feature | Servlet | Reactive | Eureka | Consul | Zookeeper | Hazelcast | Custom UI | WAR | -|-------------------|---------|----------|---------|---------|-----------|-----------|-----------|---------| -| Web Stack | Servlet | WebFlux | Servlet | Servlet | Servlet | Servlet | Servlet | Servlet | -| Security | ✅ | ✅ | ✅ | ✅ | - | - | - | - | -| Service Discovery | Static | Static | Eureka | Consul | Zookeeper | Static | Static | Static | -| Clustering | - | - | - | - | - | ✅ | - | - | -| Custom UI | - | - | - | - | - | - | ✅ | - | -| JMX Support | ✅ | - | - | - | - | - | - | ✅ | -| Notifications | ✅ | - | - | - | - | - | - | - | +| Feature | Servlet | Reactive | Eureka | Consul | Zookeeper | Hazelcast | Custom UI | WAR | MCP | +|-------------------|---------|----------|---------|---------|-----------|-----------|-----------|---------|---------| +| Web Stack | Servlet | WebFlux | Servlet | Servlet | Servlet | Servlet | Servlet | Servlet | WebFlux | +| Security | ✅ | ✅ | ✅ | ✅ | - | - | - | - | - | +| Service Discovery | Static | Static | Eureka | Consul | Zookeeper | Static | Static | Static | Static | +| Clustering | - | - | - | - | - | ✅ | - | - | - | +| Custom UI | - | - | - | - | - | - | ✅ | - | - | +| JMX Support | ✅ | - | - | - | - | - | - | ✅ | - | +| Notifications | ✅ | - | - | - | - | - | - | - | - | +| MCP Server | - | - | - | - | - | - | - | - | ✅ | ## Common Configuration From 2789243448ae46387b6c9fff5db54fea4b298924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sat, 18 Jul 2026 23:08:30 +0200 Subject: [PATCH 22/29] feat(mcp): support instance ID as fallback lookup in withInstance Add instance ID to list-applications output. Allow all tools to resolve an instance by ID when no name match is found in the registry. --- .../admin/server/mcp/tools/ActuatorClient.java | 14 +++++++++++--- .../server/mcp/tools/ApplicationTools.java | 7 +++++-- .../server/mcp/tools/ApplicationToolsTest.java | 2 ++ .../admin/server/mcp/tools/BeansToolsTest.java | 3 +++ .../server/mcp/tools/CachesToolsTest.java | 3 +++ .../admin/server/mcp/tools/EnvToolsTest.java | 3 +++ .../server/mcp/tools/HealthToolsTest.java | 18 ++++++++++++++++++ .../mcp/tools/HttpExchangesToolsTest.java | 3 +++ .../server/mcp/tools/LoggersToolsTest.java | 3 +++ .../admin/server/mcp/tools/LogsToolsTest.java | 3 +++ .../server/mcp/tools/MetricsToolsTest.java | 3 +++ .../server/mcp/tools/OperationsToolsTest.java | 3 +++ .../mcp/tools/ScheduledTasksToolsTest.java | 3 +++ .../server/mcp/tools/ThreadDumpToolsTest.java | 3 +++ 14 files changed, 66 insertions(+), 5 deletions(-) diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java index 3a490c853c5..b72e495b1f3 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java @@ -29,6 +29,7 @@ import de.codecentric.boot.admin.server.domain.entities.Instance; import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; import de.codecentric.boot.admin.server.web.client.InstanceWebClient; /** @@ -68,16 +69,23 @@ public ActuatorClient(InstanceRepository instanceRepository, InstanceWebClient i } /** - * Resolves the first registered instance for the given application name, applies + * Resolves a registered instance by application name or instance ID, applies * {@code action}, and falls back to a plain-text "not found" message when no instance - * is registered. - * @param applicationName the registered application name (case-insensitive) + * matches. + * + *

+ * The lookup first tries to find an instance by the given {@code applicationName}. If + * no match is found it attempts to interpret the value as an instance ID. + *

+ * @param applicationName the registered application name (case-sensitive) or instance + * ID * @param action function to execute against the resolved instance * @return the action result, or a plain-text "not found" message */ public Mono withInstance(String applicationName, Function> action) { return this.instanceRepository.findByName(applicationName) .next() + .switchIfEmpty(this.instanceRepository.find(InstanceId.of(applicationName))) .flatMap(action) .switchIfEmpty(Mono.just("Application '" + applicationName + "' not found in registry.")); } diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java index 3fc4c0c38de..8ec2c8432d1 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java @@ -48,13 +48,13 @@ public ApplicationTools(InstanceRepository instanceRepository) { /** * Lists all Spring Boot applications currently registered with Spring Boot Admin, - * including their name, status, and management URL. + * including their name, instance ID, status, and management URL. * @return a plain-text list of registered applications, or a message indicating none * are registered */ @McpTool(name = "list-applications", description = "List all Spring Boot applications currently registered with Spring Boot Admin. " - + "Returns name, status (UP/DOWN/UNKNOWN) and management URL for each instance.") + + "Returns name, status (UP/DOWN/UNKNOWN), instance ID and management URL for each instance.") public Mono listApplications() { return this.instanceRepository.findAll().collectList().map((instances) -> { if (instances.isEmpty()) { @@ -64,10 +64,13 @@ public Mono listApplications() { for (Instance instance : instances) { String name = instance.getRegistration().getName(); String status = instance.getStatusInfo().getStatus(); + String id = instance.getId().getValue(); String managementUrl = (instance.getRegistration().getManagementUrl() != null) ? instance.getRegistration().getManagementUrl() : "N/A"; sb.append("- ") .append(name) + .append(" | id: ") + .append(id) .append(" | status: ") .append(status) .append(" | management: ") diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java index f16b071a86c..fa7b819525f 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java @@ -62,8 +62,10 @@ void listApplications_withRegisteredApps_returnsFormattedList() { StepVerifier.create(this.applicationTools.listApplications()).assertNext((result) -> { assertThat(result).contains("Registered applications (2)"); assertThat(result).contains("payment-service"); + assertThat(result).contains("id: id-1"); assertThat(result).contains("UP"); assertThat(result).contains("order-service"); + assertThat(result).contains("id: id-2"); assertThat(result).contains("DOWN"); }).verifyComplete(); } diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java index 1debe2d91ef..619af098787 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -40,6 +41,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +67,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.beansTools = new BeansTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java index 8a27c7f5132..449c9ba0492 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -40,6 +41,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +67,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.cachesTools = new CachesTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java index c8b7159bda8..8c7680e3d79 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -40,6 +41,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +67,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.envTools = new EnvTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java index c0e818384cf..917b02177b2 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -43,6 +44,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -68,6 +70,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.healthTools = new HealthTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); @@ -152,10 +155,25 @@ void getStatus_appDown_returnsDownStatus() { @Test void getStatus_appNotFound_returnsNotFoundMessage() { when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + when(this.instanceRepository.find(InstanceId.of("ghost"))).thenReturn(Mono.empty()); StepVerifier.create(this.healthTools.getStatus("ghost")) .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) .verifyComplete(); } + @Test + void getStatus_byInstanceId_returnsStatus() { + Instance instance = Instance.create(InstanceId.of("id-5")) + .register(Registration.create("inventory-service", "http://localhost/health").build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("id-5")).thenReturn(Flux.empty()); + when(this.instanceRepository.find(InstanceId.of("id-5"))).thenReturn(Mono.just(instance)); + + StepVerifier.create(this.healthTools.getStatus("id-5")) + .assertNext((result) -> assertThat(result).contains("status: UP")) + .verifyComplete(); + } + } diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java index 1c0d75e786c..cb3417c76a7 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -40,6 +41,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +67,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.httpExchangesTools = new HttpExchangesTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java index 729f03e996c..4a3ee5f4b1f 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -42,6 +43,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -67,6 +69,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.loggersTools = new LoggersTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java index 4b71ea9a762..6414e2d7647 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -40,6 +41,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +67,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.logsTools = new LogsTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java index faa40874aaa..6fee7f06f2b 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -40,6 +41,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +67,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.metricsTools = new MetricsTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java index e2f789b405f..06dacf12158 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -41,6 +42,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -66,6 +68,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.operationsTools = new OperationsTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java index e80471585d6..48366df1e6f 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -40,6 +41,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +67,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.scheduledTasksTools = new ScheduledTasksTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450))); diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java index ca692788ce2..23b4f467649 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import de.codecentric.boot.admin.server.domain.entities.Instance; @@ -40,6 +41,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -65,6 +67,7 @@ static void tearDownClass() { void setUp() { this.wireMock.start(); this.instanceRepository = mock(InstanceRepository.class); + when(this.instanceRepository.find(any())).thenReturn(Mono.empty()); InstanceWebClient instanceWebClient = InstanceWebClient.builder().filter(rewriteEndpointUrl()).build(); this.threadDumpTools = new ThreadDumpTools( new ActuatorClient(this.instanceRepository, instanceWebClient, Duration.ofMillis(450)), From 9e560e806bbdcd28f7bb7e982e09591af8cc1e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sun, 19 Jul 2026 15:20:01 +0200 Subject: [PATCH 23/29] feat(mcp): add tools.enabled global flag to disable all tools at once Introduces ConditionalOnMcpToolEnabled and @OnMcpToolEnabled to replace the per-bean @ConditionalOnProperty. Resolution order: per-tool property wins when explicitly set, otherwise falls back to tools.enabled (default true). Allows disabling all categories at once and selectively re-enabling individual ones via their specific property: spring.boot.admin.mcp.tools.enabled=false spring.boot.admin.mcp.tools.health=true --- .../config/ConditionalOnMcpToolEnabled.java | 104 ++++++++++++++++++ .../mcp/config/McpAutoConfiguration.java | 37 +++---- .../server/mcp/config/McpProperties.java | 23 ++++ .../mcp/config/McpAutoConfigurationTest.java | 41 +++++++ 4 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/ConditionalOnMcpToolEnabled.java diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/ConditionalOnMcpToolEnabled.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/ConditionalOnMcpToolEnabled.java new file mode 100644 index 00000000000..9e6f9ab3724 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/ConditionalOnMcpToolEnabled.java @@ -0,0 +1,104 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * Condition that determines whether an individual MCP tool category bean should be + * created, respecting both the global {@code spring.boot.admin.mcp.tools.enabled} default + * and the per-category override. + * + *

+ * Resolution order: + *

+ *
    + *
  1. If the per-category property (e.g. {@code spring.boot.admin.mcp.tools.health}) is + * explicitly set, that value wins.
  2. + *
  3. Otherwise, the global {@code spring.boot.admin.mcp.tools.enabled} default is + * used.
  4. + *
+ * + *

+ * This allows disabling all tools at once while selectively re-enabling individual + * categories: + *

+ * + *
+ * spring.boot.admin.mcp.tools.enabled=false
+ * spring.boot.admin.mcp.tools.health=true   # only health tools are registered
+ * 
+ */ +public class ConditionalOnMcpToolEnabled implements Condition { + + static final String GLOBAL_KEY = "spring.boot.admin.mcp.tools.enabled"; + + static final String TOOL_PREFIX = "spring.boot.admin.mcp.tools."; + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + var attrs = metadata.getAnnotationAttributes(OnMcpToolEnabled.class.getName()); + if (attrs == null) { + return true; + } + String toolKey = TOOL_PREFIX + attrs.get("value"); + var env = context.getEnvironment(); + + // If the per-tool property is explicitly present, it takes precedence. + String toolValue = env.getProperty(toolKey); + if (toolValue != null) { + return Boolean.parseBoolean(toolValue); + } + + // Fall back to the global tools.enabled flag (defaults to true). + return env.getProperty(GLOBAL_KEY, Boolean.class, true); + } + + /** + * Meta-annotation used on each tool bean method in {@link McpAutoConfiguration}. + * + *

+ * {@code value} must be the kebab-case property segment after + * {@code spring.boot.admin.mcp.tools.} (e.g. {@code "health"}, + * {@code "thread-dump"}). + *

+ */ + @Target({ ElementType.METHOD, ElementType.TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @Conditional(ConditionalOnMcpToolEnabled.class) + public @interface OnMcpToolEnabled { + + /** + * The kebab-case tool-category key, e.g. {@code "health"} or + * {@code "thread-dump"}. + * @return the tool category key + */ + String value(); + + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java index 66cfa20a7d4..e2ec17436f7 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.java @@ -22,6 +22,7 @@ import org.springframework.context.annotation.Bean; import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.mcp.config.ConditionalOnMcpToolEnabled.OnMcpToolEnabled; import de.codecentric.boot.admin.server.mcp.tools.ActuatorClient; import de.codecentric.boot.admin.server.mcp.tools.ApplicationTools; import de.codecentric.boot.admin.server.mcp.tools.BeansTools; @@ -71,8 +72,7 @@ public ActuatorClient actuatorClient(InstanceRepository instanceRepository, * @return the configured {@link ApplicationTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "applications", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("applications") public ApplicationTools applicationTools(InstanceRepository instanceRepository) { return new ApplicationTools(instanceRepository); } @@ -83,8 +83,7 @@ public ApplicationTools applicationTools(InstanceRepository instanceRepository) * @return the configured {@link HealthTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "health", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("health") public HealthTools healthTools(ActuatorClient actuatorClient) { return new HealthTools(actuatorClient); } @@ -95,8 +94,7 @@ public HealthTools healthTools(ActuatorClient actuatorClient) { * @return the configured {@link MetricsTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "metrics", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("metrics") public MetricsTools metricsTools(ActuatorClient actuatorClient) { return new MetricsTools(actuatorClient); } @@ -107,8 +105,7 @@ public MetricsTools metricsTools(ActuatorClient actuatorClient) { * @return the configured {@link EnvTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "env", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("env") public EnvTools envTools(ActuatorClient actuatorClient) { return new EnvTools(actuatorClient); } @@ -119,8 +116,7 @@ public EnvTools envTools(ActuatorClient actuatorClient) { * @return the configured {@link LogsTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "logs", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("logs") public LogsTools logsTools(ActuatorClient actuatorClient) { return new LogsTools(actuatorClient); } @@ -132,8 +128,7 @@ public LogsTools logsTools(ActuatorClient actuatorClient) { * @return the configured {@link OperationsTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "operations", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("operations") public OperationsTools operationsTools(ActuatorClient actuatorClient) { return new OperationsTools(actuatorClient); } @@ -144,8 +139,7 @@ public OperationsTools operationsTools(ActuatorClient actuatorClient) { * @return the configured {@link LoggersTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "loggers", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("loggers") public LoggersTools loggersTools(ActuatorClient actuatorClient) { return new LoggersTools(actuatorClient); } @@ -157,8 +151,7 @@ public LoggersTools loggersTools(ActuatorClient actuatorClient) { * @return the configured {@link ThreadDumpTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "thread-dump", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("thread-dump") public ThreadDumpTools threadDumpTools(ActuatorClient actuatorClient, McpProperties mcpProperties) { return new ThreadDumpTools(actuatorClient, mcpProperties.getThreadDumpTimeout()); } @@ -169,8 +162,7 @@ public ThreadDumpTools threadDumpTools(ActuatorClient actuatorClient, McpPropert * @return the configured {@link HttpExchangesTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "http-exchanges", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("http-exchanges") public HttpExchangesTools httpExchangesTools(ActuatorClient actuatorClient) { return new HttpExchangesTools(actuatorClient); } @@ -181,8 +173,7 @@ public HttpExchangesTools httpExchangesTools(ActuatorClient actuatorClient) { * @return the configured {@link ScheduledTasksTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "scheduled-tasks", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("scheduled-tasks") public ScheduledTasksTools scheduledTasksTools(ActuatorClient actuatorClient) { return new ScheduledTasksTools(actuatorClient); } @@ -193,8 +184,7 @@ public ScheduledTasksTools scheduledTasksTools(ActuatorClient actuatorClient) { * @return the configured {@link CachesTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "caches", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("caches") public CachesTools cachesTools(ActuatorClient actuatorClient) { return new CachesTools(actuatorClient); } @@ -206,8 +196,7 @@ public CachesTools cachesTools(ActuatorClient actuatorClient) { * @return the configured {@link BeansTools} */ @Bean - @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "beans", havingValue = "true", - matchIfMissing = true) + @OnMcpToolEnabled("beans") public BeansTools beansTools(ActuatorClient actuatorClient) { return new BeansTools(actuatorClient); } diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java index 2c733ef0117..3480b471b20 100644 --- a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.java @@ -88,10 +88,33 @@ public McpProperties() { * Category-level enablement flags for the exposed MCP tools. Disabling a category * prevents the corresponding tool bean from being created, so its tools are never * advertised. Changes take effect on server restart. + * + *

+ * {@code enabled} acts as a global default for all categories. When set to + * {@code false}, every category is disabled unless its individual flag is explicitly + * set to {@code true}. When {@code true} (the default), each category flag controls + * whether that category is registered. + *

+ * + *

+ * Example — disable all tools except health: + *

+ * + *
+	 * spring.boot.admin.mcp.tools.enabled=false
+	 * spring.boot.admin.mcp.tools.health=true
+	 * 
*/ @lombok.Data public static class Tools { + /** + * Global default for all tool categories. When {@code false}, every category is + * disabled unless its individual flag explicitly overrides it to {@code true}. + * Defaults to {@code true}. + */ + private boolean enabled = true; + /** * Whether the {@code list-applications} tool is available. */ diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java index 42bb596e5a7..c4dddaccf76 100644 --- a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java @@ -107,6 +107,47 @@ void envDisabled_envToolBeanAbsentOthersPresent() { }); } + @Test + void toolsEnabledFalse_allToolBeansAbsent() { + this.contextRunner + .withPropertyValues("spring.boot.admin.mcp.enabled=true", "spring.boot.admin.mcp.tools.enabled=false") + .run((context) -> { + assertThat(context).doesNotHaveBean(ApplicationTools.class); + assertThat(context).doesNotHaveBean(HealthTools.class); + assertThat(context).doesNotHaveBean(MetricsTools.class); + assertThat(context).doesNotHaveBean(EnvTools.class); + assertThat(context).doesNotHaveBean(LogsTools.class); + assertThat(context).doesNotHaveBean(OperationsTools.class); + assertThat(context).doesNotHaveBean(LoggersTools.class); + assertThat(context).doesNotHaveBean(ThreadDumpTools.class); + assertThat(context).doesNotHaveBean(HttpExchangesTools.class); + assertThat(context).doesNotHaveBean(ScheduledTasksTools.class); + assertThat(context).doesNotHaveBean(CachesTools.class); + assertThat(context).doesNotHaveBean(BeansTools.class); + }); + } + + @Test + void toolsEnabledFalse_perToolOverrideReenable() { + this.contextRunner + .withPropertyValues("spring.boot.admin.mcp.enabled=true", "spring.boot.admin.mcp.tools.enabled=false", + "spring.boot.admin.mcp.tools.health=true") + .run((context) -> { + assertThat(context).hasSingleBean(HealthTools.class); + assertThat(context).doesNotHaveBean(ApplicationTools.class); + assertThat(context).doesNotHaveBean(MetricsTools.class); + assertThat(context).doesNotHaveBean(EnvTools.class); + assertThat(context).doesNotHaveBean(LogsTools.class); + assertThat(context).doesNotHaveBean(OperationsTools.class); + assertThat(context).doesNotHaveBean(LoggersTools.class); + assertThat(context).doesNotHaveBean(ThreadDumpTools.class); + assertThat(context).doesNotHaveBean(HttpExchangesTools.class); + assertThat(context).doesNotHaveBean(ScheduledTasksTools.class); + assertThat(context).doesNotHaveBean(CachesTools.class); + assertThat(context).doesNotHaveBean(BeansTools.class); + }); + } + @Configuration(proxyBeanMethods = false) static class RequiredBeansConfiguration { From 30b88133d11b7b0b9babdc558d14a62a323d3bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sun, 19 Jul 2026 15:20:15 +0200 Subject: [PATCH 24/29] feat(mcp-ui): add MCP tools overview page Live-fetches the tool list from /mcp via the MCP initialize + tools/list handshake. McpService handles session init, SSE/JSON parsing, and tool mapping. The nav item is shown/hidden based on MCP endpoint availability via McpService.isAvailable(). Vite dev config: proxy POST /mcp to the backend while routing GET to the Vue SPA; suppress the stale built mcp-ui.js extension so the dev source registration takes precedence. --- .../src/main/frontend/views/mcp/Handle.vue | 17 +++ .../src/main/frontend/views/mcp/McpView.vue | 135 ++++++++++++++++++ .../src/main/frontend/views/mcp/i18n.en.json | 17 +++ .../src/main/frontend/views/mcp/index.ts | 16 +++ .../main/frontend/views/mcp/mcp.service.ts | 134 +++++++++++++++++ spring-boot-admin-server-ui/vite.config.mts | 14 +- 6 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 spring-boot-admin-server-ui/src/main/frontend/views/mcp/Handle.vue create mode 100644 spring-boot-admin-server-ui/src/main/frontend/views/mcp/McpView.vue create mode 100644 spring-boot-admin-server-ui/src/main/frontend/views/mcp/i18n.en.json create mode 100644 spring-boot-admin-server-ui/src/main/frontend/views/mcp/index.ts create mode 100644 spring-boot-admin-server-ui/src/main/frontend/views/mcp/mcp.service.ts diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/mcp/Handle.vue b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/Handle.vue new file mode 100644 index 00000000000..f21433d0958 --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/Handle.vue @@ -0,0 +1,17 @@ + diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/mcp/McpView.vue b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/McpView.vue new file mode 100644 index 00000000000..500fd3e728e --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/McpView.vue @@ -0,0 +1,135 @@ + + + diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/mcp/i18n.en.json b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/i18n.en.json new file mode 100644 index 00000000000..e31032c54c9 --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/i18n.en.json @@ -0,0 +1,17 @@ +{ + "mcp": { + "title": "MCP-Server", + "label": "MCP-Server", + "description": "This Spring Boot Admin server exposes an MCP (Model Context Protocol) endpoint at /mcp. AI assistants and MCP clients can connect to it and use the following tools to inspect and manage registered Spring Boot applications.", + "loading": "Loading tools...", + "error": "Failed to load tools", + "toolCount": "{count} tools available", + "param": "Parameter", + "type": "Type", + "paramDescription": "Description", + "required": "Required", + "yes": "yes", + "no": "no", + "noParams": "No parameters" + } +} diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/mcp/index.ts b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/index.ts new file mode 100644 index 00000000000..7a632a8e0ab --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/index.ts @@ -0,0 +1,16 @@ +import ViewRegistry from '@/viewRegistry'; +import handle from '@/views/mcp/Handle.vue'; +import McpView from '@/views/mcp/McpView.vue'; +import { McpService } from '@/views/mcp/mcp.service'; + +export default { + install({ viewRegistry }: { viewRegistry: ViewRegistry }) { + viewRegistry.addView({ + name: 'mcp', + path: '/mcp', + component: McpView, + handle: handle, + isEnabled: () => McpService.isAvailable(), + }); + }, +}; diff --git a/spring-boot-admin-server-ui/src/main/frontend/views/mcp/mcp.service.ts b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/mcp.service.ts new file mode 100644 index 00000000000..2d9388e7dfc --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/views/mcp/mcp.service.ts @@ -0,0 +1,134 @@ +export type ToolParam = { + name: string; + type: string; + required: boolean; + description: string; +}; + +export type McpTool = { + name: string; + description: string; + params: ToolParam[]; +}; + +export const CATEGORY_COLORS: Record = { + applications: 'bg-blue-100 text-blue-800', + health: 'bg-green-100 text-green-800', + metrics: 'bg-purple-100 text-purple-800', + env: 'bg-yellow-100 text-yellow-800', + logs: 'bg-orange-100 text-orange-800', + operations: 'bg-red-100 text-red-800', + loggers: 'bg-indigo-100 text-indigo-800', + threadDump: 'bg-pink-100 text-pink-800', + httpExchanges: 'bg-teal-100 text-teal-800', + scheduledTasks: 'bg-cyan-100 text-cyan-800', + caches: 'bg-lime-100 text-lime-800', + beans: 'bg-amber-100 text-amber-800', + other: 'bg-slate-100 text-slate-800', +}; + +export class McpService { + static categoryOf(name: string): string { + if (name.includes('application')) return 'applications'; + if (name.includes('health') || name.includes('status')) return 'health'; + if (name.includes('metric')) return 'metrics'; + if (name.includes('env')) return 'env'; + if (name.includes('log') && !name.includes('logger')) return 'logs'; + if (name.includes('restart') || name.includes('refresh')) + return 'operations'; + if (name.includes('logger')) return 'loggers'; + if (name.includes('thread')) return 'threadDump'; + if (name.includes('http')) return 'httpExchanges'; + if (name.includes('scheduled')) return 'scheduledTasks'; + if (name.includes('cache')) return 'caches'; + if (name.includes('bean')) return 'beans'; + return 'other'; + } + + static async isAvailable(): Promise { + try { + await McpService.initializeSession(); + return true; + } catch { + return false; + } + } + + static async fetchTools(): Promise { + const sessionId = await McpService.initializeSession(); + + const toolsRes = await fetch('/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'Mcp-Session-Id': sessionId, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }), + }); + + const json = await McpService.parseResponse(toolsRes); + const rawTools: any[] = json?.result?.tools ?? []; + return rawTools.map(McpService.mapTool); + } + + private static async initializeSession(): Promise { + const res = await fetch('/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'sba-ui', version: '1.0' }, + }, + }), + }); + + const sessionId = res.headers.get('Mcp-Session-Id'); + if (!sessionId) { + throw new Error('No Mcp-Session-Id returned from initialize'); + } + return sessionId; + } + + private static async parseResponse(res: Response): Promise { + const contentType = res.headers.get('Content-Type') ?? ''; + if (contentType.includes('text/event-stream')) { + const text = await res.text(); + const dataLine = text.split('\n').find((l) => l.startsWith('data:')); + if (!dataLine) throw new Error('No data line in SSE response'); + return JSON.parse(dataLine.slice('data:'.length)); + } + return res.json(); + } + + private static mapTool(raw: any): McpTool { + const props = raw.inputSchema?.properties ?? {}; + const required: string[] = raw.inputSchema?.required ?? []; + const params: ToolParam[] = Object.entries(props).map( + ([k, v]: [string, any]) => ({ + name: k, + type: v.type ?? 'string', + required: required.includes(k), + description: v.description ?? '', + }), + ); + return { + name: raw.name, + description: raw.description ?? '', + params, + }; + } +} diff --git a/spring-boot-admin-server-ui/vite.config.mts b/spring-boot-admin-server-ui/vite.config.mts index 1be72d6ff5f..91c7243c87a 100644 --- a/spring-boot-admin-server-ui/vite.config.mts +++ b/spring-boot-admin-server-ui/vite.config.mts @@ -3,7 +3,7 @@ import tailwindcss from '@tailwindcss/vite'; import vue from '@vitejs/plugin-vue'; import { resolve } from 'path'; import { visualizer } from 'rollup-plugin-visualizer'; -import { defineConfig, loadEnv } from 'vite'; +import { Plugin, defineConfig, loadEnv } from 'vite'; import { viteStaticCopy } from 'vite-plugin-static-copy'; import vueDevTools from 'vite-plugin-vue-devtools'; @@ -94,13 +94,23 @@ export default defineConfig(({ mode }) => { const isEventStream = req.headers.accept === 'text/event-stream'; const isAjaxCall = req.headers['x-requested-with'] === 'XMLHttpRequest'; - const isFile = req.url.indexOf('.js') !== -1; + const isFile = req.url.includes('.js'); const redirectToIndex = !(isAjaxCall || isEventStream) && !isFile; if (redirectToIndex) { return '/index.html'; } }, }, + '^/mcp$': { + target: 'http://localhost:8080', + changeOrigin: true, + bypass: (req) => { + // Only proxy POST requests (MCP protocol); GET goes to Vue Router + if (req.method !== 'POST') { + return '/index.html'; + } + }, + }, }, }, }; From 4285167f9e109bc7d25f70a2b3dd7f30a8837397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20Ko=CC=88ninger?= Date: Sun, 19 Jul 2026 15:20:28 +0200 Subject: [PATCH 25/29] fix(ui): support async isEnabled for view visibility navbar: wrap isAboutEnabled/isMcpEnabled in computedAsync so async checks resolve correctly instead of returning a truthy Promise object. Fix topLevelViews to actually filter using the resolved isEnabled result, which was awaited but previously discarded. sidebar: convert enabledViews to per-view computedAsync instances so sidebar items appear as each check resolves rather than waiting for the slowest one (no Promise.all barrier). viewRegistry/global.d.ts: widen isEnabled type signature to accept boolean | Promise. Adds @vueuse/core for computedAsync. --- spring-boot-admin-server-ui/package-lock.json | 45 +++++++++++++++++++ spring-boot-admin-server-ui/package.json | 1 + .../src/main/frontend/global.d.ts | 4 +- .../src/main/frontend/shell/navbar.vue | 30 +++++++++---- .../src/main/frontend/viewRegistry.ts | 10 ++--- .../views/instances/shell/sidebar.vue | 26 ++++++++--- 6 files changed, 94 insertions(+), 22 deletions(-) diff --git a/spring-boot-admin-server-ui/package-lock.json b/spring-boot-admin-server-ui/package-lock.json index 4da4c43f612..8e81c30e9a8 100644 --- a/spring-boot-admin-server-ui/package-lock.json +++ b/spring-boot-admin-server-ui/package-lock.json @@ -18,6 +18,7 @@ "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@types/sanitize-html": "^2.16.0", + "@vueuse/core": "14.3.0", "ansi_up": "6.0.6", "autolinker": "4.1.5", "axios": "1.18.1", @@ -4261,6 +4262,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -5099,6 +5106,44 @@ } } }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/@webcontainer/env": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz", diff --git a/spring-boot-admin-server-ui/package.json b/spring-boot-admin-server-ui/package.json index 25868675a4b..e4dd962fcbb 100644 --- a/spring-boot-admin-server-ui/package.json +++ b/spring-boot-admin-server-ui/package.json @@ -29,6 +29,7 @@ "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.20", "@types/sanitize-html": "^2.16.0", + "@vueuse/core": "14.3.0", "ansi_up": "6.0.6", "autolinker": "4.1.5", "axios": "1.18.1", diff --git a/spring-boot-admin-server-ui/src/main/frontend/global.d.ts b/spring-boot-admin-server-ui/src/main/frontend/global.d.ts index a17bcc8fb00..065b27a5eb2 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/global.d.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/global.d.ts @@ -107,7 +107,7 @@ declare global { path?: string; href?: string; order: number; - isEnabled: () => boolean; + isEnabled: () => boolean | Promise; component: Raw; group: string; hasChildren: boolean; @@ -124,7 +124,7 @@ declare global { order?: number; group?: string; component: Component; - isEnabled?: () => boolean; + isEnabled?: () => boolean | Promise; } interface LinkView { diff --git a/spring-boot-admin-server-ui/src/main/frontend/shell/navbar.vue b/spring-boot-admin-server-ui/src/main/frontend/shell/navbar.vue index 478d5396f37..8a496f4f52b 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/shell/navbar.vue +++ b/spring-boot-admin-server-ui/src/main/frontend/shell/navbar.vue @@ -68,14 +68,17 @@ icon="question-circle" /> + + +