hawkBit MCP server (#2871)

* hawkBit MCP server

Signed-off-by: Denislav Prinov <denislav.prinov@bosch.com>

* Fix STDIO authentication support. Change license headers. Inline Docker build

Signed-off-by: Denislav Prinov <denislav.prinov@bosch.com>

* Address PR review: refactor operation DTOs to sealed interfaces, make authentication validator conditional, and separate HTTP/STDIO client configurations

Signed-off-by: Denislav Prinov <denislav.prinov@bosch.com>

* Address PR review. Provide More context in tools description.

Signed-off-by: Denislav Prinov <denislav.prinov@bosch.com>

* Address PR Review

Signed-off-by: Denislav Prinov <denislav.prinov@bosch.com>

---------

Signed-off-by: Denislav Prinov <denislav.prinov@bosch.com>
This commit is contained in:
Denislav Prinov
2026-02-05 16:06:50 +02:00
committed by GitHub
parent c33156b134
commit 9f7ca0a3ed
30 changed files with 2958 additions and 0 deletions

View File

@@ -62,6 +62,8 @@ if [ -z "$1" ]; then
build "hawkbit-update-server"
# db init
build "hawkbit-repository-jpa-init"
# mcp server
build "hawkbit-mcp-server"
else
echo "Build $1"
build $1

109
hawkbit-mcp/README.md Normal file
View File

@@ -0,0 +1,109 @@
# hawkBit MCP Server
A standalone [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that provides AI assistants with tools to interact with [Eclipse hawkBit](https://www.eclipse.org/hawkbit/) for IoT device software update management.
## Building
From the project root directory:
```bash
mvn clean package -pl hawkbit-mcp -am -DskipTests
```
The JAR will be created at: `hawkbit-mcp/target/hawkbit-mcp-server-0-SNAPSHOT.jar`
## Configuration
The MCP server supports two transport modes:
| Mode | Use Case | Authentication |
|------|----------|----------------|
| **HTTP/SSE** | Remote access, multi-user | Per-request via `Authorization` header |
| **STDIO** | Local CLI tools (e.g., Claude Code) | Environment variables |
### HTTP Transport
Use HTTP transport when running the server as a standalone service:
```json
{
"mcpServers": {
"hawkbit-mcp": {
"type": "http",
"url": "http://localhost:8081/mcp",
"headers": {
"Authorization": "Basic <BASE64_ENCODED_CREDENTIALS>"
}
}
}
}
```
Start the server separately:
```bash
java -jar hawkbit-mcp-server-0-SNAPSHOT.jar \
--hawkbit.mcp.mgmt-url=<HAWKBIT_URL>
```
**Generating Base64 credentials:**
```bash
# Linux/Mac
echo -n "<USERNAME>:<PASSWORD>" | base64
# PowerShell
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("<USERNAME>:<PASSWORD>"))
```
### STDIO Transport
Use STDIO transport for direct integration:
```json
{
"mcpServers": {
"hawkbit-mcp": {
"command": "java",
"args": [
"-Dspring.ai.mcp.server.stdio=true",
"-Dspring.main.web-application-type=none",
"-jar",
"/path/to/hawkbit-mcp-server-0-SNAPSHOT.jar"
],
"env": {
"HAWKBIT_URL": "<HAWKBIT_URL>",
"HAWKBIT_USERNAME": "<USERNAME>",
"HAWKBIT_PASSWORD": "<PASSWORD>"
}
}
}
}
```
## Configuration Properties
| Property | Environment Variable | Description | Default |
|----------|---------------------|-------------|---------|
| `hawkbit.mcp.mgmt-url` | `HAWKBIT_URL` | hawkBit Management API URL | `http://localhost:8080` |
| `hawkbit.mcp.username` | `HAWKBIT_USERNAME` | Username for STDIO mode | - |
| `hawkbit.mcp.password` | `HAWKBIT_PASSWORD` | Password for STDIO mode | - |
| `hawkbit.mcp.validation.enabled` | - | Validate credentials against hawkBit | `true` |
| `hawkbit.mcp.validation.cache-ttl` | - | Cache TTL for auth validation | `600s` |
### Operation Controls
You can enable/disable specific operations globally or per-entity:
```properties
# Global: disable all deletes
hawkbit.mcp.operations.delete-enabled=false
# Per-entity: allow delete for targets only
hawkbit.mcp.operations.targets.delete-enabled=true
# Disable rollout lifecycle operations
hawkbit.mcp.operations.rollouts.start-enabled=false
hawkbit.mcp.operations.rollouts.approve-enabled=false
```

135
hawkbit-mcp/pom.xml Normal file
View File

@@ -0,0 +1,135 @@
<!--
Copyright (c) 2025 Contributors to the Eclipse Foundation
This program and the accompanying materials are made
available under the terms of the Eclipse Public License 2.0
which is available at https://www.eclipse.org/legal/epl-2.0/
SPDX-License-Identifier: EPL-2.0
-->
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>hawkbit-mcp-server</artifactId>
<name>hawkBit :: MCP Server (Standalone)</name>
<description>Standalone MCP server that connects to hawkBit via REST API</description>
<dependencies>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-sdk-mgmt</artifactId>
<version>${project.version}</version>
<exclusions>
<exclusion>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-mcp-annotations</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-hawkbit-docs</id>
<phase>generate-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}/hawkbit-docs</outputDirectory>
<resources>
<resource>
<directory>${project.basedir}/../docs</directory>
<includes>
<include>README.md</include>
<include>what-is-hawkbit.md</include>
<include>quick-start.md</include>
<include>features.md</include>
<include>architecture.md</include>
<include>base-setup.md</include>
<include>hawkbit-sdk.md</include>
<include>feign-client.md</include>
<include>clustering.md</include>
<include>authentication.md</include>
<include>authorization.md</include>
<include>datamodel.md</include>
<include>rollout-management.md</include>
<include>targetstate.md</include>
<include>management-api.md</include>
<include>direct-device-integration-api.md</include>
<include>device-management-federation-api.md</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>org.eclipse.hawkbit.mcp.server.HawkbitMcpServerApplication</mainClass>
<attach>false</attach>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
<configuration>
<classifier>exec</classifier>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,32 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server;
import org.eclipse.hawkbit.mcp.server.config.HawkbitMcpProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
/**
* Standalone MCP Server application that connects to hawkBit via REST API.
* <p>
* This server acts as a proxy between MCP clients and hawkBit,
* passing through authentication credentials to the hawkBit REST API.
* </p>
*/
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
@EnableConfigurationProperties(HawkbitMcpProperties.class)
public class HawkbitMcpServerApplication {
public static void main(String[] args) {
SpringApplication.run(HawkbitMcpServerApplication.class, args);
}
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.client;
/**
* Interface for authentication validation.
* Implementations can validate credentials against hawkBit or provide no-op validation.
*/
public interface AuthenticationValidator {
/**
* Validates the given authorization header.
*
* @param authHeader the Authorization header value
* @return validation result
*/
ValidationResult validate(String authHeader);
/**
* Result of authentication validation.
*/
enum ValidationResult {
/**
* Credentials are valid (authenticated user).
*/
VALID,
/**
* No credentials provided.
*/
MISSING_CREDENTIALS,
/**
* Credentials are invalid (401 from hawkBit).
*/
INVALID_CREDENTIALS,
/**
* hawkBit is unavailable or returned unexpected error.
*/
HAWKBIT_ERROR
}
}

View File

@@ -0,0 +1,130 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.client;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import feign.FeignException;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mcp.server.config.HawkbitMcpProperties;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTenantManagementRestApi;
import org.eclipse.hawkbit.sdk.HawkbitClient;
import org.eclipse.hawkbit.sdk.Tenant;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/**
* Validates authentication credentials against hawkBit REST API using the SDK.
* This validator is conditionally created when {@code hawkbit.mcp.validation.enabled=true}.
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "hawkbit.mcp.validation.enabled", havingValue = "true", matchIfMissing = true)
public class HawkbitAuthenticationValidator implements AuthenticationValidator {
private final HawkbitClient hawkbitClient;
private final Tenant dummyTenant;
private final Cache<String, Boolean> validationCache;
public HawkbitAuthenticationValidator(final HawkbitClient hawkbitClient,
final Tenant dummyTenant,
final HawkbitMcpProperties properties) {
this.hawkbitClient = hawkbitClient;
this.dummyTenant = dummyTenant;
this.validationCache = Caffeine.newBuilder()
.expireAfterWrite(properties.getValidation().getCacheTtl())
.maximumSize(properties.getValidation().getCacheMaxSize())
.build();
log.info("Authentication validation enabled with cache TTL={}, maxSize={}",
properties.getValidation().getCacheTtl(),
properties.getValidation().getCacheMaxSize());
}
/**
* Validates the given authorization header against hawkBit.
*
* @param authHeader the Authorization header value
* @return validation result
*/
@Override
public ValidationResult validate(final String authHeader) {
if (authHeader == null || authHeader.isBlank()) {
return ValidationResult.MISSING_CREDENTIALS;
}
String cacheKey = hashAuthHeader(authHeader);
Boolean cachedResult = validationCache.getIfPresent(cacheKey);
if (cachedResult != null) {
log.debug("Authentication validation cache hit: valid={}", cachedResult);
return cachedResult ? ValidationResult.VALID : ValidationResult.INVALID_CREDENTIALS;
}
return validateWithHawkbit(cacheKey);
}
private ValidationResult validateWithHawkbit(final String cacheKey) {
log.debug("Validating authentication against hawkBit using SDK");
try {
MgmtTenantManagementRestApi tenantApi = hawkbitClient.mgmtService(
MgmtTenantManagementRestApi.class, dummyTenant);
ResponseEntity<?> response = tenantApi.getTenantConfiguration();
int statusCode = response.getStatusCode().value();
if (statusCode >= 200 && statusCode < 300) {
log.debug("Authentication valid (status={})", statusCode);
validationCache.put(cacheKey, true);
return ValidationResult.VALID;
} else {
log.warn("Unexpected status from hawkBit during auth validation: {}", statusCode);
return ValidationResult.HAWKBIT_ERROR;
}
} catch (FeignException.Unauthorized e) {
log.debug("Authentication invalid (status=401)");
validationCache.put(cacheKey, false);
return ValidationResult.INVALID_CREDENTIALS;
} catch (FeignException.Forbidden e) {
// 403 = Valid credentials but lacks READ_TENANT_CONFIGURATION permission
// User is authenticated in hawkBit but doesn't have this specific permission
log.debug("Authentication valid but lacks permission (status=403)");
validationCache.put(cacheKey, true);
return ValidationResult.VALID;
} catch (FeignException e) {
log.warn("Error validating authentication against hawkBit: {} - {}",
e.getClass().getSimpleName(), e.getMessage());
return ValidationResult.HAWKBIT_ERROR;
} catch (Exception e) {
// Unexpected errors, don't cache, fail closed
log.warn("Unexpected error validating authentication against hawkBit: {}", e.getMessage());
return ValidationResult.HAWKBIT_ERROR;
}
}
private String hashAuthHeader(final String authHeader) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(authHeader.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
// SHA-256 is always available
throw new McpAuthenticationException("SHA-256 not available." + e.getMessage());
}
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.client;
public class McpAuthenticationException extends RuntimeException {
public McpAuthenticationException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.config;
import feign.Contract;
import feign.RequestInterceptor;
import feign.codec.Decoder;
import feign.codec.Encoder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.sdk.Controller;
import org.eclipse.hawkbit.sdk.HawkbitClient;
import org.eclipse.hawkbit.sdk.HawkbitServer;
import org.eclipse.hawkbit.sdk.Tenant;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import java.util.function.BiFunction;
/**
* Common configuration for the hawkBit SDK client.
* <p>
* Provides the {@link HawkbitServer} and {@link HawkbitClient} beans.
* Mode-specific beans (Tenant, request interceptor) are provided by:
* <ul>
* <li>{@link McpHttpClientConfiguration} - for HTTP mode (per-request authentication)</li>
* <li>{@link McpStdioClientConfiguration} - for STDIO mode (static credentials)</li>
* </ul>
* </p>
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class HawkbitClientConfiguration {
private final HawkbitMcpProperties properties;
@Bean
@Primary
public HawkbitServer hawkbitServer() {
HawkbitServer server = new HawkbitServer();
server.setMgmtUrl(properties.getMgmtUrl());
log.info("Configured hawkBit server URL: {}", properties.getMgmtUrl());
return server;
}
@Bean
public HawkbitClient hawkbitClient(
final HawkbitServer server,
final Encoder encoder,
final Decoder decoder,
final Contract contract,
final BiFunction<Tenant, Controller, RequestInterceptor> hawkbitRequestInterceptor) {
log.info("Configuring hawkBit client");
return HawkbitClient.builder()
.hawkBitServer(server)
.encoder(encoder)
.decoder(decoder)
.contract(contract)
.requestInterceptorFn(hawkbitRequestInterceptor)
.build();
}
}

View File

@@ -0,0 +1,225 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.config;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
/**
* Configuration properties for the standalone hawkBit MCP server.
*/
@Data
@Validated
@ConfigurationProperties(prefix = "hawkbit.mcp")
public class HawkbitMcpProperties {
/**
* Base URL of the hawkBit Management API (e.g., <a href="http://localhost:8080">...</a>).
*/
@NotBlank(message = "hawkbit.mcp.mgmt-url must be configured")
private String mgmtUrl;
/**
* Username for hawkBit authentication (used in STDIO mode).
* Read directly from HAWKBIT_USERNAME environment variable.
*/
@Value("${HAWKBIT_USERNAME:#{null}}")
private String username;
/**
* Password for hawkBit authentication (used in STDIO mode).
* Read directly from HAWKBIT_PASSWORD environment variable.
*/
@Value("${HAWKBIT_PASSWORD:#{null}}")
private String password;
/**
* Check if static credentials are configured.
* Allows empty strings as valid values (for users who intentionally set empty password).
*/
public boolean hasStaticCredentials() {
return username != null && password != null;
}
/**
* Whether to enable the built-in hawkBit tools.
* Set to false to provide custom tool implementations.
*/
private boolean toolsEnabled = true;
/**
* Whether to enable the built-in hawkBit documentation resources.
* Set to false to provide custom resource implementations.
*/
private boolean resourcesEnabled = true;
/**
* Whether to enable the built-in hawkBit prompts.
* Set to false to provide custom prompt implementations.
*/
private boolean promptsEnabled = true;
/**
* Authentication validation configuration.
*/
private Validation validation = new Validation();
/**
* Operations configuration for enabling/disabling specific operations.
*/
private Operations operations = new Operations();
/**
* Configuration for pre-authentication validation against hawkBit.
*/
@Data
public static class Validation {
/**
* Whether to validate authentication against hawkBit before processing MCP requests.
*/
private boolean enabled = true;
/**
* Duration to cache authentication validation results.
* Shorter values are more secure but increase load on hawkBit.
*/
private Duration cacheTtl = Duration.ofSeconds(60);
/**
* Maximum number of entries in the authentication validation cache.
*/
private int cacheMaxSize = 1000;
}
/**
* Configuration for enabling/disabling operations at global and per-entity levels.
*/
@Data
public static class Operations {
// Global defaults
private boolean listEnabled = true;
private boolean createEnabled = true;
private boolean updateEnabled = true;
private boolean deleteEnabled = true;
// Per-entity overrides (null = use global)
private EntityConfig targets = new EntityConfig();
private RolloutConfig rollouts = new RolloutConfig();
private EntityConfig distributionSets = new EntityConfig();
private ActionConfig actions = new ActionConfig();
private EntityConfig softwareModules = new EntityConfig();
private EntityConfig targetFilters = new EntityConfig();
/**
* Check if an operation is enabled globally.
*/
public boolean isGlobalOperationEnabled(final String operation) {
return switch (operation.toLowerCase()) {
case "list" -> listEnabled;
case "create" -> createEnabled;
case "update" -> updateEnabled;
case "delete" -> deleteEnabled;
default -> true;
};
}
}
/**
* Per-entity operation configuration.
*/
@Data
public static class EntityConfig {
private Boolean listEnabled;
private Boolean createEnabled;
private Boolean updateEnabled;
private Boolean deleteEnabled;
/**
* Get the enabled state for an operation, or null if not set (use global).
*/
public Boolean getOperationEnabled(final String operation) {
return switch (operation.toLowerCase()) {
case "list" -> listEnabled;
case "create" -> createEnabled;
case "update" -> updateEnabled;
case "delete" -> deleteEnabled;
default -> null;
};
}
}
/**
* Rollout-specific operation configuration including lifecycle operations.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public static class RolloutConfig extends EntityConfig {
private Boolean startEnabled = true;
private Boolean pauseEnabled = true;
private Boolean stopEnabled = true;
private Boolean resumeEnabled = true;
private Boolean approveEnabled = true;
private Boolean denyEnabled = true;
private Boolean retryEnabled = true;
private Boolean triggerNextGroupEnabled = true;
@Override
public Boolean getOperationEnabled(final String operation) {
final Boolean baseResult = super.getOperationEnabled(operation);
if (baseResult != null) {
return baseResult;
}
return switch (operation.toLowerCase().replace("_", "-")) {
case "start" -> startEnabled;
case "pause" -> pauseEnabled;
case "stop" -> stopEnabled;
case "resume" -> resumeEnabled;
case "approve" -> approveEnabled;
case "deny" -> denyEnabled;
case "retry" -> retryEnabled;
case "trigger-next-group" -> triggerNextGroupEnabled;
default -> null;
};
}
}
/**
* Action-specific operation configuration.
*/
@Data
public static class ActionConfig {
private Boolean listEnabled;
private Boolean deleteEnabled;
private Boolean deleteBatchEnabled = true;
/**
* Get the enabled state for an operation, or null if not set (use global).
*/
public Boolean getOperationEnabled(final String operation) {
return switch (operation.toLowerCase().replace("_", "-")) {
case "list" -> listEnabled;
case "delete" -> deleteEnabled;
case "delete-batch" -> deleteBatchEnabled;
default -> null;
};
}
}
}

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.config;
import feign.RequestInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.sdk.Controller;
import org.eclipse.hawkbit.sdk.Tenant;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.function.BiFunction;
/**
* Configuration for HTTP mode.
* <p>
* In HTTP mode, authentication is extracted from the incoming HTTP request's
* Authorization header and forwarded to hawkBit (per-request authentication).
* </p>
*/
@Slf4j
@Configuration
@ConditionalOnProperty(name = "spring.ai.mcp.server.stdio", havingValue = "false", matchIfMissing = true)
public class McpHttpClientConfiguration {
/**
* Request interceptor for HTTP mode - extracts authentication from incoming HTTP request.
*/
@Bean
public BiFunction<Tenant, Controller, RequestInterceptor> hawkbitRequestInterceptor() {
log.info("Configuring HTTP mode request interceptor (per-request authentication)");
return (tenant, controller) -> template -> {
final ServletRequestAttributes attrs =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
final HttpServletRequest request = attrs.getRequest();
final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authHeader != null) {
template.header(HttpHeaders.AUTHORIZATION, authHeader);
log.trace("Using auth header from HTTP request");
} else {
log.warn("No authentication header in request - request will likely fail");
}
} else {
log.warn("No request context available - request will likely fail");
}
};
}
/**
* Tenant bean for HTTP mode - credentials are null as authentication
* comes from the incoming HTTP request context via the interceptor.
*/
@Bean
public Tenant dummyTenant() {
log.info("Configured tenant for HTTP mode (per-request authentication)");
return new Tenant();
}
}

View File

@@ -0,0 +1,129 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mcp.server.client.AuthenticationValidator;
import org.eclipse.hawkbit.mcp.server.client.AuthenticationValidator.ValidationResult;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Optional;
/**
* Security configuration for the MCP server.
* <p>
* This configuration is only active in HTTP/servlet mode. In STDIO mode,
* authentication is handled via static credentials from properties.
* </p>
* <p>
* When authentication validation is enabled ({@code hawkbit.mcp.validation.enabled=true}),
* a filter validates credentials against hawkBit before forwarding requests.
* When disabled, no validation filter is added and requests pass through directly.
* </p>
*/
@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(name = "spring.ai.mcp.server.stdio", havingValue = "false", matchIfMissing = true)
public class McpSecurityConfiguration {
private final Optional<AuthenticationValidator> authenticationValidator;
@Bean
@SuppressWarnings("java:S4502") // CSRF protection is not needed for stateless REST APIs using Authorization header
public SecurityFilterChain mcpSecurityFilterChain(final HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
authenticationValidator.ifPresentOrElse(
validator -> {
log.info("Authentication validation enabled - adding validation filter");
http.addFilterBefore(new HawkBitAuthenticationFilter(validator),
UsernamePasswordAuthenticationFilter.class);
},
() -> log.info("Authentication validation disabled - requests will be forwarded without validation")
);
return http.build();
}
/**
* Filter that validates authentication against hawkBit.
* <p>
* Only added to the filter chain when authentication validation is enabled.
* </p>
*/
@Slf4j
@RequiredArgsConstructor
public static class HawkBitAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationValidator validator;
@Override
protected void doFilterInternal(final HttpServletRequest request, final @NonNull HttpServletResponse response,
final @NonNull FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
final ValidationResult result = validator.validate(authHeader);
switch (result) {
case VALID -> filterChain.doFilter(request, response);
case MISSING_CREDENTIALS -> {
log.debug("Rejecting request: missing credentials");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED,
"Authentication required. Please provide hawkBit credentials.");
}
case INVALID_CREDENTIALS -> {
log.debug("Rejecting request: invalid credentials");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED,
"Invalid hawkBit credentials.");
}
case HAWKBIT_ERROR -> {
log.warn("Rejecting request: hawkBit unavailable");
sendErrorResponse(response, HttpStatus.SERVICE_UNAVAILABLE,
"Unable to validate credentials. hawkBit may be unavailable.");
}
}
}
private void sendErrorResponse(final HttpServletResponse response, final HttpStatus status, final String message)
throws IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(String.format(
"{\"error\":\"%s\",\"message\":\"%s\"}",
status.getReasonPhrase(),
message));
}
}
}

