diff --git a/pom.xml b/pom.xml index ae536cfc7c4..65849bf1310 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ 4.1.0 2025.1.2 + 2.0.0 2.6.0 @@ -94,10 +95,12 @@ spring-boot-admin-dependencies spring-boot-admin-build spring-boot-admin-server + spring-boot-admin-server-mcp spring-boot-admin-server-ui spring-boot-admin-client spring-boot-admin-docs spring-boot-admin-starter-server + spring-boot-admin-starter-server-mcp spring-boot-admin-starter-client spring-boot-admin-samples diff --git a/spring-boot-admin-dependencies/pom.xml b/spring-boot-admin-dependencies/pom.xml index 1d6b5973343..8e3176b8f5b 100644 --- a/spring-boot-admin-dependencies/pom.xml +++ b/spring-boot-admin-dependencies/pom.xml @@ -40,6 +40,11 @@ spring-boot-admin-server ${revision} + + de.codecentric + spring-boot-admin-server-mcp + ${revision} + de.codecentric spring-boot-admin-server-ui @@ -60,6 +65,11 @@ spring-boot-admin-starter-server ${revision} + + de.codecentric + spring-boot-admin-starter-server-mcp + ${revision} + diff --git a/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json b/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json new file mode 100644 index 00000000000..b80211434da --- /dev/null +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/_category_.json @@ -0,0 +1,4 @@ +{ + "position": 7, + "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 new file mode 100644 index 00000000000..382382cca38 --- /dev/null +++ b/spring-boot-admin-docs/src/site/docs/07-mcp/index.md @@ -0,0 +1,468 @@ +--- +sidebar_position: 7 +sidebar_custom_props: + icon: 'cpu' +--- + +# MCP Integration (experimental) + +Spring Boot Admin can act as a **Model Context Protocol (MCP) server**, exposing your registered applications to AI +assistants. This lets you monitor and manage your Spring Boot applications conversationally — without leaving your chat +interface. + +``` +"What's the heap usage of payment-service?" +"Are there errors in the checkout-service logs?" +"Restart order-service" +"Refresh the configuration for inventory-service" +``` + +## How It Works + +```mermaid +flowchart LR + AI[AI Assistant\nOpenCode / Claude / ChatGPT] + SBA[Spring Boot Admin\nMCP Server] + Apps[Spring Boot\nApplications] + AI -->|MCP tools| SBA + SBA -->|/actuator/*| Apps +``` + +Spring Boot Admin acts as a bridge: the AI assistant calls MCP tools, which Spring Boot Admin translates into actuator +calls against your registered applications. Responses are formatted as plain text for natural display in the chat. + +## Available Tools + +:::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 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 + +| 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 + +### 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**: + +```xml title="pom.xml" + + + de.codecentric + spring-boot-admin-starter-server-mcp + +``` + +:::tip Add `spring-boot-admin-starter-server` alongside the MCP starter if you also want the Web UI. Both run on the +same port, giving you the UI and the MCP server side by side. +::: + +:::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 + +```yaml title="application.yml" +spring: + boot: + admin: + mcp: + enabled: true +``` + +:::note +`spring.boot.admin.mcp.enabled` defaults to `false`. Existing deployments are unaffected until you opt in. +::: + +:::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: + 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: + +``` +http://localhost:8080/mcp +``` + +See the [AI Tool Configuration](#ai-tool-configuration) section below for tool-specific instructions. + +## AI Tool Configuration + +### OpenCode + +Add to your `opencode.json` (global at `~/.config/opencode/opencode.json` or per-project): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "spring-boot-admin": { + "type": "remote", + "url": "http://localhost:8080/mcp", + "enabled": true + } + } +} +``` + +Verify the connection: + +```bash +opencode mcp list +``` + +You should see `spring-boot-admin connected`. Then ask naturally in any session: + +``` +list all registered spring boot applications +what is the health of payment-service? +get the last 100 log lines for order-service +restart checkout-service +``` + +### Claude Desktop + +Add to your Claude Desktop configuration file: + +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +```json title="claude_desktop_config.json" +{ + "mcpServers": { + "spring-boot-admin": { + "type": "streamableHttp", + "url": "http://localhost:8080/mcp" + } + } +} +``` + +Restart Claude Desktop after saving. The Spring Boot Admin tools will appear in the tool list. + +### Claude Code (claude.ai/code) + +Add to your project or global MCP configuration: + +```json title=".mcp.json" +{ + "mcpServers": { + "spring-boot-admin": { + "type": "streamableHttp", + "url": "http://localhost:8080/mcp" + } + } +} +``` + +### Cursor + +Add to your Cursor MCP settings (`~/.cursor/mcp.json`): + +```json title="~/.cursor/mcp.json" +{ + "mcpServers": { + "spring-boot-admin": { + "url": "http://localhost:8080/mcp", + "type": "streamableHttp" + } + } +} +``` + +### Generic MCP Client + +Any MCP client that supports **Streamable HTTP** transport can connect: + +- **Endpoint**: `POST http://localhost:8080/mcp` +- **Transport**: Streamable HTTP (MCP spec 2025-03-26) +- **Auth**: none by default (see [Security](#security)) + +## Example Conversations + +### Fleet status check + +``` +You: list all registered applications +Assistant: Registered applications (4): +- payment-service | 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 + +``` +You: what is the health of checkout-service? +Assistant: checkout-service health: DOWN + db: DOWN + diskSpace: UP + +You: get the last 50 log lines for checkout-service +Assistant: Last 50 lines of checkout-service log: + ... + ERROR 2026-05-17 09:12:43 - Connection refused: jdbc:postgresql://db:5432/checkout + +You: restart checkout-service +Assistant: checkout-service restart initiated successfully. +``` + +### Metrics investigation + +``` +You: list metrics for payment-service +Assistant: Available metrics for payment-service (42): +- jvm.memory.used +- jvm.memory.max +- system.cpu.usage +- http.server.requests +... + +You: get the jvm.memory.used metric for payment-service +Assistant: payment-service — jvm.memory.used: + VALUE: 1258291200 bytes +``` + +### Inspecting configuration + +``` +You: list the env properties for payment-service filtered by datasource +Assistant: Environment for payment-service (filtered by "datasource"): + +[application.yml] (2): + spring.datasource.url = jdbc:postgresql://db:5432/payment + spring.datasource.username = payment + +You: what is the HELLO env variable for payment-service? +Assistant: payment-service — HELLO: + value: world + source: systemEnvironment +``` + +### Configuration refresh + +``` +You: refresh the configuration for inventory-service +Assistant: Configuration refreshed for inventory-service. Changed keys: ["app.feature.newFlow","app.cache.ttl"] +``` + +## Requirements in Monitored Applications + +Certain tools require additional setup in the monitored applications: + +| Tool | Requirement | +|----------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `get-logs` | `logging.file.name` or `logging.file.path` configured; `logfile` actuator endpoint exposed | +| `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: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: ALWAYS + restart: + enabled: true +logging: + file: + name: "logs/my-application.log" +``` + +## Security + +By default, MCP endpoints are open. For production deployments, restrict access via Spring Security. + +The simplest approach is to use the existing Spring Boot Admin security configuration and extend it to protect `/mcp`: + +```java title="SecurityConfiguration.java" + +@Bean +@Profile("secure") +public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + return http + .authorizeExchange((auth) -> auth + .pathMatchers("/mcp").authenticated() + .anyExchange().authenticated()) + .httpBasic(Customizer.withDefaults()) + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .build(); +} +``` + +Then pass credentials in the MCP client configuration: + +```json title="opencode.json" +{ + "mcp": { + "spring-boot-admin": { + "type": "remote", + "url": "http://localhost:8080/mcp", + "headers": { + "Authorization": "Basic {env:SBA_BASIC_AUTH}" + } + } + } +} +``` + +## Configuration Reference + +| Property | Default | Description | +|-----------------------------------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------| +| `spring.boot.admin.mcp.enabled` | `false` | Enable the MCP server integration | +| `spring.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 | +| `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` | `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. | + +:::note The `spring.boot.admin.mcp.tools.*` flags toggle tool availability **on the Spring Boot Admin server** +a disabled category is never advertised to MCP clients. They are independent of the monitored applications' +`management.endpoint.*.enabled` settings, which are enforced at runtime by each target application (a call to a disabled +endpoint simply returns an error). For example, to run a read-only monitoring deployment, set +`spring.boot.admin.mcp.tools.operations=false`. Changes take effect on server restart. +::: + +## Sample Application + +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 +``` + +The Web UI is available at `http://localhost:8080` and the MCP endpoint at `http://localhost:8080/mcp`. 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 diff --git a/spring-boot-admin-samples/pom.xml b/spring-boot-admin-samples/pom.xml index 1860153702f..4f2b47fdd07 100644 --- a/spring-boot-admin-samples/pom.xml +++ b/spring-boot-admin-samples/pom.xml @@ -39,6 +39,7 @@ spring-boot-admin-sample-reactive spring-boot-admin-sample-war spring-boot-admin-sample-hazelcast + spring-boot-admin-sample-mcp diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml new file mode 100644 index 00000000000..73712045d93 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/pom.xml @@ -0,0 +1,90 @@ + + + + + 4.0.0 + + spring-boot-admin-sample-mcp + + Spring Boot Admin Sample MCP + Spring Boot Admin sample demonstrating the MCP server alongside the Web UI + + + de.codecentric + spring-boot-admin-samples + ${revision} + ../pom.xml + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + de.codecentric + spring-boot-admin-starter-server-mcp + + + + de.codecentric + spring-boot-admin-starter-server + + + + de.codecentric + spring-boot-admin-starter-client + + + + org.springframework.cloud + spring-cloud-starter + + + + + ${project.artifactId} + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + build-info + + + + + de.codecentric.boot.admin.sample.mcp.SpringBootAdminMcpApplication + false + + + + + diff --git a/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java new file mode 100644 index 00000000000..76bf1735251 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/java/de/codecentric/boot/admin/sample/mcp/SpringBootAdminMcpApplication.java @@ -0,0 +1,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.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import de.codecentric.boot.admin.server.config.EnableAdminServer; + +@SpringBootApplication +@EnableAdminServer +public class SpringBootAdminMcpApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringBootAdminMcpApplication.class, args); + } + +} 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..230a4e40ba5 --- /dev/null +++ b/spring-boot-admin-samples/spring-boot-admin-sample-mcp/src/main/resources/application.yml @@ -0,0 +1,29 @@ +spring: + application: + name: spring-boot-admin-sample-mcp + boot: + admin: + client: + url: http://localhost:8080 + mcp: + enabled: true + profiles: + active: + - insecure + +logging: + file: + name: "target/boot-admin-sample-mcp.log" + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: ALWAYS + restart: + enabled: true + refresh: + enabled: true diff --git a/spring-boot-admin-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..66cfa20a7d4 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfiguration.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.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.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; +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; + +/** + * 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 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 + * @return the configured {@link ApplicationTools} + */ + @Bean + @ConditionalOnProperty(prefix = "spring.boot.admin.mcp.tools", name = "applications", havingValue = "true", + matchIfMissing = true) + public ApplicationTools applicationTools(InstanceRepository instanceRepository) { + return new ApplicationTools(instanceRepository); + } + + /** + * Creates the {@link HealthTools} bean for querying application health and status. + * @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(ActuatorClient actuatorClient) { + return new HealthTools(actuatorClient); + } + + /** + * Creates the {@link MetricsTools} bean for querying application metrics. + * @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(ActuatorClient actuatorClient) { + return new MetricsTools(actuatorClient); + } + + /** + * Creates the {@link EnvTools} bean for resolving application environment properties. + * @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(ActuatorClient actuatorClient) { + return new EnvTools(actuatorClient); + } + + /** + * Creates the {@link LogsTools} bean for accessing application log output. + * @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(ActuatorClient actuatorClient) { + return new LogsTools(actuatorClient); + } + + /** + * Creates the {@link OperationsTools} bean for performing write operations on + * applications. + * @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(ActuatorClient actuatorClient) { + return new OperationsTools(actuatorClient); + } + + /** + * Creates the {@link LoggersTools} bean for querying and configuring log levels. + * @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(ActuatorClient actuatorClient) { + return new LoggersTools(actuatorClient); + } + + /** + * Creates the {@link ThreadDumpTools} bean for capturing thread dumps. + * @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(ActuatorClient actuatorClient, McpProperties mcpProperties) { + return new ThreadDumpTools(actuatorClient, mcpProperties.getThreadDumpTimeout()); + } + + /** + * Creates the {@link HttpExchangesTools} bean for inspecting recent HTTP exchanges. + * @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(ActuatorClient actuatorClient) { + return new HttpExchangesTools(actuatorClient); + } + + /** + * Creates the {@link ScheduledTasksTools} bean for inspecting scheduled tasks. + * @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(ActuatorClient actuatorClient) { + return new ScheduledTasksTools(actuatorClient); + } + + /** + * Creates the {@link CachesTools} bean for inspecting application caches. + * @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(ActuatorClient actuatorClient) { + return new CachesTools(actuatorClient); + } + + /** + * Creates the {@link BeansTools} bean for inspecting Spring application context + * beans. + * @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(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 new file mode 100644 index 00000000000..2c733ef0117 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpProperties.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.config; + +import java.time.Duration; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for the Spring Boot Admin MCP server integration. + * + *

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

+ * + *

+ * Example configuration: + *

+ * + *
+ * spring:
+ *   boot:
+ *     admin:
+ *       mcp:
+ *         enabled: true
+ *   ai:
+ *     mcp:
+ *       server:
+ *         type: ASYNC
+ *         protocol: STREAMABLE
+ * 
+ */ +@lombok.Data +@ConfigurationProperties(prefix = "spring.boot.admin.mcp") +public class McpProperties { + + /** + * Creates a new {@code McpProperties} instance with default values. + */ + public McpProperties() { + // NOOP + } + + /** + * Whether the MCP server integration is enabled. Defaults to {@code false} to ensure + * zero impact on existing Spring Boot Admin deployments. + */ + private boolean enabled = false; + + /** + * 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 + * independent of the monitored applications' {@code management.endpoint.*} settings + * (which are enforced at runtime by the target application). All categories default + * to {@code true}. + */ + private Tools tools = new Tools(); + + /** + * Category-level enablement flags for the exposed MCP tools. Disabling a category + * prevents the corresponding tool bean from being created, so its tools are never + * advertised. Changes take effect on server restart. + */ + @lombok.Data + public static class Tools { + + /** + * Whether the {@code list-applications} tool is available. + */ + private boolean applications = true; + + /** + * Whether the {@code get-health} and {@code get-status} tools are available. + */ + private boolean health = true; + + /** + * Whether the {@code list-metrics} and {@code get-metrics} tools are available. + */ + private boolean metrics = true; + + /** + * Whether the {@code get-env} and {@code list-env} tools are available. + */ + private boolean env = true; + + /** + * Whether the {@code get-logs} tool is available. + */ + private boolean logs = true; + + /** + * Whether the write operation tools ({@code restart-application} and + * {@code refresh-configuration}) are available. + */ + private boolean operations = true; + + /** + * 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/config/McpServerDefaultsEnvironmentPostProcessor.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java new file mode 100644 index 00000000000..2fb9e9c8c32 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessor.java @@ -0,0 +1,156 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.config; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.EnvironmentPostProcessor; +import org.springframework.boot.SpringApplication; +import org.springframework.core.Ordered; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.util.StringUtils; + +/** + * Contributes sensible defaults for the Spring AI MCP server metadata reported to + * clients, namely {@code spring.ai.mcp.server.name} and + * {@code spring.ai.mcp.server.version}. + * + *

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

+ * + *

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

+ * + *

+ * 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 { + + /** + * The property that reports the MCP server name to clients. + */ + static final String NAME_PROPERTY = "spring.ai.mcp.server.name"; + + /** + * The property that reports the MCP server version to clients. + */ + static final String VERSION_PROPERTY = "spring.ai.mcp.server.version"; + + /** + * 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. + */ + static final String PROPERTY_SOURCE_NAME = "springBootAdminMcpServerDefaults"; + + /** + * Creates a new {@code McpServerDefaultsEnvironmentPostProcessor}. + */ + public McpServerDefaultsEnvironmentPostProcessor() { + // NOOP + } + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + Map defaults = new HashMap<>(); + defaults.put(NAME_PROPERTY, DEFAULT_SERVER_NAME); + 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); + } + // addLast -> lowest precedence, so user configuration always wins. + environment.getPropertySources().addLast(new MapPropertySource(PROPERTY_SOURCE_NAME, defaults)); + } + + /** + * Resolves the Spring Boot Admin implementation version from the JAR manifest. + * @return the implementation version, or {@code null} when unavailable (e.g. not + * running from a packaged JAR) + */ + private String resolveVersion() { + Package pkg = McpServerDefaultsEnvironmentPostProcessor.class.getPackage(); + return (pkg != null) ? pkg.getImplementationVersion() : null; + } + + @Override + public int getOrder() { + // Run late so the defaults are only ever contributed as a fallback. + return Ordered.LOWEST_PRECEDENCE; + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/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..b72e495b1f3 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ActuatorClient.java @@ -0,0 +1,223 @@ +/* + * 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.domain.values.InstanceId; +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 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 + * 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.")); + } + + /** + * 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/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..8ec2c8432d1 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationTools.java @@ -0,0 +1,86 @@ +/* + * 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, 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), instance ID 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 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: ") + .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/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..575524fa735 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/BeansTools.java @@ -0,0 +1,147 @@ +/* + * 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.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +/** + * 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 final ActuatorClient actuatorClient; + + /** + * Creates a new {@code BeansTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public BeansTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * 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.actuatorClient.query(applicationName, "/beans", (app, body) -> formatBeans(app, filter, body)); + } + + @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..52b53bfb541 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/CachesTools.java @@ -0,0 +1,101 @@ +/* + * 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.util.Map; + +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +/** + * 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 final ActuatorClient actuatorClient; + + /** + * Creates a new {@code CachesTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public CachesTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * 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.actuatorClient.query(applicationName, "/caches", this::formatCaches); + } + + @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/EnvTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java new file mode 100644 index 00000000000..0c641c2c19e --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/EnvTools.java @@ -0,0 +1,196 @@ +/* + * 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.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +/** + * MCP tools for querying the environment of registered Spring Boot applications. + * + *
    + *
  • {@code get-env} — resolves a single configuration property (including environment + * variables) across all property sources
  • + *
  • {@code list-env} — retrieves all environment properties grouped by property + * source
  • + *
+ */ +public class EnvTools { + + private final ActuatorClient actuatorClient; + + /** + * Creates a new {@code EnvTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public EnvTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * Resolves a single configuration property for the named application by calling its + * {@code /actuator/env/{propertyName}} endpoint. This covers environment variables + * (e.g. {@code HELLO}), system properties and any other property source. + * @param applicationName the registered application name (case-insensitive) + * @param propertyName the property or environment variable name (e.g. {@code HELLO}) + * @return plain-text resolved value with its originating property sources, or an + * error message + */ + @McpTool(name = "get-env", + description = "Resolve a single configuration property or environment variable for a registered " + + "Spring Boot application by calling its /actuator/env/{name} endpoint. Works for " + + "environment variables (e.g. HELLO), system properties and application properties. " + + "Requires the env actuator endpoint to be exposed on the monitored application.") + public Mono getEnv( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "The property or environment variable name (e.g. HELLO)", + required = true) String propertyName) { + return this.actuatorClient.query(applicationName, "/env/" + propertyName, + (app, body) -> formatProperty(app, propertyName, body)); + } + + /** + * Retrieves all environment properties for the named application by calling its + * {@code /actuator/env} endpoint. Properties are grouped by their originating + * property source (e.g. {@code systemEnvironment}, {@code systemProperties}, + * application config). An optional case-insensitive filter restricts the result to + * property names containing the given text. + * @param applicationName the registered application name (case-insensitive) + * @param filter optional case-insensitive substring; only property names containing + * it are returned. When {@code null} or blank, all properties are returned. + * @return plain-text listing of every (matching) property grouped by property source, + * or an error message + */ + @McpTool(name = "list-env", + description = "Retrieve environment properties for a registered Spring Boot application by calling " + + "its /actuator/env endpoint. Returns properties grouped by their property source " + + "(e.g. systemEnvironment, systemProperties, application config). Provide an optional " + + "'filter' to return only property names containing that text (case-insensitive); omit " + + "it to return ALL properties. Values may be masked (******) if the monitored application " + + "does not expose them. Requires the env actuator endpoint to be exposed on the monitored " + + "application.") + public Mono listEnv( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "Optional case-insensitive substring; only property names containing it are " + + "returned. Omit to return all properties.", required = false) String filter) { + return this.actuatorClient.query(applicationName, "/env", (app, body) -> formatEnv(app, filter, body)); + } + + @SuppressWarnings("unchecked") + private String formatEnv(String applicationName, String filter, Map body) { + Object propertySources = body.get("propertySources"); + if (!(propertySources instanceof List sources) || sources.isEmpty()) { + return "No environment properties available for " + applicationName + "."; + } + + boolean filtered = filter != null && !filter.isBlank(); + String needle = filtered ? filter.toLowerCase(Locale.ROOT) : null; + + StringBuilder body_sb = new StringBuilder(); + int matches = 0; + + for (Object source : sources) { + if (!(source instanceof Map sourceEntry)) { + continue; + } + Object name = sourceEntry.get("name"); + Object properties = sourceEntry.get("properties"); + if (!(properties instanceof Map props)) { + continue; + } + StringBuilder sourceSb = new StringBuilder(); + int sourceMatches = 0; + for (Map.Entry entry : props.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (filtered && !key.toLowerCase(Locale.ROOT).contains(needle)) { + continue; + } + sourceMatches++; + sourceSb.append(" ").append(key); + if (entry.getValue() instanceof Map propValue) { + sourceSb.append(" = ").append(propValue.get("value")); + Object origin = propValue.get("origin"); + if (origin != null) { + sourceSb.append(" (").append(origin).append(")"); + } + } + sourceSb.append("\n"); + } + if (sourceMatches > 0) { + body_sb.append("\n[").append(name).append("] (").append(sourceMatches).append("):\n").append(sourceSb); + matches += sourceMatches; + } + } + + if (filtered && matches == 0) { + return "No environment properties matching '" + filter + "' for " + applicationName + "."; + } + + StringBuilder sb = new StringBuilder("Environment for ").append(applicationName); + if (filtered) { + sb.append(" (filtered by \"").append(filter).append("\")"); + } + sb.append(":\n"); + + Object activeProfiles = body.get("activeProfiles"); + if (activeProfiles instanceof List profiles && !profiles.isEmpty()) { + sb.append(" active profiles: ").append(profiles).append("\n"); + } + + sb.append(body_sb); + return sb.toString().trim(); + } + + @SuppressWarnings("unchecked") + private String formatProperty(String applicationName, String propertyName, Map body) { + Object property = body.get("property"); + if (!(property instanceof Map resolved) || resolved.get("value") == null) { + return "Property '" + propertyName + "' is not set for " + applicationName + "."; + } + + StringBuilder sb = new StringBuilder(applicationName).append(" — ").append(propertyName).append(":\n"); + sb.append(" value: ").append(resolved.get("value")).append("\n"); + Object source = resolved.get("source"); + if (source != null) { + sb.append(" source: ").append(source).append("\n"); + } + + Object propertySources = body.get("propertySources"); + if (propertySources instanceof List sources && !sources.isEmpty()) { + sb.append(" property sources:\n"); + for (Object item : sources) { + if (item instanceof Map sourceEntry && sourceEntry.get("property") instanceof Map prop) { + sb.append(" - ").append(sourceEntry.get("name")).append(": ").append(prop.get("value")); + Object origin = prop.get("origin"); + if (origin != null) { + sb.append(" (").append(origin).append(")"); + } + sb.append("\n"); + } + } + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/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..a7455cc4ff7 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HealthTools.java @@ -0,0 +1,116 @@ +/* + * 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.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 reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.values.Endpoint; + +/** + * 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 final ActuatorClient actuatorClient; + + /** + * Creates a new {@code HealthTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public HealthTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * Fetches the full health details for the named application by calling its + * {@code /actuator/health} endpoint. Returns a plain-text summary including the + * overall status and individual health component statuses. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text health summary, or an error message if the app is not found or + * the actuator call fails + */ + @McpTool(name = "get-health", + description = "Fetch health details for a registered Spring Boot application by calling its " + + "/actuator/health endpoint. Returns overall status and per-component breakdown.") + public Mono getHealth(@McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.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()))); + } + + /** + * Returns the cached status for the named application from the registry without + * making an actuator call. This is a fast, lightweight alternative to + * {@code get-health}. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text status line, or an error message if the app is not found + */ + @McpTool(name = "get-status", + description = "Return the cached health status (UP/DOWN/UNKNOWN/etc.) for a registered " + + "Spring Boot application from the registry — no actuator call is made. " + + "Use get-health for full component details.") + public Mono getStatus(@McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.actuatorClient.withInstance(applicationName, + (instance) -> Mono.just(applicationName + " status: " + instance.getStatusInfo().getStatus())); + } + + @SuppressWarnings("unchecked") + private String formatHealthResponse(String applicationName, Map body) { + StringBuilder sb = new StringBuilder(); + Object status = body.get("status"); + sb.append(applicationName).append(" health: ").append((status != null) ? status : "UNKNOWN").append("\n"); + + Object components = body.get("components"); + if (components instanceof Map componentMap) { + for (Map.Entry entry : componentMap.entrySet()) { + String componentName = String.valueOf(entry.getKey()); + Object componentValue = entry.getValue(); + if (componentValue instanceof Map componentDetails) { + Object componentStatus = componentDetails.get("status"); + sb.append(" ") + .append(componentName) + .append(": ") + .append((componentStatus != null) ? componentStatus : "UNKNOWN") + .append("\n"); + } + } + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/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..3c428c0ef6b --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesTools.java @@ -0,0 +1,129 @@ +/* + * 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.util.List; +import java.util.Map; + +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +/** + * 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 int DEFAULT_LIMIT = 20; + + private static final int MAX_LIMIT = 100; + + private final ActuatorClient actuatorClient; + + /** + * Creates a new {@code HttpExchangesTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public HttpExchangesTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * 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.actuatorClient.query(applicationName, "/httpexchanges", + (app, body) -> formatExchanges(app, body, effectiveLimit)); + } + + @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..d3556435123 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LoggersTools.java @@ -0,0 +1,202 @@ +/* + * 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.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 reactor.core.publisher.Mono; + +/** + * 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 final ActuatorClient actuatorClient; + + /** + * Creates a new {@code LoggersTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public LoggersTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * 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.actuatorClient.query(applicationName, "/loggers", (app, body) -> formatLoggers(app, filter, body)); + } + + /** + * 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.actuatorClient.query(applicationName, "/loggers/" + loggerName, + (app, body) -> formatLogger(app, loggerName, body)); + } + + /** + * 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.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.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); + return "Log level for '" + loggerName + "' in " + applicationName + " set to " + effectiveLevel + + "."; + } + return "Setting log level returned unexpected status " + status + " for " + applicationName + "."; + }) + .onErrorResume((ex) -> Mono.just("Error setting log level for '" + loggerName + "' in " + + applicationName + ": " + ex.getMessage())); + }); + } + + @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/LogsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java new file mode 100644 index 00000000000..271fe6375c9 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/LogsTools.java @@ -0,0 +1,91 @@ +/* + * 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.util.Arrays; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +/** + * MCP tools for accessing logs of registered Spring Boot applications. + * + *
    + *
  • {@code get-logs} — fetches the last N lines from the application's logfile
  • + *
+ */ +public class LogsTools { + + private static final Logger log = LoggerFactory.getLogger(LogsTools.class); + + private static final int DEFAULT_LINES = 50; + + private static final int MAX_LINES = 500; + + private final ActuatorClient actuatorClient; + + /** + * Creates a new {@code LogsTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public LogsTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * Fetches the last N lines from the logfile of the named application by calling its + * {@code /actuator/logfile} endpoint. + * @param applicationName the registered application name (case-insensitive) + * @param lines number of lines to return from the end of the log (default 50, max + * 500) + * @return plain-text log tail, or an error message + */ + @McpTool(name = "get-logs", + description = "Fetch the last N lines from the logfile of a registered Spring Boot application. " + + "Requires logging.file.name or logging.file.path to be configured in the application. " + + "Default is 50 lines, maximum is 500.") + public Mono getLogs( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "Number of lines to return from the end of the log (default 50, max 500)", + required = false) Integer lines) { + int lineCount = (lines != null) ? Math.min(Math.max(lines, 1), MAX_LINES) : DEFAULT_LINES; + + return this.actuatorClient.withInstance(applicationName, (instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/logfile"; + return this.actuatorClient.fetchText(instance, url, log, "logs for " + applicationName) + .map((body) -> formatLogTail(applicationName, body, lineCount)) + .onErrorResume( + (ex) -> Mono.just("Error retrieving logs for " + applicationName + ": " + ex.getMessage())); + }); + } + + private String formatLogTail(String applicationName, String body, int lineCount) { + if (body == null || body.isBlank()) { + return "No log content available for " + applicationName + "."; + } + String[] allLines = body.split("\n"); + int from = Math.max(0, allLines.length - lineCount); + String tail = Arrays.stream(allLines, from, allLines.length).collect(Collectors.joining("\n")); + return "Last " + Math.min(lineCount, allLines.length) + " lines of " + applicationName + " log:\n" + tail; + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java new file mode 100644 index 00000000000..6951678965e --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/MetricsTools.java @@ -0,0 +1,114 @@ +/* + * 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.util.List; +import java.util.Map; + +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +/** + * MCP tools for querying metrics of registered Spring Boot applications. + * + *
    + *
  • {@code list-metrics} — lists all available metric names for an application
  • + *
  • {@code get-metrics} — fetches the value of a specific metric
  • + *
+ */ +public class MetricsTools { + + private final ActuatorClient actuatorClient; + + /** + * Creates a new {@code MetricsTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public MetricsTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * Lists all available metric names for the named application by calling its + * {@code /actuator/metrics} endpoint. + * @param applicationName the registered application name (case-insensitive) + * @return plain-text list of metric names, or an error message + */ + @McpTool(name = "list-metrics", + description = "List all available metric names for a registered Spring Boot application. " + + "Use the returned names with get-metrics to fetch specific values.") + public Mono listMetrics(@McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.actuatorClient.query(applicationName, "/metrics", this::formatMetricNames); + } + + /** + * Fetches the value of a specific metric for the named application by calling its + * {@code /actuator/metrics/{metricName}} endpoint. + * @param applicationName the registered application name (case-insensitive) + * @param metricName the metric name (e.g. {@code jvm.memory.used}) + * @return plain-text metric value with unit, or an error message + */ + @McpTool(name = "get-metrics", + description = "Fetch the current value of a specific metric for a registered Spring Boot application. " + + "Common metrics: jvm.memory.used, jvm.memory.max, system.cpu.usage, " + + "http.server.requests, jvm.threads.live. Use list-metrics to discover available names.") + public Mono getMetrics( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName, + @McpToolParam(description = "The metric name (e.g. jvm.memory.used)", required = true) String metricName) { + return this.actuatorClient.query(applicationName, "/metrics/" + metricName, + (app, body) -> formatMetricValue(app, metricName, body)); + } + + @SuppressWarnings("unchecked") + private String formatMetricNames(String applicationName, Map body) { + Object names = body.get("names"); + if (!(names instanceof List nameList) || nameList.isEmpty()) { + return "No metrics available for " + applicationName + "."; + } + StringBuilder sb = new StringBuilder("Available metrics for ").append(applicationName) + .append(" (") + .append(nameList.size()) + .append("):\n"); + nameList.forEach((name) -> sb.append("- ").append(name).append("\n")); + return sb.toString().trim(); + } + + @SuppressWarnings("unchecked") + private String formatMetricValue(String applicationName, String metricName, Map body) { + StringBuilder sb = new StringBuilder(applicationName).append(" — ").append(metricName).append(":\n"); + Object measurements = body.get("measurements"); + if (measurements instanceof List list) { + for (Object item : list) { + if (item instanceof Map m) { + Object statistic = m.get("statistic"); + Object value = m.get("value"); + sb.append(" ").append(statistic).append(": ").append(value); + Object baseUnit = body.get("baseUnit"); + if (baseUnit != null) { + sb.append(" ").append(baseUnit); + } + sb.append("\n"); + } + } + } + return sb.toString().trim(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java new file mode 100644 index 00000000000..f411b334aa9 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/OperationsTools.java @@ -0,0 +1,105 @@ +/* + * 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 org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +/** + * MCP tools for performing write operations on registered Spring Boot applications. + * + *
    + *
  • {@code restart-application} — restarts the application via + * {@code /actuator/restart}
  • + *
  • {@code refresh-configuration} — refreshes the application configuration via + * {@code /actuator/refresh}
  • + *
+ */ +public class OperationsTools { + + private static final Logger log = LoggerFactory.getLogger(OperationsTools.class); + + private final ActuatorClient actuatorClient; + + /** + * Creates a new {@code OperationsTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public OperationsTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * Restarts the named application by calling its {@code /actuator/restart} endpoint. + * 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 + */ + @McpTool(name = "restart-application", + description = "Restart a registered Spring Boot application via its /actuator/restart endpoint. " + + "Requires management.endpoint.restart.enabled=true in the monitored application.") + public Mono restartApplication( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.actuatorClient.withInstance(applicationName, (instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/restart"; + 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 + "."; + }) + .onErrorResume((ex) -> Mono.just("Error restarting " + applicationName + ": " + ex.getMessage())); + }); + } + + /** + * Refreshes the configuration of the named application by calling its + * {@code /actuator/refresh} endpoint. Requires Spring Cloud Context on the monitored + * application's classpath. + * @param applicationName the registered application name (case-insensitive) + * @return confirmation message listing refreshed keys, or an error message + */ + @McpTool(name = "refresh-configuration", + description = "Refresh the configuration of a registered Spring Boot application via its " + + "/actuator/refresh endpoint. Requires Spring Cloud Context (spring-cloud-starter) " + + "on the monitored application's classpath.") + public Mono refreshConfiguration( + @McpToolParam(description = "The registered application name (case-insensitive)", + required = true) String applicationName) { + return this.actuatorClient.withInstance(applicationName, (instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/refresh"; + return this.actuatorClient.post(instance, url, "{}", log, "refresh of " + applicationName) + .map((body) -> formatRefreshResponse(applicationName, body)) + .onErrorResume((ex) -> Mono + .just("Error refreshing configuration for " + applicationName + ": " + ex.getMessage())); + }); + } + + private String formatRefreshResponse(String applicationName, String body) { + if ((body == null) || body.isBlank() || "[]".equals(body.trim())) { + return "Configuration refreshed for " + applicationName + ". No properties changed."; + } + return "Configuration refreshed for " + applicationName + ". Changed keys: " + body; + } + +} diff --git a/spring-boot-admin-server-mcp/src/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..ffb61dbd008 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksTools.java @@ -0,0 +1,139 @@ +/* + * 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.util.List; +import java.util.Map; + +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; + +/** + * 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 final ActuatorClient actuatorClient; + + /** + * Creates a new {@code ScheduledTasksTools} instance. + * @param actuatorClient the shared actuator call helper + */ + public ScheduledTasksTools(ActuatorClient actuatorClient) { + this.actuatorClient = actuatorClient; + } + + /** + * 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.actuatorClient.query(applicationName, "/scheduledtasks", this::formatScheduledTasks); + } + + @SuppressWarnings("unchecked") + private String formatScheduledTasks(String applicationName, Map body) { + StringBuilder sb = new StringBuilder("Scheduled tasks for ").append(applicationName).append(":\n"); + int total = 0; + + 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"); + } + } + } + + 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); + } + } + + 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..44f97a58bd2 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpTools.java @@ -0,0 +1,147 @@ +/* + * 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 reactor.core.publisher.Mono; + +/** + * 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 final ActuatorClient actuatorClient; + + private final Duration timeout; + + /** + * Creates a new {@code ThreadDumpTools} instance. + * @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(ActuatorClient actuatorClient, Duration timeout) { + this.actuatorClient = actuatorClient; + this.timeout = timeout; + } + + /** + * 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.actuatorClient.withInstance(applicationName, (instance) -> { + String url = instance.getRegistration().getManagementUrl() + "/threaddump"; + return this.actuatorClient.fetch(instance, url, this.timeout, log, "thread dump for " + applicationName) + .map((body) -> formatThreadDump(applicationName, body)) + .onErrorResume((ex) -> Mono + .just("Error retrieving thread dump for " + applicationName + ": " + ex.getMessage())); + }); + } + + @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 + "."; + } + + 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/main/resources/META-INF/spring.factories b/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..c1e9ad395cb --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.EnvironmentPostProcessor=\ +de.codecentric.boot.admin.server.mcp.config.McpServerDefaultsEnvironmentPostProcessor diff --git a/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..f95cdc240f0 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +de.codecentric.boot.admin.server.mcp.config.McpAutoConfiguration diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java new file mode 100644 index 00000000000..42bb596e5a7 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpAutoConfigurationTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.mcp.tools.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; +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; +import static org.mockito.Mockito.mock; + +class McpAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(McpAutoConfiguration.class)) + .withUserConfiguration(RequiredBeansConfiguration.class); + + @Test + void mcpDisabled_noToolBeansCreated() { + this.contextRunner.run((context) -> { + assertThat(context).doesNotHaveBean(ActuatorClient.class); + assertThat(context).doesNotHaveBean(ApplicationTools.class); + assertThat(context).doesNotHaveBean(HealthTools.class); + assertThat(context).doesNotHaveBean(MetricsTools.class); + assertThat(context).doesNotHaveBean(EnvTools.class); + assertThat(context).doesNotHaveBean(LogsTools.class); + assertThat(context).doesNotHaveBean(OperationsTools.class); + assertThat(context).doesNotHaveBean(LoggersTools.class); + assertThat(context).doesNotHaveBean(ThreadDumpTools.class); + assertThat(context).doesNotHaveBean(HttpExchangesTools.class); + assertThat(context).doesNotHaveBean(ScheduledTasksTools.class); + assertThat(context).doesNotHaveBean(CachesTools.class); + assertThat(context).doesNotHaveBean(BeansTools.class); + }); + } + + @Test + void 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); + 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); + }); + } + + @Test + void operationsDisabled_operationsToolBeanAbsentOthersPresent() { + this.contextRunner + .withPropertyValues("spring.boot.admin.mcp.enabled=true", "spring.boot.admin.mcp.tools.operations=false") + .run((context) -> { + assertThat(context).doesNotHaveBean(OperationsTools.class); + assertThat(context).hasSingleBean(ApplicationTools.class); + assertThat(context).hasSingleBean(EnvTools.class); + }); + } + + @Test + void envDisabled_envToolBeanAbsentOthersPresent() { + this.contextRunner + .withPropertyValues("spring.boot.admin.mcp.enabled=true", "spring.boot.admin.mcp.tools.env=false") + .run((context) -> { + assertThat(context).doesNotHaveBean(EnvTools.class); + assertThat(context).hasSingleBean(OperationsTools.class); + assertThat(context).hasSingleBean(HealthTools.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class RequiredBeansConfiguration { + + @Bean + InstanceRepository instanceRepository() { + return mock(InstanceRepository.class); + } + + @Bean + InstanceWebClient.Builder instanceWebClientBuilder() { + return InstanceWebClient.builder(); + } + + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.java new file mode 100644 index 00000000000..c1c844b5e07 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/config/McpServerDefaultsEnvironmentPostProcessorTest.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.config; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.springframework.core.env.MapPropertySource; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpServerDefaultsEnvironmentPostProcessorTest { + + private final McpServerDefaultsEnvironmentPostProcessor postProcessor = new McpServerDefaultsEnvironmentPostProcessor(); + + @Test + void shouldContributeDefaultServerName() { + MockEnvironment environment = new MockEnvironment(); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.NAME_PROPERTY)) + .isEqualTo("Spring Boot Admin MCP Server"); + } + + @Test + void shouldNotOverrideUserProvidedName() { + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources() + .addFirst(new MapPropertySource("userConfig", Collections + .singletonMap(McpServerDefaultsEnvironmentPostProcessor.NAME_PROPERTY, "My Custom Server"))); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.NAME_PROPERTY)) + .isEqualTo("My Custom Server"); + } + + @Test + void shouldNotOverrideUserProvidedVersion() { + MockEnvironment environment = new MockEnvironment(); + environment.getPropertySources() + .addFirst(new MapPropertySource("userConfig", + Collections.singletonMap(McpServerDefaultsEnvironmentPostProcessor.VERSION_PROPERTY, "9.9.9"))); + + this.postProcessor.postProcessEnvironment(environment, null); + + assertThat(environment.getProperty(McpServerDefaultsEnvironmentPostProcessor.VERSION_PROPERTY)) + .isEqualTo("9.9.9"); + } + + @Test + void 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(); + environment.getPropertySources().addLast(new MapPropertySource("dummy", Collections.emptyMap())); + + this.postProcessor.postProcessEnvironment(environment, null); + + // The contributed source must always be added last (fallback). + assertThat(environment.getPropertySources().stream().reduce((first, second) -> second).orElseThrow().getName()) + .isEqualTo(McpServerDefaultsEnvironmentPostProcessor.PROPERTY_SOURCE_NAME); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java new file mode 100644 index 00000000000..fa7b819525f --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ApplicationToolsTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ApplicationToolsTest { + + private InstanceRepository instanceRepository; + + private ApplicationTools applicationTools; + + @BeforeEach + void setUp() { + this.instanceRepository = mock(InstanceRepository.class); + this.applicationTools = new ApplicationTools(this.instanceRepository); + } + + @Test + void listApplications_withRegisteredApps_returnsFormattedList() { + Instance instance1 = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", "http://payment/actuator/health") + .managementUrl("http://payment/actuator") + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + Instance instance2 = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("order-service", "http://order/actuator/health") + .managementUrl("http://order/actuator") + .build()) + .withStatusInfo(StatusInfo.ofDown()); + + when(this.instanceRepository.findAll()).thenReturn(Flux.just(instance1, instance2)); + + StepVerifier.create(this.applicationTools.listApplications()).assertNext((result) -> { + assertThat(result).contains("Registered applications (2)"); + assertThat(result).contains("payment-service"); + assertThat(result).contains("id: id-1"); + assertThat(result).contains("UP"); + assertThat(result).contains("order-service"); + assertThat(result).contains("id: id-2"); + assertThat(result).contains("DOWN"); + }).verifyComplete(); + } + + @Test + void listApplications_withNoApps_returnsNoAppsMessage() { + when(this.instanceRepository.findAll()).thenReturn(Flux.empty()); + + StepVerifier.create(this.applicationTools.listApplications()) + .assertNext((result) -> assertThat(result).isEqualTo("No applications are currently registered.")) + .verifyComplete(); + } + + @Test + void listApplications_onRepositoryError_returnsErrorMessage() { + when(this.instanceRepository.findAll()).thenReturn(Flux.error(new RuntimeException("connection failed"))); + + StepVerifier.create(this.applicationTools.listApplications()) + .assertNext((result) -> assertThat(result).isEqualTo("Error retrieving registered applications.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/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..619af098787 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/BeansToolsTest.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 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.core.publisher.Mono; +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.ArgumentMatchers.any; +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); + 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))); + } + + @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..449c9ba0492 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/CachesToolsTest.java @@ -0,0 +1,127 @@ +/* + * 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.core.publisher.Mono; +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.ArgumentMatchers.any; +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); + 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))); + } + + @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/EnvToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java new file mode 100644 index 00000000000..8c7680e3d79 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/EnvToolsTest.java @@ -0,0 +1,232 @@ +/* + * 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.core.publisher.Mono; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class EnvToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private EnvTools envTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + 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))); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + @Test + void getEnv_propertySet_returnsValueAndSources() { + Instance instance = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env/HELLO")) + .willReturn(okJson("{\"property\":{\"source\":\"systemEnvironment\",\"value\":\"world\"}," + + "\"propertySources\":[{\"name\":\"systemEnvironment\"," + + "\"property\":{\"value\":\"world\",\"origin\":\"System Environment Property \\\"HELLO\\\"\"}}]}"))); + + StepVerifier.create(this.envTools.getEnv("payment-service", "HELLO")).assertNext((result) -> { + assertThat(result).contains("payment-service"); + assertThat(result).contains("HELLO"); + assertThat(result).contains("value: world"); + assertThat(result).contains("systemEnvironment"); + }).verifyComplete(); + } + + @Test + void getEnv_propertyNotSet_returnsNotSetMessage() { + Instance instance = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("order-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env/MISSING")) + .willReturn(okJson("{\"property\":null,\"propertySources\":[{\"name\":\"systemEnvironment\"}]}"))); + + StepVerifier.create(this.envTools.getEnv("order-service", "MISSING")) + .assertNext((result) -> assertThat(result).isEqualTo("Property 'MISSING' is not set for order-service.")) + .verifyComplete(); + } + + @Test + void getEnv_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.envTools.getEnv("ghost", "HELLO")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + + @Test + void listEnv_returnsAllPropertiesGroupedBySource() { + Instance instance = Instance.create(InstanceId.of("id-3")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env")) + .willReturn(okJson("{\"activeProfiles\":[\"prod\"],\"propertySources\":[" + + "{\"name\":\"systemEnvironment\",\"properties\":{" + + "\"HELLO\":{\"value\":\"world\",\"origin\":\"System Environment Property \\\"HELLO\\\"\"}}}," + + "{\"name\":\"application.yml\",\"properties\":{" + + "\"spring.application.name\":{\"value\":\"payment-service\"}}}]}"))); + + StepVerifier.create(this.envTools.listEnv("payment-service", null)).assertNext((result) -> { + assertThat(result).contains("Environment for payment-service"); + assertThat(result).contains("active profiles: [prod]"); + assertThat(result).contains("[systemEnvironment]"); + assertThat(result).contains("HELLO = world"); + assertThat(result).contains("[application.yml]"); + assertThat(result).contains("spring.application.name = payment-service"); + }).verifyComplete(); + } + + @Test + void listEnv_withFilter_returnsOnlyMatchingProperties() { + Instance instance = Instance.create(InstanceId.of("id-5")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env")) + .willReturn(okJson("{\"activeProfiles\":[\"prod\"],\"propertySources\":[" + + "{\"name\":\"systemEnvironment\",\"properties\":{" + + "\"HELLO\":{\"value\":\"world\"},\"DB_URL\":{\"value\":\"jdbc:h2:mem\"}}}," + + "{\"name\":\"application.yml\",\"properties\":{" + + "\"spring.datasource.url\":{\"value\":\"jdbc:h2\"}," + + "\"spring.application.name\":{\"value\":\"payment-service\"}}}]}"))); + + StepVerifier.create(this.envTools.listEnv("payment-service", "url")).assertNext((result) -> { + assertThat(result).contains("filtered by \"url\""); + assertThat(result).contains("DB_URL = jdbc:h2:mem"); + assertThat(result).contains("spring.datasource.url = jdbc:h2"); + assertThat(result).doesNotContain("HELLO"); + assertThat(result).doesNotContain("spring.application.name"); + }).verifyComplete(); + } + + @Test + void listEnv_withFilter_noMatches_returnsMessage() { + Instance instance = Instance.create(InstanceId.of("id-6")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env")) + .willReturn(okJson("{\"propertySources\":[{\"name\":\"systemEnvironment\",\"properties\":{" + + "\"HELLO\":{\"value\":\"world\"}}}]}"))); + + StepVerifier.create(this.envTools.listEnv("payment-service", "nomatch")) + .assertNext((result) -> assertThat(result) + .isEqualTo("No environment properties matching 'nomatch' for payment-service.")) + .verifyComplete(); + } + + @Test + void listEnv_noPropertySources_returnsNoPropertiesMessage() { + Instance instance = Instance.create(InstanceId.of("id-4")) + .register(Registration.create("order-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/env")).willReturn(okJson("{\"propertySources\":[]}"))); + + StepVerifier.create(this.envTools.listEnv("order-service", null)) + .assertNext( + (result) -> assertThat(result).isEqualTo("No environment properties available for order-service.")) + .verifyComplete(); + } + + @Test + void listEnv_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.envTools.listEnv("ghost", null)) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java new file mode 100644 index 00000000000..917b02177b2 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HealthToolsTest.java @@ -0,0 +1,179 @@ +/* + * 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.core.publisher.Mono; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.Endpoint; +import de.codecentric.boot.admin.server.domain.values.Endpoints; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class HealthToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private HealthTools healthTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + 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))); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + @Test + void getHealth_appFoundAndActuatorReachable_returnsHealthSummary() { + Instance instance = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")).build()) + .withEndpoints(Endpoints.single(Endpoint.HEALTH, this.wireMock.url("/actuator/health"))) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + String healthBody = "{\"status\":\"UP\",\"components\":{\"db\":{\"status\":\"UP\"},\"diskSpace\":{\"status\":\"UP\"}}}"; + this.wireMock.stubFor(get(urlEqualTo("/actuator/health")).willReturn(okJson(healthBody))); + + StepVerifier.create(this.healthTools.getHealth("payment-service")).assertNext((result) -> { + assertThat(result).contains("payment-service health: UP"); + assertThat(result).contains("db: UP"); + assertThat(result).contains("diskSpace: UP"); + }).verifyComplete(); + } + + @Test + void getHealth_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("unknown")).thenReturn(Flux.empty()); + + StepVerifier.create(this.healthTools.getHealth("unknown")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'unknown' not found in registry.")) + .verifyComplete(); + } + + @Test + void getHealth_actuatorTimeout_returnsErrorMessage() { + Instance instance = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("slow-service", this.wireMock.url("/actuator/health")).build()) + .withEndpoints(Endpoints.single(Endpoint.HEALTH, this.wireMock.url("/actuator/health"))) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("slow-service")).thenReturn(Flux.just(instance)); + + this.wireMock + .stubFor(get(urlEqualTo("/actuator/health")).willReturn(aResponse().withFixedDelay(600).withBody("{}"))); + + StepVerifier.create(this.healthTools.getHealth("slow-service")) + .assertNext((result) -> assertThat(result).contains("Error retrieving health for slow-service")) + .verifyComplete(); + } + + @Test + void getStatus_appFound_returnsStatusLine() { + Instance instance = Instance.create(InstanceId.of("id-3")) + .register(Registration.create("order-service", "http://localhost/health").build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + StepVerifier.create(this.healthTools.getStatus("order-service")) + .assertNext((result) -> assertThat(result).isEqualTo("order-service status: UP")) + .verifyComplete(); + } + + @Test + void getStatus_appDown_returnsDownStatus() { + Instance instance = Instance.create(InstanceId.of("id-4")) + .register(Registration.create("checkout-service", "http://localhost/health").build()) + .withStatusInfo(StatusInfo.ofDown()); + + when(this.instanceRepository.findByName("checkout-service")).thenReturn(Flux.just(instance)); + + StepVerifier.create(this.healthTools.getStatus("checkout-service")) + .assertNext((result) -> assertThat(result).isEqualTo("checkout-service status: DOWN")) + .verifyComplete(); + } + + @Test + void getStatus_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + 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 new file mode 100644 index 00000000000..cb3417c76a7 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/HttpExchangesToolsTest.java @@ -0,0 +1,146 @@ +/* + * 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.core.publisher.Mono; +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.ArgumentMatchers.any; +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); + 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))); + } + + @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..4a3ee5f4b1f --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LoggersToolsTest.java @@ -0,0 +1,207 @@ +/* + * 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.core.publisher.Mono; +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.ArgumentMatchers.any; +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); + 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))); + } + + @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/LogsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java new file mode 100644 index 00000000000..6414e2d7647 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/LogsToolsTest.java @@ -0,0 +1,156 @@ +/* + * 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.core.publisher.Mono; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LogsToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private LogsTools logsTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + 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))); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + @Test + void getLogs_appFound_returnsLastNLines() { + Instance instance = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + String logContent = "line1\nline2\nline3\nline4\nline5"; + this.wireMock.stubFor(get(urlEqualTo("/actuator/logfile")) + .willReturn(aResponse().withStatus(200).withBody(logContent).withHeader("Content-Type", "text/plain"))); + + StepVerifier.create(this.logsTools.getLogs("payment-service", 3)).assertNext((result) -> { + assertThat(result).contains("payment-service"); + assertThat(result).contains("line3"); + assertThat(result).contains("line4"); + assertThat(result).contains("line5"); + assertThat(result).doesNotContain("line1"); + assertThat(result).doesNotContain("line2"); + }).verifyComplete(); + } + + @Test + void getLogs_emptyLogfile_returnsNoContentMessage() { + Instance instance = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("order-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/logfile")) + .willReturn(aResponse().withStatus(200).withBody("").withHeader("Content-Type", "text/plain"))); + + StepVerifier.create(this.logsTools.getLogs("order-service", null)) + .assertNext((result) -> assertThat(result).contains("No log content available")) + .verifyComplete(); + } + + @Test + void getLogs_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.logsTools.getLogs("ghost", null)) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + + @Test + void getLogs_defaultLines_returns50Lines() { + Instance instance = Instance.create(InstanceId.of("id-3")) + .register(Registration.create("checkout-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("checkout-service")).thenReturn(Flux.just(instance)); + + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= 100; i++) { + sb.append("line").append(i).append("\n"); + } + this.wireMock.stubFor(get(urlEqualTo("/actuator/logfile")) + .willReturn(aResponse().withStatus(200).withBody(sb.toString()).withHeader("Content-Type", "text/plain"))); + + StepVerifier.create(this.logsTools.getLogs("checkout-service", null)).assertNext((result) -> { + assertThat(result).contains("Last 50 lines"); + assertThat(result).contains("line100"); + assertThat(result).doesNotContain("line50\n").contains("line51"); + }).verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java new file mode 100644 index 00000000000..6fee7f06f2b --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/MetricsToolsTest.java @@ -0,0 +1,141 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.mcp.tools; + +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.Options; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +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.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MetricsToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private MetricsTools metricsTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + 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))); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + @Test + void listMetrics_appFound_returnsMetricNames() { + Instance instance = Instance.create(InstanceId.of("id-1")) + .register(Registration.create("payment-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/metrics")) + .willReturn(okJson("{\"names\":[\"jvm.memory.used\",\"jvm.memory.max\",\"system.cpu.usage\"]}"))); + + StepVerifier.create(this.metricsTools.listMetrics("payment-service")).assertNext((result) -> { + assertThat(result).contains("Available metrics for payment-service"); + assertThat(result).contains("jvm.memory.used"); + assertThat(result).contains("system.cpu.usage"); + }).verifyComplete(); + } + + @Test + void listMetrics_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("unknown")).thenReturn(Flux.empty()); + + StepVerifier.create(this.metricsTools.listMetrics("unknown")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'unknown' not found in registry.")) + .verifyComplete(); + } + + @Test + void getMetrics_appFound_returnsMetricValue() { + Instance instance = Instance.create(InstanceId.of("id-2")) + .register(Registration.create("order-service", this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance)); + + this.wireMock.stubFor(get(urlEqualTo("/actuator/metrics/jvm.memory.used")) + .willReturn(okJson("{\"name\":\"jvm.memory.used\",\"baseUnit\":\"bytes\"," + + "\"measurements\":[{\"statistic\":\"VALUE\",\"value\":1234567890}]}"))); + + StepVerifier.create(this.metricsTools.getMetrics("order-service", "jvm.memory.used")).assertNext((result) -> { + assertThat(result).contains("order-service"); + assertThat(result).contains("jvm.memory.used"); + assertThat(result).contains("VALUE"); + assertThat(result).contains("bytes"); + }).verifyComplete(); + } + + @Test + void getMetrics_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.metricsTools.getMetrics("ghost", "jvm.memory.used")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} diff --git a/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java new file mode 100644 index 00000000000..06dacf12158 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/OperationsToolsTest.java @@ -0,0 +1,145 @@ +/* + * 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.core.publisher.Mono; +import reactor.test.StepVerifier; + +import de.codecentric.boot.admin.server.domain.entities.Instance; +import de.codecentric.boot.admin.server.domain.entities.InstanceRepository; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; +import de.codecentric.boot.admin.server.domain.values.StatusInfo; +import de.codecentric.boot.admin.server.web.client.InstanceWebClient; + +import static com.github.tomakehurst.wiremock.client.WireMock.noContent; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static de.codecentric.boot.admin.server.web.client.InstanceExchangeFilterFunctions.rewriteEndpointUrl; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OperationsToolsTest { + + private final WireMockServer wireMock = new WireMockServer(Options.DYNAMIC_PORT); + + private InstanceRepository instanceRepository; + + private OperationsTools operationsTools; + + @BeforeAll + static void setUpClass() { + StepVerifier.setDefaultTimeout(Duration.ofSeconds(5)); + } + + @AfterAll + static void tearDownClass() { + StepVerifier.resetDefaultTimeout(); + } + + @BeforeEach + void setUp() { + this.wireMock.start(); + this.instanceRepository = mock(InstanceRepository.class); + 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))); + } + + @AfterEach + void tearDown() { + this.wireMock.stop(); + } + + private Instance instance(String name) { + return Instance.create(InstanceId.of("id-" + name)) + .register(Registration.create(name, this.wireMock.url("/actuator/health")) + .managementUrl(this.wireMock.url("/actuator")) + .build()) + .withStatusInfo(StatusInfo.ofUp()); + } + + @Test + void restartApplication_success_returnsConfirmation() { + when(this.instanceRepository.findByName("payment-service")).thenReturn(Flux.just(instance("payment-service"))); + + this.wireMock.stubFor(post(urlEqualTo("/actuator/restart")).willReturn(noContent())); + + StepVerifier.create(this.operationsTools.restartApplication("payment-service")) + .assertNext((result) -> assertThat(result).contains("payment-service restart initiated successfully")) + .verifyComplete(); + } + + @Test + void restartApplication_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.operationsTools.restartApplication("ghost")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + + @Test + void refreshConfiguration_withChanges_returnsChangedKeys() { + when(this.instanceRepository.findByName("order-service")).thenReturn(Flux.just(instance("order-service"))); + + this.wireMock.stubFor(post(urlEqualTo("/actuator/refresh")) + .willReturn(okJson("[\"spring.datasource.url\",\"app.timeout\"]"))); + + StepVerifier.create(this.operationsTools.refreshConfiguration("order-service")).assertNext((result) -> { + assertThat(result).contains("Configuration refreshed for order-service"); + assertThat(result).contains("spring.datasource.url"); + }).verifyComplete(); + } + + @Test + void refreshConfiguration_noChanges_returnsNoChangesMessage() { + when(this.instanceRepository.findByName("checkout-service")) + .thenReturn(Flux.just(instance("checkout-service"))); + + this.wireMock.stubFor(post(urlEqualTo("/actuator/refresh")).willReturn(okJson("[]"))); + + StepVerifier.create(this.operationsTools.refreshConfiguration("checkout-service")).assertNext((result) -> { + assertThat(result).contains("Configuration refreshed for checkout-service"); + assertThat(result).contains("No properties changed"); + }).verifyComplete(); + } + + @Test + void refreshConfiguration_appNotFound_returnsNotFoundMessage() { + when(this.instanceRepository.findByName("ghost")).thenReturn(Flux.empty()); + + StepVerifier.create(this.operationsTools.refreshConfiguration("ghost")) + .assertNext((result) -> assertThat(result).isEqualTo("Application 'ghost' not found in registry.")) + .verifyComplete(); + } + +} 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..48366df1e6f --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ScheduledTasksToolsTest.java @@ -0,0 +1,130 @@ +/* + * 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.core.publisher.Mono; +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.ArgumentMatchers.any; +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); + 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))); + } + + @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..23b4f467649 --- /dev/null +++ b/spring-boot-admin-server-mcp/src/test/java/de/codecentric/boot/admin/server/mcp/tools/ThreadDumpToolsTest.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 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.core.publisher.Mono; +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.ArgumentMatchers.any; +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); + 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)), + Duration.ofSeconds(10)); + } + + @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(); + } + +} 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 + + +