From b0d48e08d0b507924f7bf1cd6c9cd45c331b63e2 Mon Sep 17 00:00:00 2001 From: Denislav Prinov Date: Mon, 12 Jan 2026 16:47:35 +0200 Subject: [PATCH] hawkBit MCP server Signed-off-by: Denislav Prinov --- hawkbit-mcp/Dockerfile | 25 + hawkbit-mcp/pom.xml | 130 ++++ .../server/HawkBitMcpServerApplication.java | 32 + .../HawkBitAuthenticationValidator.java | 157 +++++ .../client/McpAuthenticationException.java | 17 + .../config/HawkBitClientConfiguration.java | 89 +++ .../server/config/HawkBitMcpProperties.java | 200 +++++++ .../config/McpSecurityConfiguration.java | 122 ++++ .../server/config/McpToolConfiguration.java | 83 +++ .../mcp/server/dto/ActionOperation.java | 18 + .../hawkbit/mcp/server/dto/ListRequest.java | 42 ++ .../mcp/server/dto/ManageActionRequest.java | 28 + .../dto/ManageDistributionSetRequest.java | 30 + .../mcp/server/dto/ManageRolloutRequest.java | 32 + .../dto/ManageSoftwareModuleRequest.java | 30 + .../server/dto/ManageTargetFilterRequest.java | 26 + .../mcp/server/dto/ManageTargetRequest.java | 26 + .../hawkbit/mcp/server/dto/Operation.java | 19 + .../mcp/server/dto/OperationResponse.java | 55 ++ .../hawkbit/mcp/server/dto/PagedResponse.java | 29 + .../mcp/server/dto/RolloutOperation.java | 27 + .../server/prompts/HawkBitPromptProvider.java | 67 +++ .../HawkBitDocumentationResource.java | 201 +++++++ .../server/tools/HawkBitMcpToolProvider.java | 553 ++++++++++++++++++ .../src/main/resources/application.properties | 51 ++ .../resources/hawkbit-entity-definitions.md | 351 +++++++++++ .../main/resources/prompts/hawkbit-context.md | 48 ++ .../src/main/resources/prompts/rsql-help.md | 51 ++ .../src/main/resources/application.properties | 1 + pom.xml | 11 + 30 files changed, 2551 insertions(+) create mode 100644 hawkbit-mcp/Dockerfile create mode 100644 hawkbit-mcp/pom.xml create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/HawkBitMcpServerApplication.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/HawkBitAuthenticationValidator.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/McpAuthenticationException.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkBitClientConfiguration.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkBitMcpProperties.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpSecurityConfiguration.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpToolConfiguration.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ActionOperation.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ListRequest.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageActionRequest.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageDistributionSetRequest.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageRolloutRequest.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageSoftwareModuleRequest.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageTargetFilterRequest.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageTargetRequest.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/Operation.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/OperationResponse.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/PagedResponse.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/RolloutOperation.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/prompts/HawkBitPromptProvider.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/resources/HawkBitDocumentationResource.java create mode 100644 hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/tools/HawkBitMcpToolProvider.java create mode 100644 hawkbit-mcp/src/main/resources/application.properties create mode 100644 hawkbit-mcp/src/main/resources/hawkbit-entity-definitions.md create mode 100644 hawkbit-mcp/src/main/resources/prompts/hawkbit-context.md create mode 100644 hawkbit-mcp/src/main/resources/prompts/rsql-help.md diff --git a/hawkbit-mcp/Dockerfile b/hawkbit-mcp/Dockerfile new file mode 100644 index 0000000000..b25b5672a0 --- /dev/null +++ b/hawkbit-mcp/Dockerfile @@ -0,0 +1,25 @@ +# +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# + +FROM eclipse-temurin:17-jre-alpine + +LABEL maintainer="Eclipse hawkBit Project" +LABEL org.opencontainers.image.source="https://github.com/eclipse-hawkbit/hawkbit" +LABEL org.opencontainers.image.description="Standalone MCP Server for hawkBit" + +ARG JAR_FILE=target/*.jar + +COPY ${JAR_FILE} app.jar + +EXPOSE 8081 + +ENV HAWKBIT_URL=http://localhost:8080 + +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/hawkbit-mcp/pom.xml b/hawkbit-mcp/pom.xml new file mode 100644 index 0000000000..db8048aa65 --- /dev/null +++ b/hawkbit-mcp/pom.xml @@ -0,0 +1,130 @@ + + + 4.0.0 + + org.eclipse.hawkbit + hawkbit-parent + ${revision} + + + hawkbit-mcp-server + hawkBit :: MCP Server (Standalone) + Standalone MCP server that connects to hawkBit via REST API + + + + org.eclipse.hawkbit + hawkbit-sdk-mgmt + ${project.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + org.springframework.boot + spring-boot-starter-hateoas + + + + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + org.springframework.ai + spring-ai-mcp-annotations + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + com.github.ben-manes.caffeine + caffeine + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-hawkbit-docs + generate-resources + + copy-resources + + + ${project.build.outputDirectory}/hawkbit-docs + + + ${project.basedir}/../docs + + README.md + what-is-hawkbit.md + quick-start.md + features.md + architecture.md + base-setup.md + hawkbit-sdk.md + feign-client.md + clustering.md + authentication.md + authorization.md + datamodel.md + rollout-management.md + targetstate.md + management-api.md + direct-device-integration-api.md + device-management-federation-api.md + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.eclipse.hawkbit.mcp.server.HawkBitMcpServerApplication + + + + + repackage + + + + + + + diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/HawkBitMcpServerApplication.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/HawkBitMcpServerApplication.java new file mode 100644 index 0000000000..f7d4073635 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/HawkBitMcpServerApplication.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +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. + *