View File

@@ -0,0 +1,76 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.config;
import feign.RequestInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.sdk.Controller;
import org.eclipse.hawkbit.sdk.Tenant;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.function.BiFunction;
/**
* Configuration for STDIO mode.
* <p>
* In STDIO mode, authentication uses static credentials from configuration properties
* (environment variables HAWKBIT_USERNAME and HAWKBIT_PASSWORD).
* </p>
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
@ConditionalOnProperty(name = "spring.ai.mcp.server.stdio", havingValue = "true")
public class McpStdioClientConfiguration {
private final HawkbitMcpProperties properties;
/**
* Request interceptor for STDIO mode - uses static credentials from configuration.
*/
@Bean
public BiFunction<Tenant, Controller, RequestInterceptor> hawkbitRequestInterceptor() {
log.info("Configuring STDIO mode request interceptor (static credentials)");
return (tenant, controller) -> template -> {
if (properties.hasStaticCredentials()) {
String credentials = properties.getUsername() + ":" + properties.getPassword();
String authHeader = "Basic " + Base64.getEncoder().encodeToString(
credentials.getBytes(StandardCharsets.UTF_8));
template.header(HttpHeaders.AUTHORIZATION, authHeader);
log.trace("Using static credentials from properties (STDIO mode)");
} else {
log.warn("No static credentials configured for STDIO mode - request will likely fail");
}
};
}
/**
* Tenant bean for STDIO mode - uses static credentials from configuration properties.
*/
@Bean
public Tenant dummyTenant() {
final Tenant tenant = new Tenant();
if (properties.hasStaticCredentials()) {
tenant.setUsername(properties.getUsername());
tenant.setPassword(properties.getPassword());
log.info("Configured tenant with static credentials for STDIO mode");
} else {
log.warn("STDIO mode enabled but no static credentials configured");
}
return tenant;
}
}

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.config;
import org.eclipse.hawkbit.mcp.server.prompts.HawkbitPromptProvider;
import org.eclipse.hawkbit.mcp.server.resources.HawkbitDocumentationResource;
import org.eclipse.hawkbit.mcp.server.tools.HawkbitMcpToolProvider;
import org.eclipse.hawkbit.sdk.HawkbitClient;
import org.eclipse.hawkbit.sdk.Tenant;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration for MCP tools, resources, and prompts.
* <p>
* <ul>
* <li>All beans use {@code @ConditionalOnMissingBean} - override by defining your own bean</li>
* <li>Properties allow disabling built-in tools/resources/prompts</li>
* <li>Spring AI MCP auto-discovers {@code @Tool}, {@code @McpResource}, and {@code @McpPrompt} annotations</li>
* </ul>
* </p>
*/
@Configuration
public class McpToolConfiguration {
/**
* Creates the hawkBit tool provider.
* <p>
* Spring AI MCP auto-discovers {@code @McpTool} annotated methods on this bean.
* Override by defining your own {@code HawkBitMcpToolProvider} bean.
* Disable by setting {@code hawkbit.mcp.tools-enabled=false}.
* Individual operations can be enabled/disabled via {@code hawkbit.mcp.operations.*} properties.
* </p>
*/
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(name = "hawkbit.mcp.tools-enabled", havingValue = "true", matchIfMissing = true)
public HawkbitMcpToolProvider hawkBitMcpToolProvider(
final HawkbitClient hawkbitClient,
final Tenant dummyTenant,
final HawkbitMcpProperties properties) {
return new HawkbitMcpToolProvider(hawkbitClient, dummyTenant, properties);
}
/**
* Creates the hawkBit documentation resource provider.
* <p>
* Spring AI MCP auto-discovers {@code @McpResource} annotated methods on this bean.
* Override by defining your own {@code HawkBitDocumentationResource} bean.
* Disable by setting {@code hawkbit.mcp.resources-enabled=false}.
* </p>
*/
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(name = "hawkbit.mcp.resources-enabled", havingValue = "true", matchIfMissing = true)
public HawkbitDocumentationResource hawkBitDocumentationResource() {
return new HawkbitDocumentationResource();
}
/**
* Creates the hawkBit prompt provider.
* <p>
* Spring AI MCP auto-discovers {@code @McpPrompt} annotated methods on this bean.
* Override by defining your own {@code HawkBitPromptProvider} bean.
* Disable by setting {@code hawkbit.mcp.prompts-enabled=false}.
* </p>
*/
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(name = "hawkbit.mcp.prompts-enabled", havingValue = "true", matchIfMissing = true)
public HawkbitPromptProvider hawkBitPromptProvider() {
return new HawkbitPromptProvider();
}
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.util.List;
/**
* Sealed interface for action management operations.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = ActionRequest.Delete.class, name = "Delete"),
@JsonSubTypes.Type(value = ActionRequest.DeleteBatch.class, name = "DeleteBatch")
})
public sealed interface ActionRequest
permits ActionRequest.Delete, ActionRequest.DeleteBatch {
/**
* Request to delete a single action.
*
* @param actionId to delete
*/
record Delete(Long actionId) implements ActionRequest {}
/**
* Request to delete multiple actions.
*
* @param actionIds list of action IDs to delete (mutually exclusive with rsql)
* @param rsql RSQL filter query for selecting actions to delete (mutually exclusive with actionIds)
*/
record DeleteBatch(List<Long> actionIds, String rsql) implements ActionRequest {}
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSetRequestBodyPost;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSetRequestBodyPut;
/**
* Sealed interface for distribution set management operations.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = DistributionSetRequest.Create.class, name = "Create"),
@JsonSubTypes.Type(value = DistributionSetRequest.Update.class, name = "Update"),
@JsonSubTypes.Type(value = DistributionSetRequest.Delete.class, name = "Delete")
})
public sealed interface DistributionSetRequest
permits DistributionSetRequest.Create, DistributionSetRequest.Update, DistributionSetRequest.Delete {
/**
* Request to create a new distribution set.
*
* @param body the request body containing distribution set data (name, version, type)
*/
record Create(MgmtDistributionSetRequestBodyPost body) implements DistributionSetRequest {}
/**
* Request to update an existing distribution set.
*
* @param distributionSetId the distribution set ID
* @param body the request body containing updated distribution set data
*/
record Update(Long distributionSetId, MgmtDistributionSetRequestBodyPut body) implements DistributionSetRequest {}
/**
* Request to delete a distribution set.
*
* @param distributionSetId the distribution set ID to delete
*/
record Delete(Long distributionSetId) implements DistributionSetRequest {}
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
/**
* Common request parameters for list operations.
*/
public record ListRequest(
@JsonPropertyDescription("RSQL filter query (e.g., 'name==test*')")
String rsql,
@JsonPropertyDescription("Number of items to skip (default: 0)")
Integer offset,
@JsonPropertyDescription("Maximum number of items to return (default: 50)")
Integer limit
) {
public static final int DEFAULT_OFFSET = 0;
public static final int DEFAULT_LIMIT = 50;
public int getOffsetOrDefault() {
return offset != null ? offset : DEFAULT_OFFSET;
}
public int getLimitOrDefault() {
return limit != null ? limit : DEFAULT_LIMIT;
}
public String getRsqlOrNull() {
return rsql != null && !rsql.isBlank() ? rsql : null;
}
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
/**
* Unified response wrapper for management operations.
*
* @param <T> the type of the data payload
* @param operation the operation that was performed
* @param success whether the operation was successful
* @param message optional message (typically for success confirmations or error details)
* @param data the operation result data (e.g., created/updated entity)
*/
public record OperationResponse<T>(
String operation,
boolean success,
String message,
T data
) {
/**
* Creates a successful response with data.
*/
public static <T> OperationResponse<T> success(final String operation, final T data) {
return new OperationResponse<>(operation, true, null, data);
}
/**
* Creates a successful response with a message (no data).
*/
public static <T> OperationResponse<T> success(final String operation, final String message) {
return new OperationResponse<>(operation, true, message, null);
}
/**
* Creates a successful response with both message and data.
*/
public static <T> OperationResponse<T> success(final String operation, final String message, final T data) {
return new OperationResponse<>(operation, true, message, data);
}
/**
* Creates a failure response with an error message.
*/
public static <T> OperationResponse<T> failure(final String operation, final String message) {
return new OperationResponse<>(operation, false, message, null);
}
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
import java.util.List;
/**
* Generic paged response for MCP tool results.
*
* @param <T> the type of items in the response
*/
public record PagedResponse<T>(
List<T> content,
long total,
int offset,
int limit
) {
public static <T> PagedResponse<T> of(final List<T> content, final long total, final int offset, final int limit) {
return new PagedResponse<>(content, total, offset, limit);
}
}

View File

@@ -0,0 +1,119 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutRestRequestBodyPost;
import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutRestRequestBodyPut;
/**
* Sealed interface for rollout management operations including CRUD and lifecycle.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = RolloutRequest.Create.class, name = "Create"),
@JsonSubTypes.Type(value = RolloutRequest.Update.class, name = "Update"),
@JsonSubTypes.Type(value = RolloutRequest.Delete.class, name = "Delete"),
@JsonSubTypes.Type(value = RolloutRequest.Start.class, name = "Start"),
@JsonSubTypes.Type(value = RolloutRequest.Pause.class, name = "Pause"),
@JsonSubTypes.Type(value = RolloutRequest.Stop.class, name = "Stop"),
@JsonSubTypes.Type(value = RolloutRequest.Resume.class, name = "Resume"),
@JsonSubTypes.Type(value = RolloutRequest.Approve.class, name = "Approve"),
@JsonSubTypes.Type(value = RolloutRequest.Deny.class, name = "Deny"),
@JsonSubTypes.Type(value = RolloutRequest.Retry.class, name = "Retry"),
@JsonSubTypes.Type(value = RolloutRequest.TriggerNextGroup.class, name = "TriggerNextGroup")
})
public sealed interface RolloutRequest
permits RolloutRequest.Create, RolloutRequest.Update, RolloutRequest.Delete,
RolloutRequest.Start, RolloutRequest.Pause, RolloutRequest.Stop,
RolloutRequest.Resume, RolloutRequest.Approve, RolloutRequest.Deny,
RolloutRequest.Retry, RolloutRequest.TriggerNextGroup {
/**
* Request to create a new rollout.
*
* @param body the request body containing rollout data
*/
record Create(MgmtRolloutRestRequestBodyPost body) implements RolloutRequest {}
/**
* Request to update an existing rollout.
*
* @param rolloutId the rollout ID
* @param body the request body containing updated rollout data
*/
record Update(Long rolloutId, MgmtRolloutRestRequestBodyPut body) implements RolloutRequest {}
/**
* Request to delete a rollout.
*
* @param rolloutId the rollout ID to delete
*/
record Delete(Long rolloutId) implements RolloutRequest {}
/**
* Request to start a rollout.
*
* @param rolloutId the rollout ID to start
*/
record Start(Long rolloutId) implements RolloutRequest {}
/**
* Request to pause a rollout.
*
* @param rolloutId the rollout ID to pause
*/
record Pause(Long rolloutId) implements RolloutRequest {}
/**
* Request to stop a rollout.
*
* @param rolloutId the rollout ID to stop
*/
record Stop(Long rolloutId) implements RolloutRequest {}
/**
* Request to resume a paused rollout.
*
* @param rolloutId the rollout ID to resume
*/
record Resume(Long rolloutId) implements RolloutRequest {}
/**
* Request to approve a rollout.
*
* @param rolloutId the rollout ID to approve
* @param remark optional remark for the approval
*/
record Approve(Long rolloutId, String remark) implements RolloutRequest {}
/**
* Request to deny a rollout.
*
* @param rolloutId the rollout ID to deny
* @param remark optional remark for the denial
*/
record Deny(Long rolloutId, String remark) implements RolloutRequest {}
/**
* Request to retry a rollout.
*
* @param rolloutId the rollout ID to retry
*/
record Retry(Long rolloutId) implements RolloutRequest {}
/**
* Request to trigger the next group in a rollout.
*
* @param rolloutId the rollout ID
*/
record TriggerNextGroup(Long rolloutId) implements RolloutRequest {}
}

View File

@@ -0,0 +1,50 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleRequestBodyPost;
import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleRequestBodyPut;
/**
* Sealed interface for software module management operations.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = SoftwareModuleRequest.Create.class, name = "Create"),
@JsonSubTypes.Type(value = SoftwareModuleRequest.Update.class, name = "Update"),
@JsonSubTypes.Type(value = SoftwareModuleRequest.Delete.class, name = "Delete")
})
public sealed interface SoftwareModuleRequest
permits SoftwareModuleRequest.Create, SoftwareModuleRequest.Update, SoftwareModuleRequest.Delete {
/**
* Request to create a new software module.
*
* @param body the request body containing software module data (name, version, type)
*/
record Create(MgmtSoftwareModuleRequestBodyPost body) implements SoftwareModuleRequest {}
/**
* Request to update an existing software module.
*
* @param softwareModuleId the software module ID
* @param body the request body containing updated software module data
*/
record Update(Long softwareModuleId, MgmtSoftwareModuleRequestBodyPut body) implements SoftwareModuleRequest {}
/**
* Request to delete a software module.
*
* @param softwareModuleId the software module ID to delete
*/
record Delete(Long softwareModuleId) implements SoftwareModuleRequest {}
}

View File

@@ -0,0 +1,49 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQueryRequestBody;
/**
* Sealed interface for target filter query management operations.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = TargetFilterRequest.Create.class, name = "Create"),
@JsonSubTypes.Type(value = TargetFilterRequest.Update.class, name = "Update"),
@JsonSubTypes.Type(value = TargetFilterRequest.Delete.class, name = "Delete")
})
public sealed interface TargetFilterRequest
permits TargetFilterRequest.Create, TargetFilterRequest.Update, TargetFilterRequest.Delete {
/**
* Request to create a new target filter.
*
* @param body the request body containing filter data (name, query)
*/
record Create(MgmtTargetFilterQueryRequestBody body) implements TargetFilterRequest {}
/**
* Request to update an existing target filter.
*
* @param filterId the target filter ID
* @param body the request body containing updated filter data
*/
record Update(Long filterId, MgmtTargetFilterQueryRequestBody body) implements TargetFilterRequest {}
/**
* Request to delete a target filter.
*
* @param filterId the target filter ID to delete
*/
record Delete(Long filterId) implements TargetFilterRequest {}
}

