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

@@ -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`.