+ */
+@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.
+ *
+ */
+@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
-
+
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.codecentricspring-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.codecentricspring-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:
+ *
+ *
+ *
If the per-category property (e.g. {@code spring.boot.admin.mcp.tools.health}) is
+ * explicitly set, that value wins.
+ *
Otherwise, the global {@code spring.boot.admin.mcp.tools.enabled} default is
+ * used.
+ *
+ *
+ *
+ * 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:
+ *