View File

@@ -0,0 +1,49 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.dto;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody;
/**
* Sealed interface for target management operations.
*/
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = TargetRequest.Create.class, name = "Create"),
@JsonSubTypes.Type(value = TargetRequest.Update.class, name = "Update"),
@JsonSubTypes.Type(value = TargetRequest.Delete.class, name = "Delete")
})
public sealed interface TargetRequest
permits TargetRequest.Create, TargetRequest.Update, TargetRequest.Delete {
/**
* Request to create a new target.
*
* @param body the request body containing target data (controllerId, name, description)
*/
record Create(MgmtTargetRequestBody body) implements TargetRequest {}
/**
* Request to update an existing target.
*
* @param controllerId the target controller ID
* @param body the request body containing updated target data
*/
record Update(String controllerId, MgmtTargetRequestBody body) implements TargetRequest {}
/**
* Request to delete a target.
*
* @param controllerId the target controller ID to delete
*/
record Delete(String controllerId) implements TargetRequest {}
}

View File

@@ -0,0 +1,67 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.prompts;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.PromptMessage;
import io.modelcontextprotocol.spec.McpSchema.Role;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import lombok.extern.slf4j.Slf4j;
import org.springaicommunity.mcp.annotation.McpPrompt;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* MCP prompts for hawkBit that provide initial context to LLMs.
* <p>
* These prompts help LLMs understand what hawkBit is and what documentation
* resources are available at the start of a session.
* </p>
*/
@Slf4j
public class HawkbitPromptProvider {
private static final String PROMPTS_PATH = "prompts/";
@McpPrompt(
name = "hawkbit-context",
description = "Provides initial context about hawkBit, available tools, and documentation resources. " +
"Use this prompt at the start of a session to understand what you can do with hawkBit MCP.")
public GetPromptResult getHawkBitContext() {
return new GetPromptResult(
"hawkBit MCP Server Context",
List.of(new PromptMessage(Role.ASSISTANT, new TextContent(loadPrompt("hawkbit-context.md"))))
);
}
@McpPrompt(
name = "rsql-help",
description = "Explains RSQL query syntax for filtering hawkBit entities. " +
"Use this when you need help constructing filter queries for targets, rollouts, etc.")
public GetPromptResult getRsqlHelp() {
return new GetPromptResult(
"RSQL Query Help",
List.of(new PromptMessage(Role.ASSISTANT, new TextContent(loadPrompt("rsql-help.md"))))
);
}
private String loadPrompt(final String filename) {
try {
ClassPathResource resource = new ClassPathResource(PROMPTS_PATH + filename);
return resource.getContentAsString(StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("Failed to load prompt: {}", filename, e);
return "Prompt content not available.";
}
}
}

View File

@@ -0,0 +1,201 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.resources;
import lombok.extern.slf4j.Slf4j;
import org.springaicommunity.mcp.annotation.McpResource;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* MCP resources providing hawkBit documentation for LLMs.
*/
@Slf4j
public class HawkbitDocumentationResource {
private static final String DOCS_PATH = "hawkbit-docs/";
@McpResource(
uri = "hawkbit://docs/overview",
name = "hawkBit Overview",
description = "High-level introduction to hawkBit: interfaces (DDI, DMF, Management API), " +
"rollout management, and package model for IoT software updates")
public String getOverview() {
return loadDoc("README.md");
}
@McpResource(
uri = "hawkbit://docs/what-is-hawkbit",
name = "What is hawkBit",
description = "Explains what hawkBit is, why IoT software updates matter, " +
"and scalability features for cloud deployments")
public String getWhatIsHawkbit() {
return loadDoc("what-is-hawkbit.md");
}
@McpResource(
uri = "hawkbit://docs/quick-start",
name = "Quick Start Guide",
description = "Docker-based setup guides for monolith and microservices deployments, " +
"building from sources, and credential configuration")
public String getQuickStart() {
return loadDoc("quick-start.md");
}
@McpResource(
uri = "hawkbit://docs/features",
name = "Feature Overview",
description = "Comprehensive feature list: device repository, software management, " +
"artifact delivery, rollout management, and API interfaces")
public String getFeatures() {
return loadDoc("features.md");
}
@McpResource(
uri = "hawkbit://docs/architecture",
name = "System Architecture",
description = "Architecture overview with module diagram and third-party technology stack")
public String getArchitecture() {
return loadDoc("architecture.md");
}
@McpResource(
uri = "hawkbit://docs/base-setup",
name = "Production Setup",
description = "Configuring production infrastructure with MariaDB/MySQL database " +
"and RabbitMQ for DMF (Device Management Federation) communication")
public String getBaseSetup() {
return loadDoc("base-setup.md");
}
@McpResource(
uri = "hawkbit://docs/sdk",
name = "SDK Guide",
description = "hawkBit SDK for device and gateway integration: configuration properties, " +
"Maven dependencies, and usage examples with DdiTenant and MgmtAPI clients")
public String getSdkGuide() {
return loadDoc("hawkbit-sdk.md");
}
@McpResource(
uri = "hawkbit://docs/feign-client",
name = "Feign Client Guide",
description = "Creating Feign-based REST clients for Management API and DDI API " +
"with Spring Boot integration examples")
public String getFeignClientGuide() {
return loadDoc("feign-client.md");
}
@McpResource(
uri = "hawkbit://docs/clustering",
name = "Clustering Guide",
description = "Running hawkBit in clustered environments: Spring Cloud Stream event distribution, " +
"caching with TTL, scheduler behavior, and DoS filter constraints")
public String getClusteringGuide() {
return loadDoc("clustering.md");
}
@McpResource(
uri = "hawkbit://docs/authentication",
name = "Authentication",
description = "Security token authentication (target and gateway tokens), certificate-based auth " +
"via reverse proxy, TLS/mTLS setup, and Nginx configuration examples")
public String getAuthentication() {
return loadDoc("authentication.md");
}
@McpResource(
uri = "hawkbit://docs/authorization",
name = "Authorization",
description = "Fine-grained permission system for Management API/UI, DDI API authorization, " +
"permission groups, OpenID Connect support, and role-based access control")
public String getAuthorization() {
return loadDoc("authorization.md");
}
@McpResource(
uri = "hawkbit://docs/datamodel",
name = "Data Model",
description = "Entity definitions: provisioning targets, distribution sets, software modules, " +
"artifacts, entity relationships, and soft/hard delete strategies")
public String getDataModel() {
return loadDoc("datamodel.md");
}
@McpResource(
uri = "hawkbit://docs/rollout-management",
name = "Rollout Management",
description = "Rollout campaigns: cascading deployment groups, success/error thresholds, " +
"approval workflow, multi-assignments (beta), action weight prioritization, and state machines")
public String getRolloutManagement() {
return loadDoc("rollout-management.md");
}
@McpResource(
uri = "hawkbit://docs/target-state",
name = "Target State",
description = "Target state definitions (UNKNOWN, IN_SYNC, PENDING, ERROR, REGISTERED) " +
"and state transition diagrams")
public String getTargetState() {
return loadDoc("targetstate.md");
}
@McpResource(
uri = "hawkbit://docs/management-api",
name = "Management API",
description = "RESTful API for CRUD operations on targets and software: API versioning, " +
"HTTP methods, headers, error handling, and embedded Swagger UI reference")
public String getManagementApi() {
return loadDoc("management-api.md");
}
@McpResource(
uri = "hawkbit://docs/ddi-api",
name = "DDI API (Direct Device Integration)",
description = "HTTP polling-based device integration API: state machine mapping, " +
"status feedback mechanisms, update retrieval, and embedded Swagger UI reference")
public String getDdiApi() {
return loadDoc("direct-device-integration-api.md");
}
@McpResource(
uri = "hawkbit://docs/dmf-api",
name = "DMF API (Device Management Federation)",
description = "AMQP-based indirect device integration: message formats (THING_CREATED, etc.), " +
"exchanges, queues, bindings, and high-throughput service-to-service communication")
public String getDmfApi() {
return loadDoc("device-management-federation-api.md");
}
@McpResource(
uri = "hawkbit://docs/entity-definitions",
name = "hawkBit Entity Definitions",
description = "RSQL filtering syntax for querying targets, rollouts, distribution sets, " +
"actions, software modules, and target filter queries with examples")
public String getEntityDefinitions() {
return loadResource("hawkbit-entity-definitions.md");
}
private String loadDoc(final String filename) {
return loadResource(DOCS_PATH + filename);
}
private String loadResource(final String path) {
try {
ClassPathResource resource = new ClassPathResource(path);
return resource.getContentAsString(StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("Failed to load documentation: {}", path, e);
return "Documentation not available. Please refer to the hawkBit documentation at https://eclipse.dev/hawkbit/";
}
}
}

View File

@@ -0,0 +1,558 @@
/**
* Copyright (c) 2026 Contributors to the Eclipse Foundation
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.mcp.server.tools;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.mcp.server.config.HawkbitMcpProperties;
import org.eclipse.hawkbit.mcp.server.dto.ActionRequest;
import org.eclipse.hawkbit.mcp.server.dto.DistributionSetRequest;
import org.eclipse.hawkbit.mcp.server.dto.ListRequest;
import org.eclipse.hawkbit.mcp.server.dto.OperationResponse;
import org.eclipse.hawkbit.mcp.server.dto.PagedResponse;
import org.eclipse.hawkbit.mcp.server.dto.RolloutRequest;
import org.eclipse.hawkbit.mcp.server.dto.SoftwareModuleRequest;
import org.eclipse.hawkbit.mcp.server.dto.TargetFilterRequest;
import org.eclipse.hawkbit.mcp.server.dto.TargetRequest;
import org.eclipse.hawkbit.mgmt.json.model.PagedList;
import org.eclipse.hawkbit.mgmt.json.model.action.MgmtAction;
import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSet;
import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutResponseBody;
import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModule;
import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTarget;
import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQuery;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtActionRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtDistributionSetRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtRolloutRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtSoftwareModuleRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetFilterQueryRestApi;
import org.eclipse.hawkbit.mgmt.rest.api.MgmtTargetRestApi;
import org.eclipse.hawkbit.sdk.HawkbitClient;
import org.eclipse.hawkbit.sdk.Tenant;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.http.ResponseEntity;
import java.util.Collections;
import java.util.List;
/**
* MCP tools for hawkBit using the SDK.
* <p>
* Provides tools for managing targets, rollouts, distribution sets, actions,
* software modules, and target filter queries via the hawkBit REST API.
* </p>
*/
@Slf4j
@RequiredArgsConstructor
public class HawkbitMcpToolProvider {
private static final String OP_CREATE = "CREATE";
private static final String OP_UPDATE = "UPDATE";
private static final String OP_DELETE = "DELETE";
private static final String OP_DELETE_BATCH = "DELETE_BATCH";
private static final String OP_START = "START";
private static final String OP_PAUSE = "PAUSE";
private static final String OP_STOP = "STOP";
private static final String OP_RESUME = "RESUME";
private static final String OP_APPROVE = "APPROVE";
private static final String OP_DENY = "DENY";
private static final String OP_RETRY = "RETRY";
private static final String OP_TRIGGER_NEXT_GROUP = "TRIGGER_NEXT_GROUP";
private final HawkbitClient hawkbitClient;
private final Tenant dummyTenant;
private final HawkbitMcpProperties properties;
private <T> PagedResponse<T> toPagedResponse(final PagedList<T> pagedList, final ListRequest request) {
if (pagedList == null) {
return PagedResponse.of(
Collections.emptyList(),
0L,
request.getOffsetOrDefault(),
request.getLimitOrDefault());
}
return PagedResponse.of(
pagedList.getContent(),
pagedList.getTotal(),
request.getOffsetOrDefault(),
request.getLimitOrDefault());
}
@McpTool(name = "list_targets",
description = "Retrieves a paged list of targets (devices) with optional RSQL filtering. " +
"Targets represent devices that can receive software updates.")
public PagedResponse<MgmtTarget> listTargets(final ListRequest request) {
log.debug("Listing targets with rsql={}, offset={}, limit={}",
request.rsql(), request.getOffsetOrDefault(), request.getLimitOrDefault());
MgmtTargetRestApi targetApi = hawkbitClient.mgmtService(MgmtTargetRestApi.class, dummyTenant);
ResponseEntity<PagedList<MgmtTarget>> response = targetApi.getTargets(
request.getRsqlOrNull(),
request.getOffsetOrDefault(),
request.getLimitOrDefault(),
null);
return toPagedResponse(response.getBody(), request);
}
@McpTool(name = "list_rollouts",
description = "Retrieves a paged list of rollouts with optional RSQL filtering. " +
"Rollouts are used to deploy software to groups of targets.")
public PagedResponse<MgmtRolloutResponseBody> listRollouts(final ListRequest request) {
log.debug("Listing rollouts with rsql={}, offset={}, limit={}",
request.rsql(), request.getOffsetOrDefault(), request.getLimitOrDefault());
MgmtRolloutRestApi rolloutApi = hawkbitClient.mgmtService(MgmtRolloutRestApi.class, dummyTenant);
ResponseEntity<PagedList<MgmtRolloutResponseBody>> response = rolloutApi.getRollouts(
request.getRsqlOrNull(),
request.getOffsetOrDefault(),
request.getLimitOrDefault(),
null,
null);
return toPagedResponse(response.getBody(), request);
}
@McpTool(name = "list_distribution_sets",
description = "Retrieves a paged list of distribution sets with optional RSQL filtering. " +
"Distribution sets are software packages that can be deployed to targets.")
public PagedResponse<MgmtDistributionSet> listDistributionSets(final ListRequest request) {
log.debug("Listing distribution sets with rsql={}, offset={}, limit={}",
request.rsql(), request.getOffsetOrDefault(), request.getLimitOrDefault());
MgmtDistributionSetRestApi dsApi = hawkbitClient.mgmtService(MgmtDistributionSetRestApi.class, dummyTenant);
ResponseEntity<PagedList<MgmtDistributionSet>> response = dsApi.getDistributionSets(
request.getRsqlOrNull(),
request.getOffsetOrDefault(),
request.getLimitOrDefault(),
null);
return toPagedResponse(response.getBody(), request);
}
@McpTool(name = "list_actions",
description = "Retrieves a paged list of actions with optional RSQL filtering. " +
"Actions represent deployment operations assigned to targets.")
public PagedResponse<MgmtAction> listActions(final ListRequest request) {
log.debug("Listing actions with rsql={}, offset={}, limit={}",
request.rsql(), request.getOffsetOrDefault(), request.getLimitOrDefault());
MgmtActionRestApi actionApi = hawkbitClient.mgmtService(MgmtActionRestApi.class, dummyTenant);
ResponseEntity<PagedList<MgmtAction>> response = actionApi.getActions(
request.getRsqlOrNull(),
request.getOffsetOrDefault(),
request.getLimitOrDefault(),
null,
null);
return toPagedResponse(response.getBody(), request);
}
@McpTool(name = "list_software_modules",
description = "Retrieves a paged list of software modules with optional RSQL filtering. " +
"Software modules are individual software components within distribution sets.")
public PagedResponse<MgmtSoftwareModule> listSoftwareModules(final ListRequest request) {
log.debug("Listing software modules with rsql={}, offset={}, limit={}",
request.rsql(), request.getOffsetOrDefault(), request.getLimitOrDefault());
MgmtSoftwareModuleRestApi smApi = hawkbitClient.mgmtService(MgmtSoftwareModuleRestApi.class, dummyTenant);
ResponseEntity<PagedList<MgmtSoftwareModule>> response = smApi.getSoftwareModules(
request.getRsqlOrNull(),
request.getOffsetOrDefault(),
request.getLimitOrDefault(),
null);
return toPagedResponse(response.getBody(), request);
}
@McpTool(name = "list_target_filters",
description = "Retrieves a paged list of target filter queries with optional RSQL filtering. " +
"Target filters define RSQL queries to group targets for rollouts or auto-assignment.")
public PagedResponse<MgmtTargetFilterQuery> listTargetFilters(final ListRequest request) {
log.debug("Listing target filters with rsql={}, offset={}, limit={}",
request.rsql(), request.getOffsetOrDefault(), request.getLimitOrDefault());
MgmtTargetFilterQueryRestApi filterApi = hawkbitClient.mgmtService(MgmtTargetFilterQueryRestApi.class, dummyTenant);
ResponseEntity<PagedList<MgmtTargetFilterQuery>> response = filterApi.getFilters(
request.getRsqlOrNull(),
request.getOffsetOrDefault(),
request.getLimitOrDefault(),
null,
null);
return toPagedResponse(response.getBody(), request);
}
@McpTool(name = "manage_target",
description = "Create, update, or delete targets (devices). " +
"Operations: CREATE (new target with controllerId, name, description. When creating a target without a specific target type, set \"targetType\": null), " +
"UPDATE (modify existing target by controllerId), " +
"DELETE (remove target by controllerId). " +
"Use 'type' field to select operation: " +
"{\"type\":\"Create\",\"body\":{\"controllerId\":\"id\",\"name\":\"name\"}}, " +
"{\"type\":\"Update\",\"controllerId\":\"id\",\"body\":{...}}, " +
"{\"type\":\"Delete\",\"controllerId\":\"id\"}")
public OperationResponse<Object> manageTarget(final TargetRequest request) {
log.debug("Managing target: request={}", request.getClass().getSimpleName());
final MgmtTargetRestApi api = hawkbitClient.mgmtService(MgmtTargetRestApi.class, dummyTenant);
if (request instanceof TargetRequest.Create r) {
validateOperation("create", "targets");
if (r.body() == null) {
return OperationResponse.failure(OP_CREATE, "Request body is required for CREATE operation");
}
final ResponseEntity<List<MgmtTarget>> response = api.createTargets(List.of(r.body()));
final List<MgmtTarget> created = response.getBody();
return OperationResponse.success(OP_CREATE, "Target created successfully",
created != null && !created.isEmpty() ? created.get(0) : null);
} else if (request instanceof TargetRequest.Update r) {
validateOperation("update", "targets");
if (r.controllerId() == null || r.controllerId().isBlank()) {
return OperationResponse.failure(OP_UPDATE, "controllerId is required for UPDATE operation");
}
if (r.body() == null) {
return OperationResponse.failure(OP_UPDATE, "Request body is required for UPDATE operation");
}
final ResponseEntity<MgmtTarget> response = api.updateTarget(r.controllerId(), r.body());
return OperationResponse.success(OP_UPDATE, "Target updated successfully", response.getBody());
} else if (request instanceof TargetRequest.Delete r) {
validateOperation("delete", "targets");
if (r.controllerId() == null || r.controllerId().isBlank()) {
return OperationResponse.failure(OP_DELETE, "controllerId is required for DELETE operation");
}
api.deleteTarget(r.controllerId());
return OperationResponse.success(OP_DELETE, "Target deleted successfully");
}
throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName());
}
@McpTool(name = "manage_rollout",
description = "Create, update, delete, and control rollouts for software deployment. " +
"Use 'type' field to select operation. " +
"Types: Create, Update, Delete, Start, Pause, Stop, Resume, Approve, Deny, Retry, TriggerNextGroup. " +
"For Create: use rollout 'type' values: 'soft', 'forced', 'timeforced', 'downloadonly' (lowercase). " +
"If 'groups' list is provided, omit 'amountGroups' (they are mutually exclusive). " +
"Examples: {\"type\":\"Create\",\"body\":{...}}, " +
"{\"type\":\"Start\",\"rolloutId\":123}, " +
"{\"type\":\"Approve\",\"rolloutId\":123,\"remark\":\"approved\"}")
public OperationResponse<Object> manageRollout(final RolloutRequest request) {
log.debug("Managing rollout: request={}", request.getClass().getSimpleName());
final MgmtRolloutRestApi api = hawkbitClient.mgmtService(MgmtRolloutRestApi.class, dummyTenant);
if (request instanceof RolloutRequest.Create r) {
validateRolloutOperation("create");
if (r.body() == null) {
return OperationResponse.failure(OP_CREATE, "body is required for CREATE operation");
}
final ResponseEntity<MgmtRolloutResponseBody> response = api.create(r.body());
return OperationResponse.success(OP_CREATE, "Rollout created successfully", response.getBody());
} else if (request instanceof RolloutRequest.Update r) {
validateRolloutOperation("update");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_UPDATE, "rolloutId is required for UPDATE operation");
}
if (r.body() == null) {
return OperationResponse.failure(OP_UPDATE, "body is required for UPDATE operation");
}
final ResponseEntity<MgmtRolloutResponseBody> response = api.update(r.rolloutId(), r.body());
return OperationResponse.success(OP_UPDATE, "Rollout updated successfully", response.getBody());
} else if (request instanceof RolloutRequest.Delete r) {
validateRolloutOperation("delete");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_DELETE, "rolloutId is required for DELETE operation");
}
api.delete(r.rolloutId());
return OperationResponse.success(OP_DELETE, "Rollout deleted successfully");
} else if (request instanceof RolloutRequest.Start r) {
validateRolloutOperation("start");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_START, "rolloutId is required for START operation");
}
api.start(r.rolloutId());
return OperationResponse.success(OP_START, "Rollout started successfully");
} else if (request instanceof RolloutRequest.Pause r) {
validateRolloutOperation("pause");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_PAUSE, "rolloutId is required for PAUSE operation");
}
api.pause(r.rolloutId());
return OperationResponse.success(OP_PAUSE, "Rollout paused successfully");
} else if (request instanceof RolloutRequest.Stop r) {
validateRolloutOperation("stop");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_STOP, "rolloutId is required for STOP operation");
}
api.stop(r.rolloutId());
return OperationResponse.success(OP_STOP, "Rollout stopped successfully");
} else if (request instanceof RolloutRequest.Resume r) {
validateRolloutOperation("resume");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_RESUME, "rolloutId is required for RESUME operation");
}
api.resume(r.rolloutId());
return OperationResponse.success(OP_RESUME, "Rollout resumed successfully");
} else if (request instanceof RolloutRequest.Approve r) {
validateRolloutOperation("approve");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_APPROVE, "rolloutId is required for APPROVE operation");
}
api.approve(r.rolloutId(), r.remark());
return OperationResponse.success(OP_APPROVE, "Rollout approved successfully");
} else if (request instanceof RolloutRequest.Deny r) {
validateRolloutOperation("deny");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_DENY, "rolloutId is required for DENY operation");
}
api.deny(r.rolloutId(), r.remark());
return OperationResponse.success(OP_DENY, "Rollout denied successfully");
} else if (request instanceof RolloutRequest.Retry r) {
validateRolloutOperation("retry");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_RETRY, "rolloutId is required for RETRY operation");
}
final ResponseEntity<MgmtRolloutResponseBody> response = api.retryRollout(r.rolloutId());
return OperationResponse.success(OP_RETRY, "Rollout retry created successfully", response.getBody());
} else if (request instanceof RolloutRequest.TriggerNextGroup r) {
validateRolloutOperation("trigger-next-group");
if (r.rolloutId() == null) {
return OperationResponse.failure(OP_TRIGGER_NEXT_GROUP, "rolloutId is required for TRIGGER_NEXT_GROUP operation");
}
api.triggerNextGroup(r.rolloutId());
return OperationResponse.success(OP_TRIGGER_NEXT_GROUP, "Next rollout group triggered successfully");
}
throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName());
}
@McpTool(name = "manage_distribution_set",
description = "Create, update, or delete distribution sets (software packages). " +
"Use 'type' field to select operation: " +
"{\"type\":\"Create\",\"body\":{\"name\":\"n\",\"version\":\"v\",\"type\":\"t\"}}, " +
"{\"type\":\"Update\",\"distributionSetId\":123,\"body\":{...}}, " +
"{\"type\":\"Delete\",\"distributionSetId\":123}")
public OperationResponse<Object> manageDistributionSet(final DistributionSetRequest request) {
log.debug("Managing distribution set: request={}", request.getClass().getSimpleName());
final MgmtDistributionSetRestApi api = hawkbitClient.mgmtService(MgmtDistributionSetRestApi.class, dummyTenant);
if (request instanceof DistributionSetRequest.Create r) {
validateOperation("create", "distributionSets");
if (r.body() == null) {
return OperationResponse.failure(OP_CREATE, "body is required for CREATE operation");
}
final ResponseEntity<List<MgmtDistributionSet>> response = api.createDistributionSets(List.of(r.body()));
final List<MgmtDistributionSet> created = response.getBody();
return OperationResponse.success(OP_CREATE, "Distribution set created successfully",
created != null && !created.isEmpty() ? created.get(0) : null);
} else if (request instanceof DistributionSetRequest.Update r) {
validateOperation("update", "distributionSets");
if (r.distributionSetId() == null) {
return OperationResponse.failure(OP_UPDATE, "distributionSetId is required for UPDATE operation");
}
if (r.body() == null) {
return OperationResponse.failure(OP_UPDATE, "body is required for UPDATE operation");
}
final ResponseEntity<MgmtDistributionSet> response = api.updateDistributionSet(r.distributionSetId(), r.body());
return OperationResponse.success(OP_UPDATE, "Distribution set updated successfully", response.getBody());
} else if (request instanceof DistributionSetRequest.Delete r) {
validateOperation("delete", "distributionSets");
if (r.distributionSetId() == null) {
return OperationResponse.failure(OP_DELETE, "distributionSetId is required for DELETE operation");
}
api.deleteDistributionSet(r.distributionSetId());
return OperationResponse.success(OP_DELETE, "Distribution set deleted successfully");
}
throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName());
}
@McpTool(name = "manage_action",
description = "Delete deployment actions. Actions are created indirectly via distribution set assignment. " +
"Use 'type' field to select operation: " +
"{\"type\":\"Delete\",\"actionIds\":[123]}, " +
"{\"type\":\"DeleteBatch\",\"actionIds\":[1,2,3],\"rsql\":\"\"}")
public OperationResponse<Object> manageAction(final ActionRequest request) {
log.debug("Managing action: request={}", request.getClass().getSimpleName());
final MgmtActionRestApi api = hawkbitClient.mgmtService(MgmtActionRestApi.class, dummyTenant);
if (request instanceof ActionRequest.Delete r) {
validateActionOperation("delete");
if (r.actionId() == null) {
return OperationResponse.failure(OP_DELETE, "actionId is required for DELETE operation");
}
api.deleteAction(r.actionId());
return OperationResponse.success(OP_DELETE, "Action deleted successfully");
} else if (request instanceof ActionRequest.DeleteBatch r) {
validateActionOperation("delete-batch");
if ((r.actionIds() == null || r.actionIds().isEmpty()) &&
(r.rsql() == null || r.rsql().isBlank())) {
return OperationResponse.failure(OP_DELETE_BATCH, "Either actionIds or rsql is required for DELETE_BATCH operation");
}
api.deleteActions(r.rsql(), r.actionIds());
return OperationResponse.success(OP_DELETE_BATCH, "Actions deleted successfully");
}
throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName());
}
@McpTool(name = "manage_software_module",
description = "Create, update, or delete software modules. " +
"Use 'type' field to select operation: " +
"{\"type\":\"Create\",\"body\":{\"name\":\"n\",\"version\":\"v\",\"type\":\"t\"}}, " +
"{\"type\":\"Update\",\"softwareModuleId\":123,\"body\":{...}}, " +
"{\"type\":\"Delete\",\"softwareModuleId\":123}")
public OperationResponse<Object> manageSoftwareModule(final SoftwareModuleRequest request) {
log.debug("Managing software module: request={}", request.getClass().getSimpleName());
final MgmtSoftwareModuleRestApi api = hawkbitClient.mgmtService(MgmtSoftwareModuleRestApi.class, dummyTenant);
if (request instanceof SoftwareModuleRequest.Create r) {
validateOperation("create", "softwareModules");
if (r.body() == null) {
return OperationResponse.failure(OP_CREATE, "body is required for CREATE operation");
}
final ResponseEntity<List<MgmtSoftwareModule>> response = api.createSoftwareModules(List.of(r.body()));
final List<MgmtSoftwareModule> created = response.getBody();
return OperationResponse.success(OP_CREATE, "Software module created successfully",
created != null && !created.isEmpty() ? created.get(0) : null);
} else if (request instanceof SoftwareModuleRequest.Update r) {
validateOperation("update", "softwareModules");
if (r.softwareModuleId() == null) {
return OperationResponse.failure(OP_UPDATE, "softwareModuleId is required for UPDATE operation");
}
if (r.body() == null) {
return OperationResponse.failure(OP_UPDATE, "body is required for UPDATE operation");
}
final ResponseEntity<MgmtSoftwareModule> response = api.updateSoftwareModule(r.softwareModuleId(), r.body());
return OperationResponse.success(OP_UPDATE, "Software module updated successfully", response.getBody());
} else if (request instanceof SoftwareModuleRequest.Delete r) {
validateOperation("delete", "softwareModules");
if (r.softwareModuleId() == null) {
return OperationResponse.failure(OP_DELETE, "softwareModuleId is required for DELETE operation");
}
api.deleteSoftwareModule(r.softwareModuleId());
return OperationResponse.success(OP_DELETE, "Software module deleted successfully");
}
throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName());
}
@McpTool(name = "manage_target_filter",
description = "Create, update, or delete target filter queries. " +
"Use 'type' field to select operation: " +
"{\"type\":\"Create\",\"body\":{\"name\":\"n\",\"query\":\"name==*\"}}, " +
"{\"type\":\"Update\",\"filterId\":123,\"body\":{...}}, " +
"{\"type\":\"Delete\",\"filterId\":123}")
public OperationResponse<Object> manageTargetFilter(final TargetFilterRequest request) {
log.debug("Managing target filter: request={}", request.getClass().getSimpleName());
final MgmtTargetFilterQueryRestApi api = hawkbitClient.mgmtService(MgmtTargetFilterQueryRestApi.class, dummyTenant);
if (request instanceof TargetFilterRequest.Create r) {
validateOperation("create", "targetFilters");
if (r.body() == null) {
return OperationResponse.failure(OP_CREATE, "body is required for CREATE operation");
}
final ResponseEntity<MgmtTargetFilterQuery> response = api.createFilter(r.body());
return OperationResponse.success(OP_CREATE, "Target filter created successfully", response.getBody());
} else if (request instanceof TargetFilterRequest.Update r) {
validateOperation("update", "targetFilters");
if (r.filterId() == null) {
return OperationResponse.failure(OP_UPDATE, "filterId is required for UPDATE operation");
}
if (r.body() == null) {
return OperationResponse.failure(OP_UPDATE, "body is required for UPDATE operation");
}
final ResponseEntity<MgmtTargetFilterQuery> response = api.updateFilter(r.filterId(), r.body());
return OperationResponse.success(OP_UPDATE, "Target filter updated successfully", response.getBody());
} else if (request instanceof TargetFilterRequest.Delete r) {
validateOperation("delete", "targetFilters");
if (r.filterId() == null) {
return OperationResponse.failure(OP_DELETE, "filterId is required for DELETE operation");
}
api.deleteFilter(r.filterId());
return OperationResponse.success(OP_DELETE, "Target filter deleted successfully");
}
throw new IllegalArgumentException("Unknown request type: " + request.getClass().getSimpleName());
}
private void validateOperation(final String operation, final String entity) {
if (!isOperationEnabled(operation, entity)) {
throw new IllegalArgumentException(
"Operation " + operation.toUpperCase() + " is not enabled for " + entity +
". Check hawkbit.mcp.operations configuration.");
}
}
private void validateRolloutOperation(final String operation) {
final HawkbitMcpProperties.RolloutConfig config = properties.getOperations().getRollouts();
final Boolean entitySetting = config.getOperationEnabled(operation);
// For standard CRUD ops, check global fallback
if (entitySetting == null) {
if (!properties.getOperations().isGlobalOperationEnabled(operation)) {
throw new IllegalArgumentException(
"Operation " + operation.toUpperCase() + " is not enabled for rollouts. " +
"Check hawkbit.mcp.operations configuration.");
}
return;
}
if (!entitySetting) {
throw new IllegalArgumentException(
"Operation " + operation.toUpperCase() + " is not enabled for rollouts. " +
"Check hawkbit.mcp.operations configuration.");
}
}
private void validateActionOperation(final String operation) {
final HawkbitMcpProperties.ActionConfig config = properties.getOperations().getActions();
final Boolean entitySetting = config.getOperationEnabled(operation);
if (entitySetting == null) {
if (operation.equals("delete") && !properties.getOperations().isGlobalOperationEnabled("delete")) {
throw new IllegalArgumentException(
"Operation " + operation.toUpperCase() + " is not enabled for actions. " +
"Check hawkbit.mcp.operations configuration.");
}
return;
}
if (!entitySetting) {
throw new IllegalArgumentException(
"Operation " + operation.toUpperCase() + " is not enabled for actions. " +
"Check hawkbit.mcp.operations configuration.");
}
}
private boolean isOperationEnabled(final String operation, final String entity) {
final HawkbitMcpProperties.Operations ops = properties.getOperations();
final HawkbitMcpProperties.EntityConfig entityConfig = getEntityConfig(entity);
final Boolean entitySetting = entityConfig != null ? entityConfig.getOperationEnabled(operation) : null;
if (entitySetting != null) {
return entitySetting;
}
return ops.isGlobalOperationEnabled(operation);
}
private HawkbitMcpProperties.EntityConfig getEntityConfig(final String entity) {
final HawkbitMcpProperties.Operations ops = properties.getOperations();
return switch (entity.toLowerCase()) {
case "targets" -> ops.getTargets();
case "rollouts" -> ops.getRollouts();
case "distributionsets" -> ops.getDistributionSets();
case "softwaremodules" -> ops.getSoftwareModules();
case "targetfilters" -> ops.getTargetFilters();
default -> null;
};
}
}