+ * This server acts as a proxy between MCP clients and hawkBit, + * passing through authentication credentials to the hawkBit REST API. + *

+ */ +@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) +@EnableConfigurationProperties(HawkBitMcpProperties.class) +public class HawkBitMcpServerApplication { + + public static void main(String[] args) { + SpringApplication.run(HawkBitMcpServerApplication.class, args); + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/HawkBitAuthenticationValidator.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/HawkBitAuthenticationValidator.java new file mode 100644 index 0000000000..f73d129c7b --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/HawkBitAuthenticationValidator.java @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +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.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. + */ +@Slf4j +@Component +public class HawkBitAuthenticationValidator { + + private final HawkbitClient hawkbitClient; + private final Tenant dummyTenant; + private final Cache validationCache; + private final boolean enabled; + + public HawkBitAuthenticationValidator(HawkbitClient hawkbitClient, + Tenant dummyTenant, + HawkBitMcpProperties properties) { + this.hawkbitClient = hawkbitClient; + this.dummyTenant = dummyTenant; + this.enabled = properties.getValidation().isEnabled(); + + this.validationCache = Caffeine.newBuilder() + .expireAfterWrite(properties.getValidation().getCacheTtl()) + .maximumSize(properties.getValidation().getCacheMaxSize()) + .build(); + + log.info("Authentication validation {} with cache TTL={}, maxSize={}", + enabled ? "enabled" : "disabled", + properties.getValidation().getCacheTtl(), + properties.getValidation().getCacheMaxSize()); + } + + /** + * Validates the given authorization header against hawkBit. + * @param authHeader the Authorization header value + * @return validation result + */ + public ValidationResult validate(String authHeader) { + if (!enabled) { + return ValidationResult.VALID; + } + + 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(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(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()); + } + } + + /** + * Result of authentication validation. + */ + public 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 + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/McpAuthenticationException.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/McpAuthenticationException.java new file mode 100644 index 0000000000..8fd631bfd7 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/client/McpAuthenticationException.java @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.client; + +public class McpAuthenticationException extends RuntimeException { + + public McpAuthenticationException(String message) { + super(message); + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkBitClientConfiguration.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkBitClientConfiguration.java new file mode 100644 index 0000000000..b334743fff --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkBitClientConfiguration.java @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.config; + +import feign.Contract; +import feign.RequestInterceptor; +import feign.codec.Decoder; +import feign.codec.Encoder; +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 org.springframework.http.HttpHeaders; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; + +import java.util.function.BiFunction; + +/** + * Configuration for the hawkBit SDK client. + *

+ * Uses a custom request interceptor to inject authentication from the + * current HTTP request context, enabling per-request credentials. + *

+ */ +@Slf4j +@Configuration +public class HawkBitClientConfiguration { + + @Bean + @Primary + public HawkbitServer hawkbitServer(final HawkBitMcpProperties properties) { + 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) { + return HawkbitClient.builder() + .hawkBitServer(server) + .encoder(encoder) + .decoder(decoder) + .contract(contract) + .requestInterceptorFn(requestContextInterceptor()) + .build(); + } + + private BiFunction requestContextInterceptor() { + return (tenant, controller) -> template -> { + RequestAttributes attrs = RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + String authHeader = (String) attrs.getAttribute( + McpSecurityConfiguration.AUTH_HEADER_ATTRIBUTE, + RequestAttributes.SCOPE_REQUEST); + if (authHeader != null) { + template.header(HttpHeaders.AUTHORIZATION, authHeader); + log.trace("Injected auth header from request context"); + } + } + }; + } + + /** + * Dummy tenant bean - actual authentication comes from request context via interceptor. + */ + @Bean + public Tenant dummyTenant() { + Tenant tenant = new Tenant(); + tenant.setUsername(null); + tenant.setPassword(null); + return tenant; + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkBitMcpProperties.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkBitMcpProperties.java new file mode 100644 index 0000000000..5b99065175 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/HawkBitMcpProperties.java @@ -0,0 +1,200 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.config; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +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., ...). + */ + @NotBlank(message = "hawkbit.mcp.mgmt-url must be configured") + private String mgmtUrl; + + /** + * 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 + 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; + }; + } + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpSecurityConfiguration.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpSecurityConfiguration.java new file mode 100644 index 0000000000..7af2bf583a --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpSecurityConfiguration.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +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.HawkBitAuthenticationValidator; +import org.eclipse.hawkbit.mcp.server.client.HawkBitAuthenticationValidator.ValidationResult; +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; + +/** + * Security configuration for the MCP server. + */ +@Slf4j +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class McpSecurityConfiguration { + + /** + * Request attribute key for storing the Authorization header. + */ + public static final String AUTH_HEADER_ATTRIBUTE = "hawkbit.mcp.auth.header"; + + private final HawkBitAuthenticationValidator 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)) + .addFilterBefore(new HawkBitAuthenticationFilter(authenticationValidator), + UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * Filter that validates authentication. + */ + @Slf4j + @RequiredArgsConstructor + public static class HawkBitAuthenticationFilter extends OncePerRequestFilter { + + private final HawkBitAuthenticationValidator validator; + + @Override + protected void doFilterInternal(final HttpServletRequest request, final @NonNull HttpServletResponse response, + final @NonNull FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authHeader != null) { + request.setAttribute(AUTH_HEADER_ATTRIBUTE, authHeader); + } + + ValidationResult result = validator.validate(authHeader); + + switch (result) { + case VALID: + filterChain.doFilter(request, response); + break; + + case MISSING_CREDENTIALS: + log.debug("Rejecting request: missing credentials"); + sendErrorResponse(response, HttpStatus.UNAUTHORIZED, + "Authentication required. Please provide hawkBit credentials."); + break; + + case INVALID_CREDENTIALS: + log.debug("Rejecting request: invalid credentials"); + request.removeAttribute(AUTH_HEADER_ATTRIBUTE); + sendErrorResponse(response, HttpStatus.UNAUTHORIZED, + "Invalid hawkBit credentials."); + break; + + case HAWKBIT_ERROR: + log.warn("Rejecting request: hawkBit unavailable"); + request.removeAttribute(AUTH_HEADER_ATTRIBUTE); + sendErrorResponse(response, HttpStatus.SERVICE_UNAVAILABLE, + "Unable to validate credentials. hawkBit may be unavailable."); + break; + } + } + + 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)); + } + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpToolConfiguration.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpToolConfiguration.java new file mode 100644 index 0000000000..a9bf1b70b4 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/config/McpToolConfiguration.java @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +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. + *

+ *

    + *
  • All beans use {@code @ConditionalOnMissingBean} - override by defining your own bean
  • + *
  • Properties allow disabling built-in tools/resources/prompts
  • + *
  • Spring AI MCP auto-discovers {@code @Tool}, {@code @McpResource}, and {@code @McpPrompt} annotations
  • + *
+ *

+ */ +@Configuration +public class McpToolConfiguration { + + /** + * Creates the hawkBit tool provider. + *

+ * 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. + *

+ */ + @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. + *

+ * 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}. + *

+ */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "hawkbit.mcp.resources-enabled", havingValue = "true", matchIfMissing = true) + public HawkBitDocumentationResource hawkBitDocumentationResource() { + return new HawkBitDocumentationResource(); + } + + /** + * Creates the hawkBit prompt provider. + *

+ * 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}. + *

+ */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(name = "hawkbit.mcp.prompts-enabled", havingValue = "true", matchIfMissing = true) + public HawkBitPromptProvider hawkBitPromptProvider() { + return new HawkBitPromptProvider(); + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ActionOperation.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ActionOperation.java new file mode 100644 index 0000000000..2a5bce899e --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ActionOperation.java @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +/** + * Operations for action management (actions are created indirectly via DS assignment). + */ +public enum ActionOperation { + DELETE, + DELETE_BATCH +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ListRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ListRequest.java new file mode 100644 index 0000000000..c165df4678 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ListRequest.java @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +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; + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageActionRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageActionRequest.java new file mode 100644 index 0000000000..af373a0a24 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageActionRequest.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import java.util.List; + +/** + * Request wrapper for action management operations. + * Actions are created indirectly via distribution set assignment, so only DELETE operations are supported. + * + * @param operation the operation to perform (DELETE, DELETE_BATCH) + * @param actionId the action ID (for DELETE single action) + * @param actionIds list of action IDs (for DELETE_BATCH) + * @param rsql RSQL filter query (alternative for DELETE_BATCH) + */ +public record ManageActionRequest( + ActionOperation operation, + Long actionId, + List actionIds, + String rsql +) {} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageDistributionSetRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageDistributionSetRequest.java new file mode 100644 index 0000000000..33612efa4f --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageDistributionSetRequest.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSetRequestBodyPost; +import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSetRequestBodyPut; + +/** + * Request wrapper for distribution set management operations. + * Reuses existing {@link MgmtDistributionSetRequestBodyPost} for CREATE and + * {@link MgmtDistributionSetRequestBodyPut} for UPDATE. + * + * @param operation the operation to perform (CREATE, UPDATE, DELETE) + * @param distributionSetId the distribution set ID (required for UPDATE/DELETE) + * @param createBody the request body for CREATE operation + * @param updateBody the request body for UPDATE operation + */ +public record ManageDistributionSetRequest( + Operation operation, + Long distributionSetId, + MgmtDistributionSetRequestBodyPost createBody, + MgmtDistributionSetRequestBodyPut updateBody +) {} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageRolloutRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageRolloutRequest.java new file mode 100644 index 0000000000..21ff963511 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageRolloutRequest.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutRestRequestBodyPost; +import org.eclipse.hawkbit.mgmt.json.model.rollout.MgmtRolloutRestRequestBodyPut; + +/** + * Request wrapper for rollout management operations including CRUD and lifecycle. + * Reuses existing {@link MgmtRolloutRestRequestBodyPost} for CREATE and + * {@link MgmtRolloutRestRequestBodyPut} for UPDATE. + * + * @param operation the operation to perform (CREATE, UPDATE, DELETE, START, PAUSE, STOP, RESUME, APPROVE, DENY, RETRY, TRIGGER_NEXT_GROUP) + * @param rolloutId the rollout ID (required for UPDATE/DELETE/lifecycle operations) + * @param createBody the request body for CREATE operation + * @param updateBody the request body for UPDATE operation + * @param remark optional remark for APPROVE/DENY operations + */ +public record ManageRolloutRequest( + RolloutOperation operation, + Long rolloutId, + MgmtRolloutRestRequestBodyPost createBody, + MgmtRolloutRestRequestBodyPut updateBody, + String remark +) {} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageSoftwareModuleRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageSoftwareModuleRequest.java new file mode 100644 index 0000000000..ae3b4dbaa1 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageSoftwareModuleRequest.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleRequestBodyPost; +import org.eclipse.hawkbit.mgmt.json.model.softwaremodule.MgmtSoftwareModuleRequestBodyPut; + +/** + * Request wrapper for software module management operations. + * Reuses existing {@link MgmtSoftwareModuleRequestBodyPost} for CREATE and + * {@link MgmtSoftwareModuleRequestBodyPut} for UPDATE. + * + * @param operation the operation to perform (CREATE, UPDATE, DELETE) + * @param softwareModuleId the software module ID (required for UPDATE/DELETE) + * @param createBody the request body for CREATE operation + * @param updateBody the request body for UPDATE operation + */ +public record ManageSoftwareModuleRequest( + Operation operation, + Long softwareModuleId, + MgmtSoftwareModuleRequestBodyPost createBody, + MgmtSoftwareModuleRequestBodyPut updateBody +) {} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageTargetFilterRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageTargetFilterRequest.java new file mode 100644 index 0000000000..b1537563df --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageTargetFilterRequest.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQueryRequestBody; + +/** + * Request wrapper for target filter query management operations. + * Reuses existing {@link MgmtTargetFilterQueryRequestBody} for CREATE/UPDATE data. + * + * @param operation the operation to perform (CREATE, UPDATE, DELETE) + * @param filterId the target filter query ID (required for UPDATE/DELETE) + * @param body the request body containing filter data (for CREATE/UPDATE) + */ +public record ManageTargetFilterRequest( + Operation operation, + Long filterId, + MgmtTargetFilterQueryRequestBody body +) {} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageTargetRequest.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageTargetRequest.java new file mode 100644 index 0000000000..2ace1c616b --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/ManageTargetRequest.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import org.eclipse.hawkbit.mgmt.json.model.target.MgmtTargetRequestBody; + +/** + * Request wrapper for target management operations. + * Reuses existing {@link MgmtTargetRequestBody} for CREATE/UPDATE data. + * + * @param operation the operation to perform (CREATE, UPDATE, DELETE) + * @param controllerId the target controller ID (required for UPDATE/DELETE, used as identifier for CREATE) + * @param body the request body containing target data (for CREATE/UPDATE) + */ +public record ManageTargetRequest( + Operation operation, + String controllerId, + MgmtTargetRequestBody body +) {} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/Operation.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/Operation.java new file mode 100644 index 0000000000..5987bcd376 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/Operation.java @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +/** + * Standard CRUD operations for entity management tools. + */ +public enum Operation { + CREATE, + UPDATE, + DELETE +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/OperationResponse.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/OperationResponse.java new file mode 100644 index 0000000000..910eda378b --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/OperationResponse.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +/** + * Unified response wrapper for management operations. + * + * @param 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( + String operation, + boolean success, + String message, + T data +) { + + /** + * Creates a successful response with data. + */ + public static OperationResponse 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 OperationResponse 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 OperationResponse 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 OperationResponse failure(final String operation, final String message) { + return new OperationResponse<>(operation, false, message, null); + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/PagedResponse.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/PagedResponse.java new file mode 100644 index 0000000000..62e121d89d --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/PagedResponse.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +import java.util.List; + +/** + * Generic paged response for MCP tool results. + * + * @param the type of items in the response + */ +public record PagedResponse( + List content, + long total, + int offset, + int limit +) { + + public static PagedResponse of(final List content, final long total, final int offset, final int limit) { + return new PagedResponse<>(content, total, offset, limit); + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/RolloutOperation.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/RolloutOperation.java new file mode 100644 index 0000000000..f48113c253 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/dto/RolloutOperation.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.hawkbit.mcp.server.dto; + +/** + * Operations for rollout management including CRUD and lifecycle operations. + */ +public enum RolloutOperation { + CREATE, + UPDATE, + DELETE, + START, + PAUSE, + STOP, + RESUME, + APPROVE, + DENY, + RETRY, + TRIGGER_NEXT_GROUP +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/prompts/HawkBitPromptProvider.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/prompts/HawkBitPromptProvider.java new file mode 100644 index 0000000000..7b4dbf9db4 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/prompts/HawkBitPromptProvider.java @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +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. + *

+ * These prompts help LLMs understand what hawkBit is and what documentation + * resources are available at the start of a session. + *

+ */ +@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."; + } + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/resources/HawkBitDocumentationResource.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/resources/HawkBitDocumentationResource.java new file mode 100644 index 0000000000..2f05a5fe55 --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/resources/HawkBitDocumentationResource.java @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +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/"; + } + } +} diff --git a/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/tools/HawkBitMcpToolProvider.java b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/tools/HawkBitMcpToolProvider.java new file mode 100644 index 0000000000..ec6a14d51f --- /dev/null +++ b/hawkbit-mcp/src/main/java/org/eclipse/hawkbit/mcp/server/tools/HawkBitMcpToolProvider.java @@ -0,0 +1,553 @@ +/** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ +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.ActionOperation; +import org.eclipse.hawkbit.mcp.server.dto.ListRequest; +import org.eclipse.hawkbit.mcp.server.dto.ManageActionRequest; +import org.eclipse.hawkbit.mcp.server.dto.ManageDistributionSetRequest; +import org.eclipse.hawkbit.mcp.server.dto.ManageRolloutRequest; +import org.eclipse.hawkbit.mcp.server.dto.ManageSoftwareModuleRequest; +import org.eclipse.hawkbit.mcp.server.dto.ManageTargetFilterRequest; +import org.eclipse.hawkbit.mcp.server.dto.ManageTargetRequest; +import org.eclipse.hawkbit.mcp.server.dto.Operation; +import org.eclipse.hawkbit.mcp.server.dto.OperationResponse; +import org.eclipse.hawkbit.mcp.server.dto.PagedResponse; +import org.eclipse.hawkbit.mcp.server.dto.RolloutOperation; +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. + *

+ * Provides tools for managing targets, rollouts, distribution sets, actions, + * software modules, and target filter queries via the hawkBit REST API. + *

+ */ +@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 PagedResponse toPagedResponse(final PagedList 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 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> 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 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> 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 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> 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 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> 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 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> 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 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> 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), " + + "UPDATE (modify existing target by controllerId), " + + "DELETE (remove target by controllerId)") + public OperationResponse manageTarget(final ManageTargetRequest request) { + validateOperation(request.operation(), "targets"); + log.debug("Managing target: operation={}, controllerId={}", request.operation(), request.controllerId()); + + final MgmtTargetRestApi api = hawkbitClient.mgmtService(MgmtTargetRestApi.class, dummyTenant); + + return switch (request.operation()) { + case CREATE -> { + if (request.body() == null) { + yield OperationResponse.failure(OP_CREATE, "Request body is required for CREATE operation"); + } + final ResponseEntity> response = api.createTargets(List.of(request.body())); + final List created = response.getBody(); + yield OperationResponse.success(OP_CREATE, "Target created successfully", + created != null && !created.isEmpty() ? created.get(0) : null); + } + case UPDATE -> { + if (request.controllerId() == null || request.controllerId().isBlank()) { + yield OperationResponse.failure(OP_UPDATE, "controllerId is required for UPDATE operation"); + } + if (request.body() == null) { + yield OperationResponse.failure(OP_UPDATE, "Request body is required for UPDATE operation"); + } + final ResponseEntity response = api.updateTarget(request.controllerId(), request.body()); + yield OperationResponse.success(OP_UPDATE, "Target updated successfully", response.getBody()); + } + case DELETE -> { + if (request.controllerId() == null || request.controllerId().isBlank()) { + yield OperationResponse.failure(OP_DELETE, "controllerId is required for DELETE operation"); + } + api.deleteTarget(request.controllerId()); + yield OperationResponse.success(OP_DELETE, "Target deleted successfully"); + } + }; + } + + @McpTool(name = "manage_rollout", + description = "Create, update, delete, and control rollouts for software deployment. " + + "Operations: CREATE (new rollout), UPDATE (modify rollout), DELETE (remove rollout), " + + "START (begin rollout), PAUSE, STOP, RESUME, APPROVE, DENY, RETRY, TRIGGER_NEXT_GROUP") + public OperationResponse manageRollout(final ManageRolloutRequest request) { + validateRolloutOperation(request.operation()); + log.debug("Managing rollout: operation={}, rolloutId={}", request.operation(), request.rolloutId()); + + final MgmtRolloutRestApi api = hawkbitClient.mgmtService(MgmtRolloutRestApi.class, dummyTenant); + + return switch (request.operation()) { + case CREATE -> { + if (request.createBody() == null) { + yield OperationResponse.failure(OP_CREATE, "createBody is required for CREATE operation"); + } + final ResponseEntity response = api.create(request.createBody()); + yield OperationResponse.success(OP_CREATE, "Rollout created successfully", response.getBody()); + } + case UPDATE -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_UPDATE, "rolloutId is required for UPDATE operation"); + } + if (request.updateBody() == null) { + yield OperationResponse.failure(OP_UPDATE, "updateBody is required for UPDATE operation"); + } + final ResponseEntity response = api.update(request.rolloutId(), request.updateBody()); + yield OperationResponse.success(OP_UPDATE, "Rollout updated successfully", response.getBody()); + } + case DELETE -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_DELETE, "rolloutId is required for DELETE operation"); + } + api.delete(request.rolloutId()); + yield OperationResponse.success(OP_DELETE, "Rollout deleted successfully"); + } + case START -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_START, "rolloutId is required for START operation"); + } + api.start(request.rolloutId()); + yield OperationResponse.success(OP_START, "Rollout started successfully"); + } + case PAUSE -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_PAUSE, "rolloutId is required for PAUSE operation"); + } + api.pause(request.rolloutId()); + yield OperationResponse.success(OP_PAUSE, "Rollout paused successfully"); + } + case STOP -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_STOP, "rolloutId is required for STOP operation"); + } + api.stop(request.rolloutId()); + yield OperationResponse.success(OP_STOP, "Rollout stopped successfully"); + } + case RESUME -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_RESUME, "rolloutId is required for RESUME operation"); + } + api.resume(request.rolloutId()); + yield OperationResponse.success(OP_RESUME, "Rollout resumed successfully"); + } + case APPROVE -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_APPROVE, "rolloutId is required for APPROVE operation"); + } + api.approve(request.rolloutId(), request.remark()); + yield OperationResponse.success(OP_APPROVE, "Rollout approved successfully"); + } + case DENY -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_DENY, "rolloutId is required for DENY operation"); + } + api.deny(request.rolloutId(), request.remark()); + yield OperationResponse.success(OP_DENY, "Rollout denied successfully"); + } + case RETRY -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_RETRY, "rolloutId is required for RETRY operation"); + } + final ResponseEntity response = api.retryRollout(request.rolloutId()); + yield OperationResponse.success(OP_RETRY, "Rollout retry created successfully", response.getBody()); + } + case TRIGGER_NEXT_GROUP -> { + if (request.rolloutId() == null) { + yield OperationResponse.failure(OP_TRIGGER_NEXT_GROUP, "rolloutId is required for TRIGGER_NEXT_GROUP operation"); + } + api.triggerNextGroup(request.rolloutId()); + yield OperationResponse.success(OP_TRIGGER_NEXT_GROUP, "Next rollout group triggered successfully"); + } + }; + } + + @McpTool(name = "manage_distribution_set", + description = "Create, update, or delete distribution sets (software packages). " + + "Operations: CREATE (new distribution set with name, version, type), " + + "UPDATE (modify existing distribution set), DELETE (remove distribution set)") + public OperationResponse manageDistributionSet(final ManageDistributionSetRequest request) { + validateOperation(request.operation(), "distributionSets"); + log.debug("Managing distribution set: operation={}, distributionSetId={}", request.operation(), request.distributionSetId()); + + final MgmtDistributionSetRestApi api = hawkbitClient.mgmtService(MgmtDistributionSetRestApi.class, dummyTenant); + + return switch (request.operation()) { + case CREATE -> { + if (request.createBody() == null) { + yield OperationResponse.failure(OP_CREATE, "createBody is required for CREATE operation"); + } + final ResponseEntity> response = api.createDistributionSets(List.of(request.createBody())); + final List created = response.getBody(); + yield OperationResponse.success(OP_CREATE, "Distribution set created successfully", + created != null && !created.isEmpty() ? created.get(0) : null); + } + case UPDATE -> { + if (request.distributionSetId() == null) { + yield OperationResponse.failure(OP_UPDATE, "distributionSetId is required for UPDATE operation"); + } + if (request.updateBody() == null) { + yield OperationResponse.failure(OP_UPDATE, "updateBody is required for UPDATE operation"); + } + final ResponseEntity response = api.updateDistributionSet(request.distributionSetId(), request.updateBody()); + yield OperationResponse.success(OP_UPDATE, "Distribution set updated successfully", response.getBody()); + } + case DELETE -> { + if (request.distributionSetId() == null) { + yield OperationResponse.failure(OP_DELETE, "distributionSetId is required for DELETE operation"); + } + api.deleteDistributionSet(request.distributionSetId()); + yield OperationResponse.success(OP_DELETE, "Distribution set deleted successfully"); + } + }; + } + + @McpTool(name = "manage_action", + description = "Delete deployment actions. Actions are created indirectly via distribution set assignment. " + + "Operations: DELETE (single action by ID), DELETE_BATCH (multiple actions by RSQL filter or list of IDs)") + public OperationResponse manageAction(final ManageActionRequest request) { + validateActionOperation(request.operation()); + log.debug("Managing action: operation={}, actionId={}", request.operation(), request.actionId()); + + final MgmtActionRestApi api = hawkbitClient.mgmtService(MgmtActionRestApi.class, dummyTenant); + + return switch (request.operation()) { + case DELETE -> { + if (request.actionId() == null) { + yield OperationResponse.failure(OP_DELETE, "actionId is required for DELETE operation"); + } + api.deleteAction(request.actionId()); + yield OperationResponse.success(OP_DELETE, "Action deleted successfully"); + } + case DELETE_BATCH -> { + if ((request.actionIds() == null || request.actionIds().isEmpty()) && + (request.rsql() == null || request.rsql().isBlank())) { + yield OperationResponse.failure(OP_DELETE_BATCH, "Either actionIds or rsql is required for DELETE_BATCH operation"); + } + api.deleteActions(request.rsql(), request.actionIds()); + yield OperationResponse.success(OP_DELETE_BATCH, "Actions deleted successfully"); + } + }; + } + + @McpTool(name = "manage_software_module", + description = "Create, update, or delete software modules. " + + "Operations: CREATE (new software module with name, version, type), " + + "UPDATE (modify existing software module), DELETE (remove software module)") + public OperationResponse manageSoftwareModule(final ManageSoftwareModuleRequest request) { + validateOperation(request.operation(), "softwareModules"); + log.debug("Managing software module: operation={}, softwareModuleId={}", request.operation(), request.softwareModuleId()); + + final MgmtSoftwareModuleRestApi api = hawkbitClient.mgmtService(MgmtSoftwareModuleRestApi.class, dummyTenant); + + return switch (request.operation()) { + case CREATE -> { + if (request.createBody() == null) { + yield OperationResponse.failure(OP_CREATE, "createBody is required for CREATE operation"); + } + final ResponseEntity> response = api.createSoftwareModules(List.of(request.createBody())); + final List created = response.getBody(); + yield OperationResponse.success(OP_CREATE, "Software module created successfully", + created != null && !created.isEmpty() ? created.get(0) : null); + } + case UPDATE -> { + if (request.softwareModuleId() == null) { + yield OperationResponse.failure(OP_UPDATE, "softwareModuleId is required for UPDATE operation"); + } + if (request.updateBody() == null) { + yield OperationResponse.failure(OP_UPDATE, "updateBody is required for UPDATE operation"); + } + final ResponseEntity response = api.updateSoftwareModule(request.softwareModuleId(), request.updateBody()); + yield OperationResponse.success(OP_UPDATE, "Software module updated successfully", response.getBody()); + } + case DELETE -> { + if (request.softwareModuleId() == null) { + yield OperationResponse.failure(OP_DELETE, "softwareModuleId is required for DELETE operation"); + } + api.deleteSoftwareModule(request.softwareModuleId()); + yield OperationResponse.success(OP_DELETE, "Software module deleted successfully"); + } + }; + } + + @McpTool(name = "manage_target_filter", + description = "Create, update, or delete target filter queries. " + + "Operations: CREATE (new target filter with name and RSQL query), " + + "UPDATE (modify existing target filter), DELETE (remove target filter)") + public OperationResponse manageTargetFilter(final ManageTargetFilterRequest request) { + validateOperation(request.operation(), "targetFilters"); + log.debug("Managing target filter: operation={}, filterId={}", request.operation(), request.filterId()); + + final MgmtTargetFilterQueryRestApi api = hawkbitClient.mgmtService(MgmtTargetFilterQueryRestApi.class, dummyTenant); + + return switch (request.operation()) { + case CREATE -> { + if (request.body() == null) { + yield OperationResponse.failure(OP_CREATE, "Request body is required for CREATE operation"); + } + final ResponseEntity response = api.createFilter(request.body()); + yield OperationResponse.success(OP_CREATE, "Target filter created successfully", response.getBody()); + } + case UPDATE -> { + if (request.filterId() == null) { + yield OperationResponse.failure(OP_UPDATE, "filterId is required for UPDATE operation"); + } + if (request.body() == null) { + yield OperationResponse.failure(OP_UPDATE, "Request body is required for UPDATE operation"); + } + final ResponseEntity response = api.updateFilter(request.filterId(), request.body()); + yield OperationResponse.success(OP_UPDATE, "Target filter updated successfully", response.getBody()); + } + case DELETE -> { + if (request.filterId() == null) { + yield OperationResponse.failure(OP_DELETE, "filterId is required for DELETE operation"); + } + api.deleteFilter(request.filterId()); + yield OperationResponse.success(OP_DELETE, "Target filter deleted successfully"); + } + }; + } + + + private void validateOperation(final Operation operation, final String entity) { + final String opName = operation.name().toLowerCase(); + if (!isOperationEnabled(opName, entity)) { + throw new IllegalArgumentException( + "Operation " + operation + " is not enabled for " + entity + + ". Check hawkbit.mcp.operations configuration."); + } + } + + private void validateRolloutOperation(final RolloutOperation operation) { + final String opName = operation.name().toLowerCase().replace("_", "-"); + final HawkBitMcpProperties.RolloutConfig config = properties.getOperations().getRollouts(); + final Boolean entitySetting = config.getOperationEnabled(opName); + + // For standard CRUD ops, check global fallback + if (entitySetting == null) { + if (!properties.getOperations().isGlobalOperationEnabled(opName)) { + throw new IllegalArgumentException( + "Operation " + operation + " is not enabled for rollouts. " + + "Check hawkbit.mcp.operations configuration."); + } + return; + } + + if (!entitySetting) { + throw new IllegalArgumentException( + "Operation " + operation + " is not enabled for rollouts. " + + "Check hawkbit.mcp.operations configuration."); + } + } + + private void validateActionOperation(final ActionOperation operation) { + final String opName = operation.name().toLowerCase().replace("_", "-"); + final HawkBitMcpProperties.ActionConfig config = properties.getOperations().getActions(); + final Boolean entitySetting = config.getOperationEnabled(opName); + + if (entitySetting == null) { + if (opName.equals("delete") && !properties.getOperations().isGlobalOperationEnabled("delete")) { + throw new IllegalArgumentException( + "Operation " + operation + " is not enabled for actions. " + + "Check hawkbit.mcp.operations configuration."); + } + return; + } + + if (!entitySetting) { + throw new IllegalArgumentException( + "Operation " + operation + " 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; + }; + } +} diff --git a/hawkbit-mcp/src/main/resources/application.properties b/hawkbit-mcp/src/main/resources/application.properties new file mode 100644 index 0000000000..e5472e16fe --- /dev/null +++ b/hawkbit-mcp/src/main/resources/application.properties @@ -0,0 +1,51 @@ +# +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# This program and the accompanying materials are made +# available under the terms of the Eclipse Public License 2.0 +# which is available at https://www.eclipse.org/legal/epl-2.0/ +# +# SPDX-License-Identifier: EPL-2.0 +# + +# Server configuration +server.port=8081 + +# 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.enabled=true +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 diff --git a/hawkbit-mcp/src/main/resources/hawkbit-entity-definitions.md b/hawkbit-mcp/src/main/resources/hawkbit-entity-definitions.md new file mode 100644 index 0000000000..a5768c4580 --- /dev/null +++ b/hawkbit-mcp/src/main/resources/hawkbit-entity-definitions.md @@ -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.` | Metadata value by key | String | +| `controllerAttributes.` | 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.` | 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.` | 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* +``` diff --git a/hawkbit-mcp/src/main/resources/prompts/hawkbit-context.md b/hawkbit-mcp/src/main/resources/prompts/hawkbit-context.md new file mode 100644 index 0000000000..39599f3a67 --- /dev/null +++ b/hawkbit-mcp/src/main/resources/prompts/hawkbit-context.md @@ -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` diff --git a/hawkbit-mcp/src/main/resources/prompts/rsql-help.md b/hawkbit-mcp/src/main/resources/prompts/rsql-help.md new file mode 100644 index 0000000000..39430c6923 --- /dev/null +++ b/hawkbit-mcp/src/main/resources/prompts/rsql-help.md @@ -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`. diff --git a/hawkbit-monolith/hawkbit-update-server/src/main/resources/application.properties b/hawkbit-monolith/hawkbit-update-server/src/main/resources/application.properties index b53cfc9d7d..f8616403c2 100644 --- a/hawkbit-monolith/hawkbit-update-server/src/main/resources/application.properties +++ b/hawkbit-monolith/hawkbit-update-server/src/main/resources/application.properties @@ -68,4 +68,5 @@ springdoc.packages-to-scan=org.eclipse.hawkbit.mgmt,org.eclipse.hawkbit.ddi springdoc.paths-to-exclude=/system/** springdoc.swagger-ui.enabled=true springdoc.swagger-ui.csrf.enabled=true + springdoc.swagger-ui.doc-expansion=none \ No newline at end of file diff --git a/pom.xml b/pom.xml index 43125a1201..65ca73a45e 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,8 @@ 2.21.0 4.5.0 1.8.0 + + 1.1.2 5.4.0 4.8.184 @@ -241,6 +243,14 @@ pom import + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + org.springframework.boot spring-boot-starter-web @@ -727,6 +737,7 @@ hawkbit-mgmt hawkbit-ddi hawkbit-dmf + hawkbit-mcp hawkbit-monolith hawkbit-ui