View File

@@ -0,0 +1,54 @@
#
# Copyright (c) 2026 Contributors to the Eclipse Foundation
#
# This program and the accompanying materials are made
# available under the terms of the Eclipse Public License 2.0
# which is available at https://www.eclipse.org/legal/epl-2.0/
#
# SPDX-License-Identifier: EPL-2.0
#
# Server configuration
server.port=8081
# Jackson configuration - accept both uppercase and lowercase enum values
# This allows LLMs to use either "FORCED" or "forced" for enum fields like MgmtActionType
spring.jackson.mapper.accept-case-insensitive-enums=true
# Spring application name
spring.application.name=hawkbit-mcp-server
# Spring AI MCP Server configuration
spring.ai.mcp.server.enabled=true
spring.ai.mcp.server.name=hawkbit-mcp-server
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.type=SYNC
spring.ai.mcp.server.protocol=STREAMABLE
# Change from HTTP to STDIO
#spring.ai.mcp.server.stdio=true
#spring.ai.mcp.server.protocol=STDIO
spring.ai.mcp.server.capabilities.prompt=true
# hawkBit connection configuration
hawkbit.mcp.mgmt-url=${HAWKBIT_URL:http://localhost:8080}
# Authentication validation configuration
hawkbit.mcp.validation.cache-ttl=600s
hawkbit.mcp.validation.cache-max-size=1000
# Logging configuration
logging.level.org.eclipse.hawkbit.mcp=DEBUG
logging.level.org.springframework.ai.mcp=DEBUG
# Global: disable all deletes by default
#hawkbit.mcp.operations.delete-enabled=false
# But allow delete for targets specifically
#hawkbit.mcp.operations.targets.delete-enabled=true
# Disable rollout lifecycle operations
#hawkbit.mcp.operations.rollouts.start-enabled=false
#hawkbit.mcp.operations.rollouts.approve-enabled=false
# Disable software modules delete operations
#hawkbit.mcp.operations.software-modules.delete-enabled=false

View File

@@ -0,0 +1,351 @@
# hawkBit Entity Definitions and RSQL Filtering Guide
This document describes the entities available in hawkBit and how to filter and sort them using RSQL queries through the MCP tools.
## RSQL Query Syntax
RSQL (RESTful Service Query Language) is a query language for filtering and searching entities. It uses a simple, URL-friendly syntax.
### Comparison Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `==` | Equal to | `name==MyTarget` |
| `!=` | Not equal to | `status!=ERROR` |
| `=lt=` or `<` | Less than | `createdAt=lt=1609459200000` |
| `=le=` or `<=` | Less than or equal | `weight=le=500` |
| `=gt=` or `>` | Greater than | `lastTargetQuery=gt=1609459200000` |
| `=ge=` or `>=` | Greater than or equal | `id=ge=100` |
| `=in=` | In list | `status=in=(RUNNING,FINISHED)` |
| `=out=` | Not in list | `updateStatus=out=(ERROR,UNKNOWN)` |
### Logical Operators
| Operator | Description | Example |
|----------|-------------|---------|
| `;` or `and` | Logical AND | `name==Test*;status==RUNNING` |
| `,` or `or` | Logical OR | `status==ERROR,status==CANCELED` |
Always use "and" or "or" for operators when grouping conditions - since this is the human-readable format.
### Wildcard Support
Use `*` as a wildcard character for pattern matching:
- `name==Device*` - Names starting with "Device"
- `name==*Controller` - Names ending with "Controller"
- `name==*test*` - Names containing "test"
### Sub-Entity Filtering
Access nested entity fields using dot notation:
- `assignedDistributionSet.name==MyDS`
- `target.controllerId==device123`
- `type.key==os`
### Map/Metadata Filtering
For metadata and attributes, use dot notation with the key:
- `metadata.environment==production`
- `controllerAttributes.revision==1.5`
---
## Entity Definitions
### Target
Targets represent devices or software instances that can receive software updates.
**Filterable/Sortable Fields:**
| Field | Description | Type |
|------------------------------------|----------------------------------------------------------------------|------|
| `controllerId` | Unique identifier of the target | String |
| `name` | Display name | String |
| `description` | Description text | String |
| `updateStatus` | Current update status (UNKNOWN, IN_SYNC, PENDING, ERROR, REGISTERED) | Enum |
| `address` | IP address or URI | String |
| `lastTargetQuery` | Last time the target polled (timestamp in ms) | Long |
| `createdAt` | Creation timestamp | Long |
| `createdBy` | Creator username | String |
| `lastModifiedAt` | Last modification timestamp | Long |
| `lastModifiedBy` | Last modifier username | String |
| `assignedDistributionSet.name` | Name of assigned distribution set | String |
| `assignedDistributionSet.version` | Version of assigned distribution set | String |
| `installedDistributionSet.name` | Name of installed distribution set | String |
| `installedDistributionSet.version` | Version of installed distribution set | String |
| `targetType.key` | Target type key | String |
| `targetType.name` | Target type name | String |
| `tags.name` | Tag name | String |
| `group` | Group name | String |
| `metadata.<key>` | Metadata value by key | String |
| `controllerAttributes.<key>` | Controller attribute by key | String |
**Example Queries:**
```
# Find targets with update errors
updateStatus==ERROR
# Find targets by name pattern
name==device-*
# Find targets with specific distribution set assigned
assignedDistributionSet.name==Firmware;assignedDistributionSet.version==2.0.0
# Find targets that haven't polled in 24 hours (timestamp example)
lastTargetQuery=lt=1704067200000
# Find targets by tag
tags.name==production
# Find targets by metadata
metadata.location==factory-A
# Find targets by controller attribute
controllerAttributes.firmware_version==1.2.3
```
---
### Distribution Set
Distribution Sets are collections of software modules that can be deployed to targets.
**Filterable/Sortable Fields:**
| Field | Description | Type |
|-------|-------------|------|
| `id` | Unique identifier | Long |
| `name` | Distribution set name | String |
| `version` | Version string | String |
| `description` | Description text | String |
| `type.key` | Distribution set type key | String |
| `type.name` | Distribution set type name | String |
| `valid` | Whether the DS is valid for deployment | Boolean |
| `createdAt` | Creation timestamp | Long |
| `createdBy` | Creator username | String |
| `lastModifiedAt` | Last modification timestamp | Long |
| `lastModifiedBy` | Last modifier username | String |
| `tags.name` | Tag name | String |
| `modules.name` | Software module name | String |
| `metadata.<key>` | Metadata value by key | String |
**Example Queries:**
```
# Find distribution sets by name
name==Firmware*
# Find valid distribution sets only
valid==true
# Find by type
type.key==os_app
# Find by tag
tags.name==release-candidate
# Find distribution sets containing a specific module
modules.name==bootloader
```
---
### Rollout
Rollouts are used to deploy software to groups of targets in a controlled manner.
**Filterable/Sortable Fields:**
| Field | Description | Type |
|-------|-------------|------|
| `id` | Unique identifier | Long |
| `name` | Rollout name | String |
| `description` | Description text | String |
| `status` | Rollout status (CREATING, READY, PAUSED, STARTING, RUNNING, FINISHED, etc.) | Enum |
| `distributionSet.id` | Distribution set ID | Long |
| `distributionSet.name` | Distribution set name | String |
| `distributionSet.version` | Distribution set version | String |
| `distributionSet.type` | Distribution set type | String |
| `createdAt` | Creation timestamp | Long |
| `createdBy` | Creator username | String |
| `lastModifiedAt` | Last modification timestamp | Long |
| `lastModifiedBy` | Last modifier username | String |
**Example Queries:**
```
# Find running rollouts
status==RUNNING
# Find rollouts by name
name==Campaign*
# Find rollouts for a specific distribution set
distributionSet.name==Firmware;distributionSet.version==2.0.0
# Find finished or paused rollouts
status=in=(FINISHED,PAUSED)
```
---
### Action
Actions represent deployment operations assigned to targets.
**Filterable/Sortable Fields:**
| Field | Description | Type |
|-------|-------------|------|
| `id` | Unique identifier | Long |
| `status` | Action status (SCHEDULED, RUNNING, FINISHED, ERROR, CANCELED, etc.) | Enum |
| `active` | Whether the action is currently active | Boolean |
| `weight` | Priority weight (0-1000) | Integer |
| `lastActionStatusCode` | Last status code reported | Integer |
| `externalRef` | External reference string | String |
| `target.controllerId` | Target controller ID | String |
| `target.name` | Target name | String |
| `target.updateStatus` | Target update status | Enum |
| `distributionSet.id` | Distribution set ID | Long |
| `distributionSet.name` | Distribution set name | String |
| `distributionSet.version` | Distribution set version | String |
| `rollout.id` | Rollout ID | Long |
| `rollout.name` | Rollout name | String |
| `rolloutGroup.id` | Rollout group ID | Long |
| `rolloutGroup.name` | Rollout group name | String |
| `createdAt` | Creation timestamp | Long |
| `createdBy` | Creator username | String |
| `lastModifiedAt` | Last modification timestamp | Long |
| `lastModifiedBy` | Last modifier username | String |
**Example Queries:**
```
# Find active actions
active==true
# Find actions by status
status==RUNNING
# Find failed actions
status==ERROR
# Find actions for a specific target
target.controllerId==device-001
# Find actions for a specific rollout
rollout.name==Campaign2024
# Find high-priority actions
weight=gt=800
# Find actions with specific status code
lastActionStatusCode==200
```
---
### Software Module
Software Modules are individual software components that make up distribution sets.
**Filterable/Sortable Fields:**
| Field | Description | Type |
|-------|-------------|------|
| `id` | Unique identifier | Long |
| `name` | Module name | String |
| `version` | Version string | String |
| `description` | Description text | String |
| `type.key` | Software module type key | String |
| `type.name` | Software module type name | String |
| `createdAt` | Creation timestamp | Long |
| `createdBy` | Creator username | String |
| `lastModifiedAt` | Last modification timestamp | Long |
| `lastModifiedBy` | Last modifier username | String |
| `metadata.<key>` | Metadata value by key | String |
**Example Queries:**
```
# Find modules by name
name==bootloader*
# Find modules by type
type.key==os
# Find modules by version
version==2.0.*
# Find modules with specific metadata
metadata.checksum==abc123
```
---
### Target Filter Query
Target Filter Queries define RSQL filters for grouping targets, used for rollouts and auto-assignment.
**Filterable/Sortable Fields:**
| Field | Description | Type |
|-------|-------------|------|
| `id` | Unique identifier | Long |
| `name` | Filter name | String |
| `autoAssignDistributionSet.name` | Auto-assign DS name | String |
| `autoAssignDistributionSet.version` | Auto-assign DS version | String |
| `createdAt` | Creation timestamp | Long |
| `createdBy` | Creator username | String |
| `lastModifiedAt` | Last modification timestamp | Long |
| `lastModifiedBy` | Last modifier username | String |
**Example Queries:**
```
# Find filters by name
name==Production*
# Find filters with auto-assignment configured
autoAssignDistributionSet.name==*
# Find filters for a specific auto-assign distribution set
autoAssignDistributionSet.name==Firmware;autoAssignDistributionSet.version==2.0.0
```
---
## Common Query Patterns
### Combining Multiple Conditions (AND)
```
status==RUNNING;createdAt=gt=1704067200000
```
### Alternative Conditions (OR)
```
status==ERROR,status==CANCELED
```
### Complex Queries with Grouping
```
(status==RUNNING,status==SCHEDULED);target.updateStatus!=ERROR
```
### Timestamp Filtering
Timestamps are in milliseconds since Unix epoch:
```
# Created after January 1, 2024
createdAt=gt=1704067200000
# Modified in the last 24 hours (example timestamp)
lastModifiedAt=gt=1704153600000
```
### Wildcard Patterns
```
# Starts with
name==prefix*
# Ends with
name==*suffix
# Contains
name==*substring*
```

View File

@@ -0,0 +1,48 @@
# hawkBit MCP Server - Getting Started
You are connected to the **Eclipse hawkBit MCP Server**. hawkBit is a domain-independent
back-end framework for rolling out software updates to IoT devices.
## What You Can Do
### Tools Available
You have access to tools for querying the hawkBit Management API:
- `list_targets` - Query devices that can receive software updates
- `list_distribution_sets` - Query software packages for deployment
- `list_rollouts` - Query rollout campaigns for mass deployments
- `list_actions` - Query deployment operations assigned to targets
- `list_software_modules` - Query individual software components
- `list_target_filters` - Query RSQL filters for grouping targets
All tools support RSQL filtering. Read the "hawkBit Entity Definitions" resource for query syntax.
### Documentation Resources
The following documentation is available (read with MCP resources):
**Getting Started:**
- `hawkbit://docs/overview` - High-level introduction
- `hawkbit://docs/what-is-hawkbit` - Why hawkBit exists
- `hawkbit://docs/features` - Feature overview
- `hawkbit://docs/architecture` - System architecture
**Core Concepts:**
- `hawkbit://docs/datamodel` - Entity relationships (targets, distribution sets, modules)
- `hawkbit://docs/rollout-management` - How rollouts work
- `hawkbit://docs/target-state` - Target state machine
- `hawkbit://docs/authentication` - Security and authentication
- `hawkbit://docs/authorization` - Permissions and access control
**APIs:**
- `hawkbit://docs/management-api` - REST API for management
- `hawkbit://docs/ddi-api` - Device polling API
- `hawkbit://docs/dmf-api` - AMQP-based device federation
**Reference:**
- `hawkbit://docs/entity-definitions` - RSQL filtering syntax and examples
## Recommended First Steps
1. **For general questions about hawkBit**: Read `hawkbit://docs/overview` or `hawkbit://docs/features`
2. **For data model questions**: Read `hawkbit://docs/datamodel`
3. **For RSQL query help**: Read `hawkbit://docs/entity-definitions`
4. **For rollout/deployment questions**: Read `hawkbit://docs/rollout-management`

View File

@@ -0,0 +1,51 @@
# RSQL Query Syntax for hawkBit
RSQL is a query language for filtering entities. Use it with the `rsql` parameter in list tools.
## Operators
| Operator | Meaning | Example |
|----------|---------|---------|
| `==` | Equal | `name==MyTarget` |
| `!=` | Not equal | `status!=ERROR` |
| `=lt=` | Less than | `createdAt=lt=1704067200000` |
| `=gt=` | Greater than | `lastTargetQuery=gt=1704067200000` |
| `=in=` | In list | `status=in=(RUNNING,FINISHED)` |
| `=out=` | Not in list | `updateStatus=out=(ERROR,UNKNOWN)` |
## Combining Conditions
- **AND**: Use `;``status==RUNNING;name==Device*`
- **OR**: Use `,``status==ERROR,status==CANCELED`
## Wildcards
Use `*` for pattern matching:
- `name==Device*` - Starts with "Device"
- `name==*Controller` - Ends with "Controller"
- `name==*test*` - Contains "test"
## Nested Fields
Access related entities with dot notation:
- `assignedDistributionSet.name==Firmware`
- `target.controllerId==device-001`
- `metadata.environment==production`
## Common Queries
```
# Targets with errors
updateStatus==ERROR
# Running rollouts
status==RUNNING
# Actions for a specific target
target.controllerId==device-001
# Distribution sets by type
type.key==os_app
```
For complete field reference, read `hawkbit://docs/entity-definitions`.

11
pom.xml
View File

@@ -77,6 +77,8 @@
<commons-io.version>2.21.0</commons-io.version>
<commons-collections4.version>4.5.0</commons-collections4.version>
<io-protostuff.version>1.8.0</io-protostuff.version>
<!-- Spring AI for MCP support -->
<spring-ai.version>1.1.2</spring-ai.version>
<!-- test -->
<rabbitmq.http-client.version>5.4.0</rabbitmq.http-client.version>
<classgraph.version>4.8.184</classgraph.version>
@@ -241,6 +243,14 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring AI for MCP Server -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -728,6 +738,7 @@
<module>hawkbit-mgmt</module>
<module>hawkbit-ddi</module>
<module>hawkbit-dmf</module>
<module>hawkbit-mcp</module>
<module>hawkbit-monolith</module>
<module>hawkbit-ui</module>